"""Step definitions for tool lifecycle runtime feature tests."""

from __future__ import annotations

from datetime import UTC, datetime
from typing import Any

from behave import given, then, when
from behave.runner import Context

from cleveragents.domain.models.core.tool import (
    Tool,
    ToolCapability,
    ToolSource,
    ToolType,
)
from cleveragents.infrastructure.events.reactive import ReactiveEventBus
from cleveragents.tool.context import (
    BoundResource,
    CancellationToken,
    Change,
    ChangeOperation,
    ToolCancelledError,
    ToolExecutionContext,
    ToolExecutionTrace,
)
from cleveragents.tool.lifecycle import (
    ToolAccessDeniedError,
    ToolActivationError,
    ToolCheckpointRequiredError,
    ToolDescriptor,
    ToolExecutionError,
    ToolLifecycleCache,
    ToolResult,
    ToolRuntime,
    ToolRuntimeError,
)
from cleveragents.tool.schema_validator import (
    ToolSchemaValidationError,
    validate_tool_input,
    validate_tool_output,
)

# ---------------------------------------------------------------------------
# Mock tool instance
# ---------------------------------------------------------------------------


class MockToolInstance:
    """A mock ToolInstance for testing."""

    def __init__(self, name: str = "mock/tool", read_only: bool = True) -> None:
        self.name = name
        self.read_only = read_only
        self.activated = False
        self.deactivated = False
        self.activate_count = 0
        self.execute_count = 0
        self.produce_changes = False
        self.return_invalid_output = False

    def discover(self) -> ToolDescriptor:
        return ToolDescriptor(
            name=self.name,
            description=f"Mock tool {self.name}",
            capability=ToolCapability(read_only=self.read_only),
        )

    def activate(self, ctx: ToolExecutionContext) -> None:
        self.activated = True
        self.activate_count += 1

    def execute(self, params: dict[str, Any], ctx: ToolExecutionContext) -> ToolResult:
        self.execute_count += 1
        changes: list[Change] = []
        if self.produce_changes:
            changes.append(
                Change(
                    operation=ChangeOperation.MODIFY,
                    resource_id="res-001",
                    path=params.get("path", "unknown"),
                )
            )

        data: Any = f"result for {params.get('path', 'unknown')}"
        if self.return_invalid_output:
            data = 42  # Intentionally wrong type for schema validation

        return ToolResult(success=True, data=data, changes=changes)

    def deactivate(self, ctx: ToolExecutionContext) -> None:
        self.deactivated = True


class FailingActivateMock(MockToolInstance):
    """Mock that raises on activate()."""

    def activate(self, ctx: ToolExecutionContext) -> None:
        raise RuntimeError("activate boom")


class FailingExecuteMock(MockToolInstance):
    """Mock that raises on execute()."""

    def execute(self, params: dict[str, Any], ctx: ToolExecutionContext) -> ToolResult:
        raise RuntimeError("execute boom")


class FailingDeactivateMock(MockToolInstance):
    """Mock that raises on deactivate()."""

    def deactivate(self, ctx: ToolExecutionContext) -> None:
        raise RuntimeError("deactivate boom")


class ErrorResultMock(MockToolInstance):
    """Mock that returns a ToolResult with error set."""

    def execute(self, params: dict[str, Any], ctx: ToolExecutionContext) -> ToolResult:
        return ToolResult(success=False, data=None, error="something went wrong")


# ---------------------------------------------------------------------------
# ToolExecutionContext steps
# ---------------------------------------------------------------------------


@given('I create a tool execution context with plan_id "{plan_id}"')
def step_create_context(context: Context, plan_id: str) -> None:
    context.tool_ctx = ToolExecutionContext(plan_id=plan_id)


@given('I create a tool execution context with plan_id "{plan_id}" and read_only True')
def step_create_context_read_only(context: Context, plan_id: str) -> None:
    context.tool_ctx = ToolExecutionContext(plan_id=plan_id, plan_read_only=True)


@given(
    'I create a tool execution context with plan_id "{plan_id}" and require_checkpoints True'
)
def step_create_context_checkpoints(context: Context, plan_id: str) -> None:
    context.tool_ctx = ToolExecutionContext(plan_id=plan_id, require_checkpoints=True)


