"""Step definitions for tool lifecycle coverage boost feature tests.

Targets uncovered lines and branches in src/cleveragents/tool/lifecycle.py:
- Line 253: ToolLifecycleCache.remove returns None when tool not in cache
- Line 394: ToolRuntime.activate re-raises ToolCancelledError
- Line 473: ToolRuntime.execute raises ToolNotActivatedError for None instance
- Lines 496-502: ToolRuntime.execute handles ToolCancelledError with trace
- Line 550: ToolRuntime.deactivate early return when instance not in cache
- Branch 585:exit: deactivate_plan with empty instances dict
- Branch 235:exit: get_plan_tools iterating over empty cache
- Branch 248:253: remove when tool not found / plan not found
- Branch 250:252: remove pops entry successfully
"""

from __future__ import annotations

from typing import Any
from unittest.mock import patch

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 (
    CancellationToken,
    ToolCancelledError,
    ToolExecutionContext,
)
from cleveragents.tool.lifecycle import (
    ToolDescriptor,
    ToolExecutionError,
    ToolLifecycleCache,
    ToolNotActivatedError,
    ToolResult,
    ToolRuntime,
)

# ---------------------------------------------------------------------------
# Mock tool instances for coverage-boost scenarios
# ---------------------------------------------------------------------------


class _CBMockToolInstance:
    """Basic mock ToolInstance for coverage-boost tests."""

    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

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

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

    def execute(self, params: dict[str, Any], ctx: ToolExecutionContext) -> ToolResult:
        return ToolResult(success=True, data={"result": "ok"})

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


class _CancellingActivateInstance(_CBMockToolInstance):
    """Mock that raises ToolCancelledError on activate()."""

    def activate(self, ctx: ToolExecutionContext) -> None:
        raise ToolCancelledError("Cancelled during activation")


class _CancellingExecuteInstance(_CBMockToolInstance):
    """Mock that raises ToolCancelledError on execute()."""

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

    def execute(self, params: dict[str, Any], ctx: ToolExecutionContext) -> ToolResult:
        raise ToolCancelledError("Cancelled during execution")


class _NullCacheInstance(_CBMockToolInstance):
    """Mock whose activate succeeds but we manipulate the cache afterwards."""

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


# ---------------------------------------------------------------------------
# Helper to create Tool domain models
# ---------------------------------------------------------------------------


def _cb_make_tool(
    name: str,
    *,
    read_only: bool = True,
    writes: bool = False,
) -> Tool:
    """Create a minimal Tool domain model for coverage-boost tests."""
    return Tool(
        name=name,
        description=f"Coverage-boost test tool {name}",
        source=ToolSource.BUILTIN,
        tool_type=ToolType.TOOL,
        capability=ToolCapability(
            read_only=read_only,
            writes=writes,
        ),
    )


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


@given("I create a coverage-boost lifecycle cache")
def step_create_cb_cache(context: Context) -> None:
    context.cb_cache = ToolLifecycleCache()
    context.cb_remove_result = None


@given('I have a coverage-boost mock tool instance named "{name}"')
def step_create_cb_mock(context: Context, name: str) -> None:
    context.cb_mock_instance = _CBMockToolInstance(name=name)


@given('I have a second coverage-boost mock tool instance named "{name}"')
def step_create_cb_mock_second(context: Context, name: str) -> None:
    context.cb_mock_instance_2 = _CBMockToolInstance(name=name)


@when(
    'I put the coverage-boost instance in cache for plan "{plan_id}" and tool "{tool_name}"'
)
def step_cb_cache_put(context: Context, plan_id: str, tool_name: str) -> None:
    context.cb_cache.put(plan_id, tool_name, context.cb_mock_instance)


@when(
    'I put the second coverage-boost instance in cache for plan "{plan_id}" and tool "{tool_name}"'
)
def step_cb_cache_put_second(context: Context, plan_id: str, tool_name: str) -> None:
    context.cb_cache.put(plan_id, tool_name, context.cb_mock_instance_2)


@when('I remove tool "{tool_name}" from coverage-boost cache plan "{plan_id}"')
def step_cb_cache_remove(context: Context, tool_name: str, plan_id: str) -> None:
    context.cb_remove_result = context.cb_cache.remove(plan_id, tool_name)


@then("the coverage-boost cache should have {count:d} plans")
def step_cb_cache_plan_count(context: Context, count: int) -> None:
    assert context.cb_cache.plan_count == count, (
        f"Expected {count} plans, got {context.cb_cache.plan_count}"
    )