@then('the context plan_id should be "{expected}"')
def step_check_context_plan_id(context: Context, expected: str) -> None:
    assert context.tool_ctx.plan_id == expected


@then("the context should not be read-only")
def step_check_context_not_read_only(context: Context) -> None:
    assert not context.tool_ctx.plan_read_only


@then("the context should be read-only")
def step_check_context_read_only(context: Context) -> None:
    assert context.tool_ctx.plan_read_only


@then("the context should not require checkpoints")
def step_check_context_no_checkpoints(context: Context) -> None:
    assert not context.tool_ctx.require_checkpoints


@then("the context should require checkpoints")
def step_check_context_checkpoints(context: Context) -> None:
    assert context.tool_ctx.require_checkpoints


@then("the context should have {count:d} changes")
def step_check_context_change_count(context: Context, count: int) -> None:
    assert len(context.tool_ctx.changes) == count


@then("the context should have {count:d} traces")
def step_check_context_trace_count(context: Context, count: int) -> None:
    assert len(context.tool_ctx.traces) == count


@then("the context cancellation token should not be cancelled")
def step_check_token_not_cancelled(context: Context) -> None:
    assert not context.tool_ctx.cancellation_token.is_cancelled


# ── Change recording ──────────────────────────────────────────────────


@when('I record a change with operation "{op}" and resource_id "{res_id}"')
def step_record_change(context: Context, op: str, res_id: str) -> None:
    change = Change(operation=ChangeOperation(op), resource_id=res_id)
    context.tool_ctx.record_change(change)


# ── Trace recording ──────────────────────────────────────────────────


@when('I add a trace for tool "{tool_name}" with success {success}')
def step_add_trace(context: Context, tool_name: str, success: str) -> None:
    trace = ToolExecutionTrace(
        tool_name=tool_name,
        started_at=datetime.now(UTC).isoformat(),
        success=success == "True",
    )
    context.tool_ctx.add_trace(trace)


# ── Resource binding ─────────────────────────────────────────────────


@given('I bind resource "{res_id}" to slot "{slot}" with type "{res_type}"')
def step_bind_resource(context: Context, res_id: str, slot: str, res_type: str) -> None:
    bound = BoundResource(
        slot_name=slot,
        resource_id=res_id,
        resource_type=res_type,
    )
    context.tool_ctx.resources[slot] = bound


@when('I get the resource for slot "{slot}"')
def step_get_resource(context: Context, slot: str) -> None:
    context.bound_resource = context.tool_ctx.get_resource(slot)


@then('the bound resource_id should be "{expected}"')
def step_check_bound_resource_id(context: Context, expected: str) -> None:
    assert context.bound_resource.resource_id == expected


@when('I try to get the resource for slot "{slot}"')
def step_try_get_resource(context: Context, slot: str) -> None:
    try:
        context.tool_ctx.get_resource(slot)
        context.resource_error = None
    except KeyError as exc:
        context.resource_error = exc


@then('a KeyError should be raised with message containing "{text}"')
def step_check_key_error(context: Context, text: str) -> None:
    assert context.resource_error is not None
    assert text in str(context.resource_error)


# ── Context summary ──────────────────────────────────────────────────


@then("the context summary should show resource_count {count:d}")
def step_check_summary_resources(context: Context, count: int) -> None:
    summary = context.tool_ctx.as_summary()
    assert summary["resource_count"] == count


@then("the context summary should show change_count {count:d}")
def step_check_summary_changes(context: Context, count: int) -> None:
    summary = context.tool_ctx.as_summary()
    assert summary["change_count"] == count


@then("the context summary should show trace_count {count:d}")
def step_check_summary_traces(context: Context, count: int) -> None:
    summary = context.tool_ctx.as_summary()
    assert summary["trace_count"] == count


# ---------------------------------------------------------------------------
# BoundResource steps
# ---------------------------------------------------------------------------


@given(
    'I create a bound resource with slot "{slot}" and resource_id "{res_id}" and type "{res_type}"'
)
def step_create_bound_resource(
    context: Context, slot: str, res_id: str, res_type: str
) -> None:
    context.bound_resource = BoundResource(
        slot_name=slot,
        resource_id=res_id,
        resource_type=res_type,
    )


@then('the bound resource slot_name should be "{expected}"')
def step_check_bound_slot(context: Context, expected: str) -> None:
    assert context.bound_resource.slot_name == expected


@then('the bound resource access should be "{expected}"')
def step_check_bound_access(context: Context, expected: str) -> None:
    assert context.bound_resource.access == expected


# ---------------------------------------------------------------------------
# CancellationToken steps
# ---------------------------------------------------------------------------


@given("I create a cancellation token")
def step_create_cancellation_token(context: Context) -> None:
    context.cancel_token = CancellationToken()


@when("I cancel the token")
def step_cancel_token(context: Context) -> None:
    context.cancel_token.cancel()


@then("the cancellation token should not be cancelled")
def step_check_token_not_cancelled_standalone(context: Context) -> None:
    assert not context.cancel_token.is_cancelled


@then("the cancellation token should be cancelled")
def step_check_token_cancelled(context: Context) -> None:
    assert context.cancel_token.is_cancelled


@then("calling check on the token should raise ToolCancelledError")
def step_check_token_raises(context: Context) -> None:
    try:
        context.cancel_token.check()
        raise AssertionError("Expected ToolCancelledError")
    except ToolCancelledError:
        pass


# ---------------------------------------------------------------------------
# Change steps
# ---------------------------------------------------------------------------


@given('I create a change with operation "{op}" and resource_id "{res_id}"')
def step_create_change(context: Context, op: str, res_id: str) -> None:
    context.change = Change(operation=ChangeOperation(op), resource_id=res_id)


@then('the change operation should be "{expected}"')
def step_check_change_op(context: Context, expected: str) -> None:
    assert context.change.operation == expected


@then('the change resource_id should be "{expected}"')
def step_check_change_res_id(context: Context, expected: str) -> None:
    assert context.change.resource_id == expected


@then("the change timestamp should be a valid ISO-8601 string")
def step_check_change_timestamp(context: Context) -> None:
    ts = context.change.timestamp
    assert ts is not None
    # Should parse without error
    datetime.fromisoformat(ts)


# ---------------------------------------------------------------------------
# ToolDescriptor steps
# ---------------------------------------------------------------------------


@given('I create a tool descriptor with name "{name}" and description "{desc}"')
def step_create_descriptor(context: Context, name: str, desc: str) -> None:
    context.descriptor = ToolDescriptor(name=name, description=desc)


@then('the descriptor name should be "{expected}"')
def step_check_descriptor_name(context: Context, expected: str) -> None:
    assert context.descriptor.name == expected


@then('the descriptor source should be "{expected}"')
def step_check_descriptor_source(context: Context, expected: str) -> None:
    assert context.descriptor.source == expected


# ---------------------------------------------------------------------------
# ToolResult steps
# ---------------------------------------------------------------------------


@given('I create a tool result with success {success} and data "{data}"')
def step_create_result_success(context: Context, success: str, data: str) -> None:
    context.tool_result = ToolResult(success=success == "True", data=data)


@given('I create a tool result with success {success} and error "{error}"')
def step_create_result_error(context: Context, success: str, error: str) -> None:
    context.tool_result = ToolResult(success=success == "True", error=error)


@then("the tool result should indicate success")
def step_check_result_success(context: Context) -> None:
    assert context.tool_result.success


@then("the tool result should indicate failure")
def step_check_result_not_success(context: Context) -> None:
    assert not context.tool_result.success


@then('the tool result data should be "{expected}"')
def step_check_result_data(context: Context, expected: str) -> None:
    assert context.tool_result.data == expected


@then('the tool result error should be "{expected}"')
def step_check_result_error(context: Context, expected: str) -> None:
    assert context.tool_result.error == expected


# ---------------------------------------------------------------------------
# JSON Schema validation steps
# ---------------------------------------------------------------------------


@given('I have a JSON schema requiring property "{prop}" of type "{prop_type}"')
def step_create_schema(context: Context, prop: str, prop_type: str) -> None:
    context.json_schema = {
        "type": "object",
        "properties": {prop: {"type": prop_type}},
        "required": [prop],
    }