@then('the coverage-boost cache internal dict should not contain plan "{plan_id}"')
def step_cb_cache_no_plan_key(context: Context, plan_id: str) -> None:
    # Access internal _cache dict to verify the plan key was fully deleted
    assert plan_id not in context.cb_cache._cache, (
        f"Plan '{plan_id}' should have been removed from internal _cache dict"
    )


@then("the coverage-boost remove result should be None")
def step_cb_remove_result_none(context: Context) -> None:
    assert context.cb_remove_result is None, (
        f"Expected None, got {context.cb_remove_result}"
    )


@then('the coverage-boost plan tools for plan "{plan_id}" should be an empty list')
def step_cb_plan_tools_empty(context: Context, plan_id: str) -> None:
    tools = context.cb_cache.get_plan_tools(plan_id)
    assert tools == [], f"Expected empty list, got {tools}"


@then(
    'the coverage-boost cache get for plan "{plan_id}" and tool "{tool_name}" should not be None'
)
def step_cb_cache_get_not_none(context: Context, plan_id: str, tool_name: str) -> None:
    result = context.cb_cache.get(plan_id, tool_name)
    assert result is not None, f"Expected non-None for plan={plan_id} tool={tool_name}"


# ---------------------------------------------------------------------------
# ToolRuntime steps — coverage-boost context and runtime setup
# ---------------------------------------------------------------------------


@given("I create a coverage-boost tool runtime")
def step_create_cb_runtime(context: Context) -> None:
    context.cb_runtime = ToolRuntime(event_bus=ReactiveEventBus())
    context.cb_mocks: dict[str, _CBMockToolInstance] = {}
    context.cb_error = None


@given('I create a coverage-boost execution context with plan_id "{plan_id}"')
def step_create_cb_ctx(context: Context, plan_id: str) -> None:
    token = CancellationToken()
    context.cb_ctx = ToolExecutionContext(
        plan_id=plan_id,
        cancellation_token=token,
    )


# ---------------------------------------------------------------------------
# ToolRuntime.activate — ToolCancelledError re-raise (line 394)
# ---------------------------------------------------------------------------


@given('I register a coverage-boost cancelling-activate tool "{name}"')
def step_register_cb_cancel_activate(context: Context, name: str) -> None:
    tool = _cb_make_tool(name, read_only=True)
    mock = _CancellingActivateInstance(name=name)
    context.cb_runtime.register_tool(tool, mock)
    context.cb_mocks[name] = mock


@when('I try to activate the cancelling tool "{name}" in coverage-boost runtime')
def step_try_cb_activate_cancel(context: Context, name: str) -> None:
    try:
        context.cb_runtime.activate(name, context.cb_ctx)
        context.cb_error = None
    except ToolCancelledError as exc:
        context.cb_error = exc
    except Exception as exc:
        context.cb_error = exc


@then("a ToolCancelledError should have been raised in coverage-boost activation")
def step_check_cb_activation_cancelled(context: Context) -> None:
    assert isinstance(context.cb_error, ToolCancelledError), (
        f"Expected ToolCancelledError, got {type(context.cb_error).__name__}: {context.cb_error}"
    )


# ---------------------------------------------------------------------------
# ToolRuntime.execute — None cached instance (line 473)
# ---------------------------------------------------------------------------


@given('I register a coverage-boost tool "{name}" with a mock that nullifies cache')
def step_register_cb_null_cache(context: Context, name: str) -> None:
    tool = _cb_make_tool(name, read_only=True)
    mock = _NullCacheInstance(name=name)
    context.cb_runtime.register_tool(tool, mock)
    context.cb_mocks[name] = mock


@when('I try to execute the null-cache tool "{name}" in coverage-boost runtime')
def step_try_cb_execute_null_cache(context: Context, name: str) -> None:
    # We need activate to succeed (so it passes the auto-activate gate)
    # but cache.get to return None right after.
    # Strategy: patch the cache's get method to return None after activate
    # has put the instance into the cache.
    original_get = context.cb_runtime._cache.get

    def _patched_get(plan_id: str, tool_name: str) -> Any:
        # First call is in activate() to check if already cached — return None
        # so activate proceeds. After activate puts it, the second call is in
        # execute() at line 471 — we remove it from cache first so it returns None.
        result = original_get(plan_id, tool_name)
        if result is not None:
            # Remove it so the next get (line 471) returns None
            context.cb_runtime._cache.remove(plan_id, tool_name)
            return None
        return result

    try:
        with patch.object(context.cb_runtime._cache, "get", side_effect=_patched_get):
            context.cb_runtime.execute(name, {}, context.cb_ctx)
        context.cb_error = None
    except ToolNotActivatedError as exc:
        context.cb_error = exc
    except Exception as exc:
        context.cb_error = exc