@when('I validate input with path "{value}" against the schema')
def step_validate_input_string(context: Context, value: str) -> None:
    try:
        validate_tool_input({"path": value}, context.json_schema)
        context.schema_error = None
    except ToolSchemaValidationError as exc:
        context.schema_error = exc


@when("I validate input with path {value:d} against the schema")
def step_validate_input_int(context: Context, value: int) -> None:
    try:
        validate_tool_input({"path": value}, context.json_schema)
        context.schema_error = None
    except ToolSchemaValidationError as exc:
        context.schema_error = exc


@then("the input validation should succeed")
def step_check_input_valid(context: Context) -> None:
    assert context.schema_error is None


@then("the input validation should fail with ToolSchemaValidationError")
def step_check_input_invalid(context: Context) -> None:
    assert context.schema_error is not None
    assert isinstance(context.schema_error, ToolSchemaValidationError)


@when('I validate output with content "{value}" against the schema')
def step_validate_output_string(context: Context, value: str) -> None:
    try:
        validate_tool_output({"content": value}, context.json_schema)
        context.schema_error = None
    except ToolSchemaValidationError as exc:
        context.schema_error = exc


@when("I validate output with content {value:d} against the schema")
def step_validate_output_int(context: Context, value: int) -> None:
    try:
        validate_tool_output({"content": value}, context.json_schema)
        context.schema_error = None
    except ToolSchemaValidationError as exc:
        context.schema_error = exc


@then("the output validation should succeed")
def step_check_output_valid(context: Context) -> None:
    assert context.schema_error is None


@then("the output validation should fail with ToolSchemaValidationError")
def step_check_output_invalid(context: Context) -> None:
    assert context.schema_error is not None
    assert isinstance(context.schema_error, ToolSchemaValidationError)


@then("the schema validation error should have errors list")
def step_check_schema_error_details(context: Context) -> None:
    assert context.schema_error is not None
    assert len(context.schema_error.errors) > 0


@then("the schema validation error should reference the schema")
def step_check_schema_error_schema(context: Context) -> None:
    assert context.schema_error is not None
    assert context.schema_error.schema is not None


# ---------------------------------------------------------------------------
# ToolLifecycleCache steps
# ---------------------------------------------------------------------------


@given("I create a tool lifecycle cache")
def step_create_cache(context: Context) -> None:
    context.lifecycle_cache = ToolLifecycleCache()


@given('I have a mock tool instance named "{name}"')
def step_create_mock_instance(context: Context, name: str) -> None:
    context.mock_instance = MockToolInstance(name=name)


@when('I put the instance in cache for plan "{plan_id}" and tool "{tool_name}"')
def step_cache_put(context: Context, plan_id: str, tool_name: str) -> None:
    context.lifecycle_cache.put(plan_id, tool_name, context.mock_instance)


@when('I put the second instance in cache for plan "{plan_id}" and tool "{tool_name}"')
def step_cache_put_second(context: Context, plan_id: str, tool_name: str) -> None:
    # Use a separate instance if available, otherwise create one
    inst = getattr(context, "mock_instance_2", None)
    if inst is None:
        inst = MockToolInstance(name=tool_name)
        context.mock_instance_2 = inst
    context.lifecycle_cache.put(plan_id, tool_name, inst)


@then("the cache should have {count:d} plans")
def step_check_cache_plan_count(context: Context, count: int) -> None:
    assert context.lifecycle_cache.plan_count == count


@then(
    'getting the instance for plan "{plan_id}" and tool "{tool_name}" should return it'
)
def step_cache_get_returns(context: Context, plan_id: str, tool_name: str) -> None:
    result = context.lifecycle_cache.get(plan_id, tool_name)
    assert result is not None


@then(
    'getting the instance for plan "{plan_id}" and tool "{tool_name}" should return None'
)
def step_cache_get_returns_none(context: Context, plan_id: str, tool_name: str) -> None:
    result = context.lifecycle_cache.get(plan_id, tool_name)
    assert result is None


@when('I increment execution for plan "{plan_id}" and tool "{tool_name}"')
def step_cache_increment(context: Context, plan_id: str, tool_name: str) -> None:
    context.lifecycle_cache.increment_execution(plan_id, tool_name)


@then(
    'the cache stats for plan "{plan_id}" should show tool "{tool_name}" with execution_count {count:d}'
)
def step_check_cache_stats_exec(
    context: Context, plan_id: str, tool_name: str, count: int
) -> None:
    stats = context.lifecycle_cache.get_stats(plan_id)
    assert stats["tools"][tool_name]["execution_count"] == count


@then('the plan tools for plan "{plan_id}" should include "{tool_name}"')
def step_check_plan_tools_include(
    context: Context, plan_id: str, tool_name: str
) -> None:
    tools = context.lifecycle_cache.get_plan_tools(plan_id)
    assert tool_name in tools


@when('I remove tool "{tool_name}" from plan "{plan_id}"')
def step_cache_remove_tool(context: Context, tool_name: str, plan_id: str) -> None:
    context.lifecycle_cache.remove(plan_id, tool_name)


@when('I remove plan "{plan_id}" from cache')
def step_cache_remove_plan(context: Context, plan_id: str) -> None:
    context.lifecycle_cache.remove_plan(plan_id)


# ---------------------------------------------------------------------------
# ToolRuntime steps
# ---------------------------------------------------------------------------


def _make_tool(
    name: str,
    *,
    read_only: bool = True,
    writes: bool = False,
    checkpointable: bool = False,
    input_schema: dict[str, Any] | None = None,
    output_schema: dict[str, Any] | None = None,
) -> Tool:
    """Create a minimal Tool domain model for tests."""
    return Tool(
        name=name,
        description=f"Test tool {name}",
        source=ToolSource.BUILTIN,
        tool_type=ToolType.TOOL,
        capability=ToolCapability(
            read_only=read_only,
            writes=writes,
            checkpointable=checkpointable,
        ),
        input_schema=input_schema,
        output_schema=output_schema,
    )


@given("I create a tool runtime")
def step_create_runtime(context: Context) -> None:
    context.tool_runtime = ToolRuntime(event_bus=ReactiveEventBus())
    context.mock_instances = dict[str, MockToolInstance]()


@given('I register a builtin tool "{name}" with a mock instance')
def step_register_tool(context: Context, name: str) -> None:
    tool = _make_tool(name, read_only=False)
    mock = MockToolInstance(name=name, read_only=False)
    context.tool_runtime.register_tool(tool, mock)
    context.mock_instances[name] = mock


@given('I register a builtin tool "{name}" with a mock instance that is read_only')
def step_register_read_only_tool(context: Context, name: str) -> None:
    tool = _make_tool(name, read_only=True)
    mock = MockToolInstance(name=name, read_only=True)
    context.tool_runtime.register_tool(tool, mock)
    context.mock_instances[name] = mock


@given('I register a builtin tool "{name}" that writes')
def step_register_writes_tool(context: Context, name: str) -> None:
    tool = _make_tool(name, read_only=False, writes=True)
    mock = MockToolInstance(name=name, read_only=False)
    context.tool_runtime.register_tool(tool, mock)
    context.mock_instances[name] = mock


@given('I register a builtin tool "{name}" that produces changes')
def step_register_change_tool(context: Context, name: str) -> None:
    tool = _make_tool(name, read_only=False, writes=True)
    mock = MockToolInstance(name=name, read_only=False)
    mock.produce_changes = True
    context.tool_runtime.register_tool(tool, mock)
    context.mock_instances[name] = mock


@given('I register a builtin tool with input schema "{name}"')
def step_register_tool_with_input_schema(context: Context, name: str) -> None:
    schema = {
        "type": "object",
        "properties": {"path": {"type": "string"}},
        "required": ["path"],
    }
    tool = _make_tool(name, read_only=True, input_schema=schema)
    mock = MockToolInstance(name=name, read_only=True)
    context.tool_runtime.register_tool(tool, mock)
    context.mock_instances[name] = mock