@then("a ToolNotActivatedError should have been raised in coverage-boost execution")
def step_check_cb_not_activated(context: Context) -> None:
    # ToolNotActivatedError is raised at line 473 but caught by the generic
    # except Exception handler at line 504 and wrapped in ToolExecutionError.
    is_direct = isinstance(context.cb_error, ToolNotActivatedError)
    is_wrapped = (
        isinstance(context.cb_error, ToolExecutionError)
        and "not activated" in str(context.cb_error).lower()
    )
    assert is_direct or is_wrapped, (
        f"Expected ToolNotActivatedError (or ToolExecutionError wrapping it), "
        f"got {type(context.cb_error).__name__}: {context.cb_error}"
    )


# ---------------------------------------------------------------------------
# ToolRuntime.execute — ToolCancelledError during execution (lines 496-502)
# ---------------------------------------------------------------------------


@given('I register a coverage-boost cancelling-execute tool "{name}"')
def step_register_cb_cancel_execute(context: Context, name: str) -> None:
    tool = _cb_make_tool(name, read_only=True)
    mock = _CancellingExecuteInstance(name=name)
    context.cb_runtime.register_tool(tool, mock)
    context.cb_mocks[name] = mock


@when('I try to execute the cancelling-execute tool "{name}" in coverage-boost runtime')
def step_try_cb_execute_cancel(context: Context, name: str) -> None:
    try:
        context.cb_runtime.execute(name, {}, context.cb_ctx)
        context.cb_error = None
    except ToolCancelledError as exc:
        context.cb_error = exc
    except Exception as exc:
        context.cb_error = exc


@then("a ToolCancelledError should have been raised in coverage-boost execution")
def step_check_cb_execution_cancelled(context: Context) -> None:
    assert isinstance(context.cb_error, ToolCancelledError), (
        f"Expected ToolCancelledError, got {type(context.cb_error).__name__}: {context.cb_error}"
    )


@then("the coverage-boost context should have {count:d} traces")
def step_check_cb_ctx_traces(context: Context, count: int) -> None:
    actual = len(context.cb_ctx.traces)
    assert actual == count, f"Expected {count} traces, got {actual}"


@then("the coverage-boost last trace should show success {expected}")
def step_check_cb_last_trace_success(context: Context, expected: str) -> None:
    traces = context.cb_ctx.traces
    assert len(traces) > 0, "No traces recorded"
    last = traces[-1]
    expected_bool = expected == "True"
    assert last.success == expected_bool, (
        f"Expected trace success={expected_bool}, got {last.success}"
    )


@then('the coverage-boost last trace error should be "{expected}"')
def step_check_cb_last_trace_error(context: Context, expected: str) -> None:
    traces = context.cb_ctx.traces
    assert len(traces) > 0, "No traces recorded"
    last = traces[-1]
    assert last.error == expected, (
        f"Expected trace error='{expected}', got '{last.error}'"
    )


# ---------------------------------------------------------------------------
# ToolRuntime.deactivate — tool not in cache (line 550)
# ---------------------------------------------------------------------------


@given('I register a coverage-boost read-only tool "{name}" with a mock')
def step_register_cb_readonly(context: Context, name: str) -> None:
    tool = _cb_make_tool(name, read_only=True)
    mock = _CBMockToolInstance(name=name, read_only=True)
    context.cb_runtime.register_tool(tool, mock)
    context.cb_mocks[name] = mock


@when('I deactivate tool "{name}" in the coverage-boost runtime')
def step_cb_deactivate_tool(context: Context, name: str) -> None:
    try:
        context.cb_runtime.deactivate(name, context.cb_ctx)
        context.cb_error = None
    except Exception as exc:
        context.cb_error = exc


@then("no coverage-boost exception should have been raised")
def step_check_cb_no_exception(context: Context) -> None:
    assert context.cb_error is None, (
        f"Expected no exception, got {type(context.cb_error).__name__}: {context.cb_error}"
    )


@then('the coverage-boost mock "{name}" should not have been deactivated')
def step_check_cb_mock_not_deactivated(context: Context, name: str) -> None:
    mock = context.cb_mocks[name]
    assert not mock.deactivated, f"Mock '{name}' should NOT have been deactivated"


# ---------------------------------------------------------------------------
# ToolRuntime.deactivate_plan — empty plan (branch 585:exit)
# ---------------------------------------------------------------------------


@when("I deactivate plan in the coverage-boost runtime")
def step_cb_deactivate_plan(context: Context) -> None:
    try:
        context.cb_runtime.deactivate_plan(context.cb_ctx)
        context.cb_error = None
    except Exception as exc:
        context.cb_error = exc