@given('I register a builtin tool with output schema "{name}"')
def step_register_tool_with_output_schema(context: Context, name: str) -> None:
    schema = {
        "type": "object",
        "properties": {"result": {"type": "string"}},
        "required": ["result"],
    }
    tool = _make_tool(name, read_only=True, output_schema=schema)
    mock = MockToolInstance(name=name, read_only=True)
    mock.return_invalid_output = True  # Will return int instead of dict
    context.tool_runtime.register_tool(tool, mock)
    context.mock_instances[name] = mock


@given(
    'I register a second builtin tool "{name}" with a mock instance that is read_only'
)
def step_register_second_tool(context: Context, name: str) -> None:
    tool = _make_tool(name, read_only=True)
    mock = MockToolInstance(name=name, read_only=True)
    context.tool_runtime.register_tool(tool, mock)
    context.mock_instances[name] = mock


@when('I unregister tool "{name}"')
def step_unregister_tool(context: Context, name: str) -> None:
    context.tool_runtime.unregister_tool(name)


@then('the tool list should contain "{name}"')
def step_check_tool_in_list(context: Context, name: str) -> None:
    assert name in context.tool_runtime.list_tools()


@then('the tool list should not contain "{name}"')
def step_check_tool_not_in_list(context: Context, name: str) -> None:
    assert name not in context.tool_runtime.list_tools()


@then('getting tool "{name}" should return the tool model')
def step_check_get_tool(context: Context, name: str) -> None:
    tool = context.tool_runtime.get_tool(name)
    assert tool is not None
    assert tool.name == name


@then('getting tool "{name}" should return None')
def step_check_get_tool_none(context: Context, name: str) -> None:
    assert context.tool_runtime.get_tool(name) is None


@when('I discover tool "{name}"')
def step_discover_tool(context: Context, name: str) -> None:
    context.discovered_descriptor = context.tool_runtime.discover(name)


@then('the discovered descriptor name should be "{expected}"')
def step_check_discovered_name(context: Context, expected: str) -> None:
    assert context.discovered_descriptor.name == expected


@when('I activate tool "{name}" in the runtime')
def step_activate_tool(context: Context, name: str) -> None:
    context.tool_runtime.activate(name, context.tool_ctx)


@when('I activate tool "{name}" in the runtime again')
def step_activate_tool_again(context: Context, name: str) -> None:
    context.tool_runtime.activate(name, context.tool_ctx)


@then("the mock instance should have been activated")
def step_check_mock_activated(context: Context) -> None:
    # Find the first mock instance that was activated
    for mock in context.mock_instances.values():
        if mock.activated:
            return
    raise AssertionError("No mock instance was activated")


@then("the mock instance activate count should be {count:d}")
def step_check_mock_activate_count(context: Context, count: int) -> None:
    total = sum(m.activate_count for m in context.mock_instances.values())
    assert total == count


@when('I execute tool "{name}" with params path "{path}"')
def step_execute_tool(context: Context, name: str, path: str) -> None:
    context.exec_result = context.tool_runtime.execute(
        name, {"path": path}, context.tool_ctx
    )


@when('I execute tool "{name}" with invalid params')
def step_execute_tool_invalid(context: Context, name: str) -> None:
    # Pass integer instead of string for 'path'
    context.exec_result = context.tool_runtime.execute(
        name, {"path": 42}, context.tool_ctx
    )


@then("the execution result should be successful")
def step_check_exec_success(context: Context) -> None:
    assert context.exec_result.success


@then("the execution result should not be successful")
def step_check_exec_not_success(context: Context) -> None:
    assert not context.exec_result.success


@then('the execution result error should contain "{text}"')
def step_check_exec_error_contains(context: Context, text: str) -> None:
    assert context.exec_result.error is not None
    assert text in context.exec_result.error


# ── Access denied / checkpoint required ──────────────────────────────


@when('I try to execute tool "{name}"')
def step_try_execute_tool(context: Context, name: str) -> None:
    try:
        context.tool_runtime.execute(name, {}, context.tool_ctx)
        context.access_error = None
    except ToolAccessDeniedError as exc:
        context.access_error = exc
    except ToolCheckpointRequiredError as exc:
        context.access_error = exc


@then("a ToolAccessDeniedError should be raised")
def step_check_access_denied(context: Context) -> None:
    assert isinstance(context.access_error, ToolAccessDeniedError)


@when('I try to activate tool "{name}" for checkpoint plan')
def step_try_activate_checkpoint(context: Context, name: str) -> None:
    try:
        context.tool_runtime.activate(name, context.tool_ctx)
        context.checkpoint_error = None
    except ToolCheckpointRequiredError as exc:
        context.checkpoint_error = exc


@then("a ToolCheckpointRequiredError should be raised")
def step_check_checkpoint_required(context: Context) -> None:
    assert isinstance(context.checkpoint_error, ToolCheckpointRequiredError)


# ── Cancellation ─────────────────────────────────────────────────────


@given("I cancel the context cancellation token")
def step_cancel_context_token(context: Context) -> None:
    context.tool_ctx.cancellation_token.cancel()


@when('I try to execute tool "{name}" after cancellation')
def step_try_execute_cancelled(context: Context, name: str) -> None:
    try:
        context.tool_runtime.execute(name, {"path": "test.txt"}, context.tool_ctx)
        context.cancel_error = None
    except ToolCancelledError as exc:
        context.cancel_error = exc


@then("a ToolCancelledError should be raised from execution")
def step_check_cancelled_error(context: Context) -> None:
    assert isinstance(context.cancel_error, ToolCancelledError)


# ── Deactivation ─────────────────────────────────────────────────────


@when('I deactivate tool "{name}" in the runtime')
def step_deactivate_tool(context: Context, name: str) -> None:
    context.tool_runtime.deactivate(name, context.tool_ctx)


@then("the mock instance should have been deactivated")
def step_check_mock_deactivated(context: Context) -> None:
    for mock in context.mock_instances.values():
        if mock.deactivated:
            return
    raise AssertionError("No mock instance was deactivated")


@when('I deactivate plan "{plan_id}" in the runtime')
def step_deactivate_plan(context: Context, plan_id: str) -> None:
    context.tool_runtime.deactivate_plan(context.tool_ctx)


@then("all mock instances should have been deactivated")
def step_check_all_deactivated(context: Context) -> None:
    for mock in context.mock_instances.values():
        assert mock.deactivated, f"Mock {mock.name} was not deactivated"


# ── Unregistered tool ────────────────────────────────────────────────


@when('I try to execute unregistered tool "{name}"')
def step_try_execute_unregistered(context: Context, name: str) -> None:
    try:
        context.tool_runtime.execute(name, {}, context.tool_ctx)
        context.runtime_error = None
    except ToolRuntimeError as exc:
        context.runtime_error = exc


@then('a ToolRuntimeError should be raised with message containing "{text}"')
def step_check_runtime_error(context: Context, text: str) -> None:
    assert context.runtime_error is not None
    assert text in str(context.runtime_error)


# ── Cache stats ──────────────────────────────────────────────────────


@then('the cache stats for plan "{plan_id}" should show {count:d} active tools')
def step_check_runtime_cache_stats(context: Context, plan_id: str, count: int) -> None:
    stats = context.tool_runtime.get_cache_stats(plan_id)
    assert stats["active_tools"] == count


# ── Trace assertions ─────────────────────────────────────────────────


@then("the last trace should have a non-negative duration_ms")
def step_check_trace_duration(context: Context) -> None:
    traces = context.tool_ctx.traces
    assert len(traces) > 0
    last = traces[-1]
    assert last.duration_ms is not None
    assert last.duration_ms >= 0


# ── ToolExecutionTrace steps ─────────────────────────────────────────


@given('I create a tool execution trace for "{name}"')
def step_create_trace(context: Context, name: str) -> None:
    context.trace = ToolExecutionTrace(
        tool_name=name,
        started_at=datetime.now(UTC).isoformat(),
    )


@then('the trace tool_name should be "{expected}"')
def step_check_trace_name(context: Context, expected: str) -> None:
    assert context.trace.tool_name == expected


@then("the trace should have a started_at timestamp")
def step_check_trace_started(context: Context) -> None:
    assert context.trace.started_at is not None
    datetime.fromisoformat(context.trace.started_at)


# ---------------------------------------------------------------------------
# Scenario: Activation failure raises ToolActivationError
# ---------------------------------------------------------------------------


@given('I register a builtin tool "{name}" with a failing activate mock')
def step_register_failing_activate(context: Context, name: str) -> None:
    tool = _make_tool(name, read_only=True)
    mock = FailingActivateMock(name=name, read_only=True)
    context.tool_runtime.register_tool(tool, mock)
    context.mock_instances[name] = mock


@when('I try to activate the failing tool "{name}"')
def step_try_activate_failing(context: Context, name: str) -> None:
    try:
        context.tool_runtime.activate(name, context.tool_ctx)
        context.activation_error = None
    except ToolActivationError as exc:
        context.activation_error = exc


@then("a ToolActivationError should be raised")
def step_check_activation_error(context: Context) -> None:
    assert isinstance(context.activation_error, ToolActivationError)


# ---------------------------------------------------------------------------
# Scenario: Execution failure raises ToolExecutionError
# ---------------------------------------------------------------------------


@given('I register a builtin tool "{name}" with a failing execute mock')
def step_register_failing_execute(context: Context, name: str) -> None:
    tool = _make_tool(name, read_only=True)
    mock = FailingExecuteMock(name=name, read_only=True)
    context.tool_runtime.register_tool(tool, mock)
    context.mock_instances[name] = mock


@when('I try to execute the crashing tool "{name}"')
def step_try_execute_crashing(context: Context, name: str) -> None:
    try:
        context.tool_runtime.execute(name, {"path": "test.txt"}, context.tool_ctx)
        context.execution_error = None
    except ToolExecutionError as exc:
        context.execution_error = exc


@then("a ToolExecutionError should be raised")
def step_check_execution_error(context: Context) -> None:
    assert isinstance(context.execution_error, ToolExecutionError)


@then("the last trace should show failure")
def step_check_last_trace_failure(context: Context) -> None:
    traces = context.tool_ctx.traces
    assert len(traces) > 0
    assert not traces[-1].success


# ---------------------------------------------------------------------------
# Scenario: Deactivation failure is non-fatal warning
# ---------------------------------------------------------------------------


@given('I register a builtin tool "{name}" with a failing deactivate mock')
def step_register_failing_deactivate(context: Context, name: str) -> None:
    tool = _make_tool(name, read_only=True)
    mock = FailingDeactivateMock(name=name, read_only=True)
    context.tool_runtime.register_tool(tool, mock)
    context.mock_instances[name] = mock
    context.deactivation_exception = None


@then("no exception should have been raised from deactivation")
def step_check_no_deactivation_exception(context: Context) -> None:
    # If we got here, no exception was raised (deactivation is non-fatal)
    assert context.deactivation_exception is None


# ---------------------------------------------------------------------------
# Scenario: Cache plan_count property tracks plans
# ---------------------------------------------------------------------------


@then("the runtime cache plan count should be {count:d}")
def step_check_runtime_plan_count(context: Context, count: int) -> None:
    assert context.tool_runtime.cache_plan_count == count


# ---------------------------------------------------------------------------
# Scenario: Discover unregistered tool raises ToolRuntimeError
# ---------------------------------------------------------------------------


@when('I try to discover unregistered tool "{name}"')
def step_try_discover_unregistered(context: Context, name: str) -> None:
    try:
        context.tool_runtime.discover(name)
        context.runtime_error = None
    except ToolRuntimeError as exc:
        context.runtime_error = exc


# ---------------------------------------------------------------------------
# Scenario: Execution with result error records trace error field
# ---------------------------------------------------------------------------


@given('I register a builtin tool "{name}" with a mock that returns error result')
def step_register_error_result_mock(context: Context, name: str) -> None:
    tool = _make_tool(name, read_only=True)
    mock = ErrorResultMock(name=name, read_only=True)
    context.tool_runtime.register_tool(tool, mock)
    context.mock_instances[name] = mock


@then("the last trace should show the error message")
def step_check_last_trace_error(context: Context) -> None:
    traces = context.tool_ctx.traces
    assert len(traces) > 0
    last = traces[-1]
    assert last.error is not None
    assert "something went wrong" in last.error
