"""Step definitions for actor_run_tool_calling.feature.

Tests ToolCallingAgent, ToolCallingLLMCaller, the _make_agent_instance routing
fix, and the ReactiveCleverAgentsApp.last_run_tool_calls property.
"""

from __future__ import annotations

from typing import Any
from unittest.mock import MagicMock, patch

from behave import given, then, when

from cleveragents.reactive.graph_executor import GraphExecutor
from cleveragents.reactive.stream_router import SimpleLLMAgent
from cleveragents.reactive.tool_agent import ToolCallingAgent
from cleveragents.reactive.tool_caller import ToolCallingLLMCaller
from cleveragents.tool.actor_runtime import (
    LLMResponse,
    LLMToolCall,
    ToolCallRunResult,
)
from cleveragents.tool.actor_context import ToolCallRecord
from cleveragents.tool.registry import ToolRegistry
from cleveragents.tool.router import ProviderFormat
from cleveragents.tool.runtime import ToolSpec

__all__: list[str] = []


# ---------------------------------------------------------------------------
# Helpers — fake tool spec and mock LLM callers
# ---------------------------------------------------------------------------


def _make_file_read_spec() -> ToolSpec:
    """Return a ToolSpec for builtin/file-read with an echo handler."""

    def _handler(inputs: dict[str, Any]) -> dict[str, Any]:
        path = inputs.get("path", "unknown")
        return {"content": f"(mock content of {path})"}

    return ToolSpec(
        name="builtin/file-read",
        description="Read a file (test stub)",
        input_schema={
            "type": "object",
            "properties": {"path": {"type": "string"}},
            "required": ["path"],
        },
        handler=_handler,
    )


class _MockLLMCaller:
    """Configurable mock LLMCaller that drives a ToolCallingRuntime."""

    def __init__(self, responses: list[LLMResponse]) -> None:
        self._responses = list(responses)
        self._call_count = 0
        self.captured_tool_schemas: list[dict[str, Any]] = []

    def invoke(
        self,
        prompt: str,
        tool_schemas: list[dict[str, Any]],
        tool_results: list[dict[str, Any]] | None = None,
        actor_config: dict[str, Any] | None = None,
    ) -> LLMResponse:
        self.captured_tool_schemas = list(tool_schemas)
        idx = min(self._call_count, len(self._responses) - 1)
        resp = self._responses[idx]
        self._call_count += 1
        return resp


class _CapturingToolCallingAgent(ToolCallingAgent):
    """ToolCallingAgent spy that captures arguments passed to process()."""

    def __init__(self) -> None:
        super().__init__(
            name="spy-agent",
            actor_config={},
            resolved_tool_entries=[],
            builtin_registry=ToolRegistry(),
        )
        self.captured_context: dict[str, Any] | None = None

    def process(
        self,
        content: Any,
        metadata: dict[str, Any] | None = None,
        context: dict[str, Any] | None = None,
    ) -> str:
        self.captured_context = dict(context) if context is not None else None
        return "spy result"


# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------


@given("a minimal ToolCallingAgent fixture")
def step_minimal_tool_calling_agent_fixture(context: Any) -> None:
    """Set up shared fixtures used across all scenarios."""
    builtin_registry = ToolRegistry()
    builtin_registry.register(_make_file_read_spec())
    context.builtin_registry = builtin_registry
    context.tool_calling_agent = None
    context.simple_llm_agent = None


# ---------------------------------------------------------------------------
# Scenario A: single tool call succeeds
# ---------------------------------------------------------------------------


@given("a mock LLM caller that makes one read_file tool call")
def step_mock_llm_one_read_file_call(context: Any) -> None:
    context.mock_caller = _MockLLMCaller(
        responses=[
            LLMResponse(
                content="",
                tool_calls=[
                    LLMToolCall(
                        name="builtin/file-read",
                        arguments={"path": "src/auth.py"},
                        call_id="call-1",
                    )
                ],
            ),
            LLMResponse(content="Here is my review of src/auth.py.", tool_calls=[]),
        ]
    )


@when('ToolCallingAgent.process is called with prompt "{prompt}"')
def step_tool_calling_agent_process(context: Any, prompt: str) -> None:
    resolved_entries = [{"name": "builtin/file-read"}]
    actor_config: dict[str, Any] = {"type": "llm", "provider": None, "model": None}

    agent = ToolCallingAgent(
        name="test-actor",
        actor_config=actor_config,
        resolved_tool_entries=resolved_entries,
        builtin_registry=context.builtin_registry,
    )

    # Patch ToolCallingLLMCaller at its source module so the lazy import
    # inside tool_agent.process() picks up the mock.
    with patch(
        "cleveragents.reactive.tool_caller.ToolCallingLLMCaller",
        return_value=context.mock_caller,
    ):
        context.agent_result = agent.process(prompt)

    context.tool_calling_agent = agent


@then("the tool_calls count is {expected_count:d}")
def step_tool_calls_count(context: Any, expected_count: int) -> None:
    assert context.tool_calling_agent is not None
    assert context.tool_calling_agent.last_result is not None
    actual = len(context.tool_calling_agent.last_result.tool_call_history)
    assert actual == expected_count, (
        f"Expected {expected_count} tool calls, got {actual}"
    )


@then("the final response is non-empty")
def step_final_response_non_empty(context: Any) -> None:
    assert context.agent_result, "Expected a non-empty final response"


# ---------------------------------------------------------------------------
# Scenario B: multi-turn tool loop
# ---------------------------------------------------------------------------


@given("a mock LLM caller that makes 2 consecutive tool calls before finishing")
def step_mock_llm_two_tool_calls(context: Any) -> None:
    context.mock_caller = _MockLLMCaller(
        responses=[
            LLMResponse(
                content="",
                tool_calls=[
                    LLMToolCall(
                        name="builtin/file-read",
                        arguments={"path": "file1.py"},
                        call_id="call-1",
                    )
                ],
            ),
            LLMResponse(
                content="",
                tool_calls=[
                    LLMToolCall(
                        name="builtin/file-read",
                        arguments={"path": "file2.py"},
                        call_id="call-2",
                    )
                ],
            ),
            LLMResponse(content="Analysis complete for both files.", tool_calls=[]),
        ]
    )


@then("the loop ran for at least {min_iterations:d} iterations")
def step_loop_min_iterations(context: Any, min_iterations: int) -> None:
    assert context.tool_calling_agent is not None
    assert context.tool_calling_agent.last_result is not None
    actual = context.tool_calling_agent.last_result.iterations
    assert actual >= min_iterations, (
        f"Expected >= {min_iterations} iterations, got {actual}"
    )


# ---------------------------------------------------------------------------
# Scenario C: no-skill plain LLM regression
# ---------------------------------------------------------------------------


@given("a SimpleLLMAgent with a mock LLM")
def step_simple_llm_agent_with_mock(context: Any) -> None:
    mock_llm = MagicMock()
    mock_llm.invoke.return_value = MagicMock(content="plain LLM response")
    context.mock_llm = mock_llm

    agent = SimpleLLMAgent(name="plain-actor", config={"system_prompt": "Be helpful."})
    agent._llm = mock_llm
    context.simple_llm_agent = agent


@when("SimpleLLMAgent.process is called with a simple prompt")
def step_simple_llm_process(context: Any) -> None:
    context.simple_llm_result = context.simple_llm_agent.process(
        "Hello, what time is it?"
    )


@then("SimpleLLMAgent returns the mocked response")
def step_simple_llm_returns_mocked(context: Any) -> None:
    assert context.simple_llm_result is not None
    assert "plain LLM response" in context.simple_llm_result


@then("no tool schemas were sent to the LLM")
def step_no_tool_schemas_sent(context: Any) -> None:
    mock_llm = context.mock_llm
    # SimpleLLMAgent calls llm.invoke(messages) without bind_tools
    assert mock_llm.invoke.called
    # Ensure bind_tools was never called (SimpleLLMAgent doesn't bind tools)
    assert not mock_llm.bind_tools.called, "SimpleLLMAgent should not call bind_tools"


# ---------------------------------------------------------------------------
# Scenario D: skill tools not silently dropped
# ---------------------------------------------------------------------------


@given("an actor config with no base tools list")
def step_actor_config_no_base_tools(context: Any) -> None:
    context.actor_cfg_no_tools = {"type": "llm", "provider": None, "model": None}


@given("resolved skill tool entries for the actor")
def step_resolved_skill_entries(context: Any) -> None:
    context.resolved_skill_tools = [
        {
            "name": "builtin/file-read",
            "operation": "identity",
            "skill_source": "local/file-ops",
        }
    ]


@when("_make_agent_instance is called for that actor")
def step_make_agent_instance_called(context: Any) -> None:
    """Simulate _make_agent_instance logic from application.py (ST-1)."""
    from types import SimpleNamespace

    agent_cfg = SimpleNamespace(
        type="llm",
        config=context.actor_cfg_no_tools,
    )
    resolved_skill_tools = context.resolved_skill_tools

    # Replicate the fixed routing logic from application.py _make_agent_instance()
    tools: list[dict[str, Any]] = (
        agent_cfg.config.get("tools", []) if agent_cfg.config else []
    )
    if resolved_skill_tools:
        tools = list(tools) + resolved_skill_tools

    if tools and agent_cfg.type == "llm":
        context.make_agent_result = ToolCallingAgent(
            "test", agent_cfg.config, tools, context.builtin_registry
        )
    elif tools:
        from cleveragents.reactive.stream_router import SimpleToolAgent

        context.make_agent_result = SimpleToolAgent(tools)
    elif agent_cfg.type == "llm":
        context.make_agent_result = SimpleLLMAgent("test", agent_cfg.config)
    else:
        context.make_agent_result = lambda msg: msg


@then("the result is a ToolCallingAgent instance")
def step_result_is_tool_calling_agent(context: Any) -> None:
    assert isinstance(context.make_agent_result, ToolCallingAgent), (
        f"Expected ToolCallingAgent, got {type(context.make_agent_result).__name__}"
    )


# ---------------------------------------------------------------------------
# Scenario E: ToolCallingLLMCaller sends system prompt on first call
# ---------------------------------------------------------------------------


@given("a ToolCallingLLMCaller with an actor config containing a system_prompt")
def step_tool_calling_llm_caller_with_system_prompt(context: Any) -> None:
    context.llm_caller = ToolCallingLLMCaller(
        actor_config={
            "system_prompt": "You are a test assistant.",
            "provider": None,
            "model": None,
        }
    )
    # Replace the internal LLM resolution with a mock
    mock_llm = MagicMock()
    mock_llm.invoke.return_value = MagicMock(content="response", tool_calls=[])
    mock_llm.bind_tools.return_value = mock_llm
    context.llm_caller._llm = mock_llm
    context.mock_llm_for_caller = mock_llm


@when("invoke is called for the first time with a user prompt")
def step_caller_first_invoke(context: Any) -> None:
    context.llm_response = context.llm_caller.invoke(
        prompt="Hello",
        tool_schemas=[],
    )


@then("the accumulated messages include a SystemMessage")
def step_accumulated_has_system_message(context: Any) -> None:
    try:
        from langchain_core.messages import SystemMessage
    except ImportError:
        return  # skip if langchain not installed
    has_system = any(
        isinstance(m, SystemMessage) for m in context.llm_caller._accumulated
    )
    assert has_system, "Expected a SystemMessage in accumulated messages"


@then("the accumulated messages include a HumanMessage")
def step_accumulated_has_human_message(context: Any) -> None:
    try:
        from langchain_core.messages import HumanMessage
    except ImportError:
        return  # skip if langchain not installed
    has_human = any(
        isinstance(m, HumanMessage) for m in context.llm_caller._accumulated
    )
    assert has_human, "Expected a HumanMessage in accumulated messages"


# ---------------------------------------------------------------------------
# Scenario F: ToolCallingLLMCaller appends tool results on subsequent call
# ---------------------------------------------------------------------------


@given("a prior first invoke has been made")
def step_prior_first_invoke(context: Any) -> None:
    # The first invoke was already done in Scenario E's Background + Given steps.
    # But since Behave resets context per scenario, we need to re-run the setup.
    if not hasattr(context, "llm_caller"):
        step_tool_calling_llm_caller_with_system_prompt(context)
    # Do a first invoke if not done yet
    if context.llm_caller._first_call:
        step_caller_first_invoke(context)


@when("invoke is called again with tool_results")
def step_caller_second_invoke_with_tool_results(context: Any) -> None:
    context.llm_response2 = context.llm_caller.invoke(
        prompt="ignored on subsequent call",
        tool_schemas=[],
        tool_results=[
            {
                "tool_name": "builtin/file-read",
                "call_id": "call-1",
                "success": True,
                "output": {"content": "file content"},
            }
        ],
    )


@then("a ToolMessage is appended to the accumulated messages")
def step_tool_message_appended(context: Any) -> None:
    try:
        from langchain_core.messages.tool import ToolMessage
    except ImportError:
        return  # skip if langchain not installed
    has_tool_msg = any(
        isinstance(m, ToolMessage) for m in context.llm_caller._accumulated
    )
    assert has_tool_msg, "Expected a ToolMessage in accumulated messages"


# ---------------------------------------------------------------------------
# Scenario G: GraphExecutor passes context to ToolCallingAgent
# ---------------------------------------------------------------------------


@given("a ToolCallingAgent spy that captures its process() arguments")
def step_tool_calling_agent_spy(context: Any) -> None:
    context.spy_agent = _CapturingToolCallingAgent()


@when("GraphExecutor._invoke_agent is called with that agent and a context dict")
def step_graph_executor_invoke_agent_spy(context: Any) -> None:
    context.spy_context = {"writing_stage": "intro", "test_key": "test_value"}
    GraphExecutor._invoke_agent(context.spy_agent, "test message", context.spy_context)


@then("the context dict was forwarded to ToolCallingAgent.process")
def step_context_dict_forwarded(context: Any) -> None:
    assert context.spy_agent.captured_context == context.spy_context, (
        f"Expected context {context.spy_context}, "
        f"got {context.spy_agent.captured_context}"
    )


# ---------------------------------------------------------------------------
# Scenario H: last_run_tool_calls property reflects run results
# ---------------------------------------------------------------------------


@given(
    "a ReactiveCleverAgentsApp with a registered ToolCallingAgent that has last_result"
)
def step_reactive_app_with_tool_calling_agent_result(context: Any) -> None:
    from cleveragents.reactive.application import ReactiveCleverAgentsApp

    # Build a minimal app without config files to avoid filesystem access
    with (
        patch("cleveragents.reactive.application.ReactiveStreamRouter"),
        patch("cleveragents.reactive.application.ReactiveConfigParser"),
    ):
        app = ReactiveCleverAgentsApp.__new__(ReactiveCleverAgentsApp)
        app.verbose = 0
        app.unsafe = False
        app.temperature_override = None
        app._skill_names = []
        app._resolved_skill_tools = []
        app._last_run_tool_calls = 0
        app.config = None
        app._builtin_registry = ToolRegistry()
        app.stream_router = MagicMock()
        app.config_parser = MagicMock()
        app.langgraph_bridge = None
        app.route_bridge = None

    # Create a ToolCallingAgent with last_result set
    fake_agent = ToolCallingAgent(
        name="fake",
        actor_config={},
        resolved_tool_entries=[],
        builtin_registry=ToolRegistry(),
    )
    fake_agent.last_result = ToolCallRunResult(
        content="done",
        tool_call_history=[
            ToolCallRecord(
                tool_name="builtin/file-read",
                inputs={"path": "f.py"},
                output={"content": "x"},
                duration_ms=1.0,
                success=True,
                iteration=1,
            ),
            ToolCallRecord(
                tool_name="builtin/file-read",
                inputs={"path": "g.py"},
                output={"content": "y"},
                duration_ms=1.0,
                success=True,
                iteration=2,
            ),
        ],
        iterations=3,
        terminated_by_limit=False,
    )

    # Register fake agent in the stream_router agents dict
    app.stream_router.agents = {"fake-actor": fake_agent}
    context.reactive_app = app
    context.expected_tool_calls = 2


@when("_tally_tool_calls is called on the app")
def step_tally_tool_calls(context: Any) -> None:
    context.reactive_app._tally_tool_calls()


@then("last_run_tool_calls equals the number of tool call history entries")
def step_last_run_tool_calls_correct(context: Any) -> None:
    actual = context.reactive_app.last_run_tool_calls
    assert actual == context.expected_tool_calls, (
        f"Expected last_run_tool_calls == {context.expected_tool_calls}, got {actual}"
    )


# ---------------------------------------------------------------------------
# Scenarios I: ToolCallingLLMCaller._render_prompt
# ---------------------------------------------------------------------------


@given("a fresh ToolCallingLLMCaller")
def step_fresh_tool_calling_llm_caller(context: Any) -> None:
    context.caller = ToolCallingLLMCaller(actor_config={})


@when("_render_prompt is called with an empty string template")
def step_render_prompt_empty(context: Any) -> None:
    context.render_result = context.caller._render_prompt("", {})


@then("_render_prompt returns an empty string")
def step_render_prompt_empty_result(context: Any) -> None:
    assert context.render_result == "", f"Expected '', got {context.render_result!r}"


@when("_render_prompt is called with a Jinja2 template and context")
def step_render_prompt_jinja2(context: Any) -> None:
    context.render_result = context.caller._render_prompt(
        "Hello {{ name }}!", {"name": "World"}
    )


@then("_render_prompt returns the rendered string")
def step_render_prompt_rendered(context: Any) -> None:
    assert context.render_result == "Hello World!", (
        f"Expected 'Hello World!', got {context.render_result!r}"
    )


@when("_render_prompt is called with a template that raises during rendering")
def step_render_prompt_error(context: Any) -> None:
    # Use an invalid Jinja2 template that raises during rendering
    context.bad_template = "{{ undefined_var | custom_filter }}"
    context.render_result = context.caller._render_prompt(context.bad_template, {})


@then("_render_prompt returns the original template")
def step_render_prompt_original(context: Any) -> None:
    assert context.render_result == context.bad_template, (
        f"Expected original template, got {context.render_result!r}"
    )


# ---------------------------------------------------------------------------
# Scenarios J: ToolCallingLLMCaller._resolve_llm
# ---------------------------------------------------------------------------


@given("a ToolCallingLLMCaller with temperature max_tokens max_retries in actor_config")
def step_caller_with_full_config(context: Any) -> None:
    context.caller_config = {
        "provider": "test",
        "model": "test-model",
        "temperature": 0.7,
        "max_tokens": 100,
        "max_retries": 2,
    }
    context.caller = ToolCallingLLMCaller(actor_config=context.caller_config)
    context.mock_llm = MagicMock()
    context.mock_llm.bind_tools.return_value = context.mock_llm


@when("_resolve_llm is called with tool_schemas")
def step_resolve_llm_with_schemas(context: Any) -> None:
    with patch("cleveragents.reactive.tool_caller.get_provider_registry") as mock_reg:
        mock_reg.return_value.create_llm.return_value = context.mock_llm
        context.resolved_llm = context.caller._resolve_llm([{"name": "test_tool"}])
        context.mock_registry = mock_reg.return_value


@then("the provider registry create_llm was called with the config kwargs")
def step_create_llm_called_with_kwargs(context: Any) -> None:
    call_kwargs = context.mock_registry.create_llm.call_args.kwargs
    assert call_kwargs.get("temperature") == 0.7
    assert call_kwargs.get("max_tokens") == 100
    assert call_kwargs.get("max_retries") == 2


@when("_resolve_llm is called without tool_schemas")
def step_resolve_llm_no_schemas(context: Any) -> None:
    with patch("cleveragents.reactive.tool_caller.get_provider_registry") as mock_reg:
        mock_reg.return_value.create_llm.return_value = context.mock_llm
        context.resolved_llm = context.caller._resolve_llm([])


@then("bind_tools was not called on the LLM")
def step_bind_tools_not_called(context: Any) -> None:
    assert not context.mock_llm.bind_tools.called, "bind_tools should not be called"


@given("a ToolCallingLLMCaller with a provider that raises on bind_tools")
def step_caller_bind_tools_raises(context: Any) -> None:
    context.caller = ToolCallingLLMCaller(
        actor_config={"provider": None, "model": None}
    )
    context.mock_llm_failing_bind = MagicMock()
    context.mock_llm_failing_bind.bind_tools.side_effect = ValueError(
        "bind_tools failed"
    )


@when("_resolve_llm is called with tool_schemas for a failing bind")
def step_resolve_llm_with_schemas_bind_fails(context: Any) -> None:
    with patch("cleveragents.reactive.tool_caller.get_provider_registry") as mock_reg:
        mock_reg.return_value.create_llm.return_value = context.mock_llm_failing_bind
        context.resolved_llm = context.caller._resolve_llm([{"name": "some_tool"}])


@then("the plain LLM is used without tool binding")
def step_plain_llm_used(context: Any) -> None:
    assert context.resolved_llm is context.mock_llm_failing_bind, (
        "Should fall back to the plain LLM when bind_tools fails"
    )


# ---------------------------------------------------------------------------
# Scenarios K: invoke without system prompt
# ---------------------------------------------------------------------------


@given("a ToolCallingLLMCaller with an actor config without system_prompt")
def step_caller_without_system_prompt(context: Any) -> None:
    context.llm_caller = ToolCallingLLMCaller(
        actor_config={"provider": None, "model": None}
    )
    mock_llm = MagicMock()
    mock_llm.invoke.return_value = MagicMock(
        content="no-system response", tool_calls=[]
    )
    mock_llm.bind_tools.return_value = mock_llm
    context.llm_caller._llm = mock_llm
    context.mock_llm_for_caller = mock_llm


# Note: the "invoke is called for the first time with a user prompt" step
# is defined in Scenario E's section above and shared with Scenario K.


@then("no SystemMessage was added")
def step_no_system_message_added(context: Any) -> None:
    try:
        from langchain_core.messages import SystemMessage
    except ImportError:
        return
    has_system = any(
        isinstance(m, SystemMessage) for m in context.llm_caller._accumulated
    )
    assert not has_system, "Expected NO SystemMessage in accumulated messages"


# ---------------------------------------------------------------------------
# Scenarios L: invoke with list content
# ---------------------------------------------------------------------------


@when("invoke is called and the LLM returns list content")
def step_invoke_list_content(context: Any) -> None:
    mock_llm = MagicMock()
    mock_response = MagicMock()
    mock_response.content = [
        {"text": "Part one"},
        "Part two",
    ]
    mock_response.tool_calls = []
    mock_llm.invoke.return_value = mock_response
    context.llm_caller._llm = mock_llm
    context.llm_response = context.llm_caller.invoke(
        prompt="list content test",
        tool_schemas=[],
    )


@then("the response content joins the list parts")
def step_response_content_joined(context: Any) -> None:
    assert "Part one" in context.llm_response.content, (
        f"Expected 'Part one' in content, got {context.llm_response.content!r}"
    )
    assert "Part two" in context.llm_response.content


# ---------------------------------------------------------------------------
# Scenarios M: invoke with failed tool_result
# ---------------------------------------------------------------------------


@when("invoke is called with a failed tool_result")
def step_caller_failed_tool_result(context: Any) -> None:
    context.llm_response2 = context.llm_caller.invoke(
        prompt="ignored",
        tool_schemas=[],
        tool_results=[
            {
                "tool_name": "builtin/file-read",
                "call_id": "call-fail",
                "success": False,
                "output": {},
                "error": "File not found",
            }
        ],
    )


@then("a ToolMessage with the error text is appended")
def step_tool_message_error_appended(context: Any) -> None:
    try:
        from langchain_core.messages.tool import ToolMessage
    except ImportError:
        return
    tool_messages = [
        m for m in context.llm_caller._accumulated if isinstance(m, ToolMessage)
    ]
    assert any("File not found" in m.content for m in tool_messages), (
        "Expected a ToolMessage containing 'File not found'"
    )


# ---------------------------------------------------------------------------
# Scenarios N: ToolCallingAgent._build_tool_registry edge cases
# ---------------------------------------------------------------------------


@given("a ToolCallingAgent with an entry that has an empty name")
def step_agent_empty_name_entry(context: Any) -> None:
    builtin_registry = ToolRegistry()
    builtin_registry.register(_make_file_read_spec())
    context.edge_agent = ToolCallingAgent(
        name="edge-agent",
        actor_config={},
        resolved_tool_entries=[{"name": ""}],
        builtin_registry=builtin_registry,
    )


@when("_build_tool_registry is called")
def step_build_tool_registry_called(context: Any) -> None:
    context.local_registry = context.edge_agent._build_tool_registry()


@then("the local registry is empty")
def step_local_registry_empty(context: Any) -> None:
    tools = context.local_registry.list_tools()
    assert len(tools) == 0, f"Expected empty registry, got {len(tools)} tools"


@given("a ToolCallingAgent with two entries for the same builtin tool")
def step_agent_duplicate_entries(context: Any) -> None:
    builtin_registry = ToolRegistry()
    builtin_registry.register(_make_file_read_spec())
    context.edge_agent = ToolCallingAgent(
        name="dup-agent",
        actor_config={},
        resolved_tool_entries=[
            {"name": "builtin/file-read"},
            {"name": "builtin/file-read"},  # duplicate
        ],
        builtin_registry=builtin_registry,
    )


@then("the local registry has exactly one tool registered")
def step_local_registry_one_tool(context: Any) -> None:
    tools = context.local_registry.list_tools()
    assert len(tools) == 1, f"Expected 1 tool, got {len(tools)} tools"


@given("a ToolCallingAgent with an entry not found in builtin registry")
def step_agent_unknown_tool_entry(context: Any) -> None:
    builtin_registry = ToolRegistry()  # empty registry
    context.edge_agent = ToolCallingAgent(
        name="unknown-agent",
        actor_config={},
        resolved_tool_entries=[{"name": "builtin/nonexistent-tool"}],
        builtin_registry=builtin_registry,
    )


# ---------------------------------------------------------------------------
# Scenarios O: ToolCallingAgent.process_message_sync
# ---------------------------------------------------------------------------


@given("a ToolCallingAgent with no tools and a mock process method")
def step_agent_no_tools_mock_process(context: Any) -> None:
    builtin_registry = ToolRegistry()
    context.sync_agent = ToolCallingAgent(
        name="sync-test",
        actor_config={},
        resolved_tool_entries=[],
        builtin_registry=builtin_registry,
    )
    context.mock_caller = _MockLLMCaller(
        responses=[LLMResponse(content="sync result", tool_calls=[])]
    )


@when("process_message_sync is called")
def step_process_message_sync_called(context: Any) -> None:
    with patch(
        "cleveragents.reactive.tool_caller.ToolCallingLLMCaller",
        return_value=context.mock_caller,
    ):
        context.sync_result = context.sync_agent.process_message_sync(
            "sync prompt", None
        )


@then("the result is the same as calling process directly")
def step_sync_result_equals_process(context: Any) -> None:
    assert context.sync_result == "sync result", (
        f"Expected 'sync result', got {context.sync_result!r}"
    )


# ---------------------------------------------------------------------------
# Scenarios P: extract tool calls from LLM response
# ---------------------------------------------------------------------------


@when("invoke is called and the LLM returns a response with tool call dicts")
def step_invoke_with_tool_call_dicts(context: Any) -> None:
    mock_llm = MagicMock()
    mock_response = MagicMock()
    mock_response.content = "I'll call the tool"
    mock_response.tool_calls = [
        {"name": "builtin/file-read", "args": {"path": "test.py"}, "id": "tc-1"}
    ]
    mock_llm.invoke.return_value = mock_response
    context.llm_caller._llm = mock_llm
    context.llm_response = context.llm_caller.invoke(
        prompt="extract tool calls",
        tool_schemas=[],
    )


@then("the LLMResponse contains the extracted tool calls")
def step_response_has_tool_calls(context: Any) -> None:
    assert len(context.llm_response.tool_calls) == 1, (
        f"Expected 1 tool call, got {len(context.llm_response.tool_calls)}"
    )
    tc = context.llm_response.tool_calls[0]
    assert tc.name == "builtin/file-read"
    assert tc.call_id == "tc-1"


# ---------------------------------------------------------------------------
# Scenarios Q: _resolve_provider_format
# ---------------------------------------------------------------------------


@given('an actor config with provider "{provider}"')
def step_actor_config_with_provider(context: Any, provider: str) -> None:
    context.resolve_config = {"provider": provider, "model": "test-model"}


@given("an empty actor config")
def step_empty_actor_config(context: Any) -> None:
    context.resolve_config = {}


@when("_resolve_provider_format is called with that actor config")
def step_resolve_provider_format_called(context: Any) -> None:
    context.resolve_result = ToolCallingAgent._resolve_provider_format(
        context.resolve_config
    )


@when("_resolve_provider_format is called with actor_config None")
def step_resolve_provider_format_none(context: Any) -> None:
    context.resolve_result = ToolCallingAgent._resolve_provider_format(None)


@then("_resolve_provider_format returns ProviderFormat.ANTHROPIC")
def step_format_is_anthropic(context: Any) -> None:
    assert context.resolve_result == ProviderFormat.ANTHROPIC, (
        f"Expected ANTHROPIC, got {context.resolve_result}"
    )


@then("_resolve_provider_format returns ProviderFormat.OPENAI")
def step_format_is_openai(context: Any) -> None:
    assert context.resolve_result == ProviderFormat.OPENAI, (
        f"Expected OPENAI, got {context.resolve_result}"
    )


# ---------------------------------------------------------------------------
# Scenario R: ToolCallingAgent.process passes provider_format to runtime
# ---------------------------------------------------------------------------


@given("a ToolCallingAgent with an anthropic actor config and a file-read tool entry")
def step_agent_anthropic_config(context: Any) -> None:
    builtin_registry = ToolRegistry()
    builtin_registry.register(_make_file_read_spec())
    context.builtin_registry = builtin_registry
    context.format_agent = ToolCallingAgent(
        name="format-test",
        actor_config={"provider": "anthropic", "model": "claude-sonnet-4-20250514"},
        resolved_tool_entries=[{"name": "builtin/file-read"}],
        builtin_registry=builtin_registry,
    )
    context.mock_caller = _MockLLMCaller(
        responses=[LLMResponse(content="read the file", tool_calls=[])]
    )


@then("ToolCallingRuntime was constructed with ProviderFormat.ANTHROPIC")
def step_runtime_constructed_with_anthropic(context: Any) -> None:
    with (
        patch(
            "cleveragents.reactive.tool_caller.ToolCallingLLMCaller",
            return_value=context.mock_caller,
        ),
        patch(
            "cleveragents.reactive.tool_agent.ToolCallingRuntime"
        ) as mock_runtime_cls,
    ):
        mock_runtime = MagicMock()
        mock_runtime.run_tool_loop.return_value = ToolCallRunResult(
            content="result", iterations=1, terminated_by_limit=False
        )
        mock_runtime_cls.return_value = mock_runtime

        context.format_agent.process("Read my file")

        # Extract the provider_format kwarg passed to ToolCallingRuntime(...)
        call_kwargs = mock_runtime_cls.call_args.kwargs
        assert "provider_format" in call_kwargs, (
            "provider_format was not passed to ToolCallingRuntime"
        )
        assert call_kwargs["provider_format"] == ProviderFormat.ANTHROPIC, (
            f"Expected ANTHROPIC, got {call_kwargs['provider_format']}"
        )


# ---------------------------------------------------------------------------
# Scenarios S: Tool name encoding/decoding
# ---------------------------------------------------------------------------


@when('_encode_tool_name is called with "{tool_name}"')
def step_encode_tool_name(context: Any, tool_name: str) -> None:
    from cleveragents.reactive.tool_caller import _encode_tool_name

    context.encoded_name = _encode_tool_name(tool_name)


@then('_encode_tool_name returns "{expected}"')
def step_encode_tool_name_returns(context: Any, expected: str) -> None:
    assert context.encoded_name == expected, (
        f"Expected {expected!r}, got {context.encoded_name!r}"
    )


@when('_decode_tool_name is called with "{tool_name}"')
def step_decode_tool_name(context: Any, tool_name: str) -> None:
    from cleveragents.reactive.tool_caller import _decode_tool_name

    context.decoded_name = _decode_tool_name(tool_name)


@then('_decode_tool_name returns "{expected}"')
def step_decode_tool_name_returns(context: Any, expected: str) -> None:
    assert context.decoded_name == expected, (
        f"Expected {expected!r}, got {context.decoded_name!r}"
    )


@given('a tool name "{original_name}"')
def step_given_tool_name(context: Any, original_name: str) -> None:
    context.roundtrip_name = original_name


@when("the tool name is encoded then decoded")
def step_encode_then_decode(context: Any) -> None:
    from cleveragents.reactive.tool_caller import _decode_tool_name, _encode_tool_name

    context.roundtrip_result = _decode_tool_name(
        _encode_tool_name(context.roundtrip_name)
    )


@then('the round-trip name equals "{expected}"')
def step_roundtrip_equals(context: Any, expected: str) -> None:
    assert context.roundtrip_result == expected, (
        f"Round-trip expected {expected!r}, got {context.roundtrip_result!r}"
    )


# ---------------------------------------------------------------------------
# Scenarios T: _resolve_llm encodes tool names in schemas
# ---------------------------------------------------------------------------


@given("a ToolCallingLLMCaller with default actor config")
def step_caller_default_config(context: Any) -> None:
    context.tool_caller = ToolCallingLLMCaller(
        actor_config={"provider": None, "model": None}
    )
    context.mock_llm_t = MagicMock()
    context.mock_llm_t.bind_tools.return_value = context.mock_llm_t


@when("_resolve_llm is called with a schema containing a namespaced tool name")
def step_resolve_llm_namespaced_schema(context: Any) -> None:
    with patch("cleveragents.reactive.tool_caller.get_provider_registry") as mock_reg:
        mock_reg.return_value.create_llm.return_value = context.mock_llm_t
        context.tool_caller._resolve_llm(
            [{"name": "builtin/file-read", "description": "Read a file"}]
        )


@then("bind_tools was called with the encoded tool name")
def step_bind_tools_called_with_encoded_name(context: Any) -> None:
    assert context.mock_llm_t.bind_tools.called, "Expected bind_tools to be called"
    call_args = context.mock_llm_t.bind_tools.call_args[0][0]
    assert len(call_args) == 1, f"Expected 1 schema, got {len(call_args)}"
    encoded_name = call_args[0]["name"]
    assert encoded_name == "builtin_S_file-read", (
        f"Expected encoded name 'builtin_S_file-read', got {encoded_name!r}"
    )
    # Verify the original schema was not mutated
    assert "/" not in encoded_name, "Encoded name must not contain '/'"


# ---------------------------------------------------------------------------
# Scenarios U: invoke decodes tool names from LLM response
# ---------------------------------------------------------------------------


@when("invoke is called and the LLM returns a response with encoded tool call names")
def step_invoke_encoded_tool_calls(context: Any) -> None:
    mock_llm = MagicMock()
    mock_response = MagicMock()
    mock_response.content = "I'll call a tool"
    mock_response.tool_calls = [
        {
            "name": "builtin_S_file-read",
            "args": {"path": "test.py"},
            "id": "tc-encoded",
        },
        {
            "name": "server_C_local_S_my-tool",
            "args": {"arg": "val"},
            "id": "tc-server-qualified",
        },
    ]
    mock_llm.invoke.return_value = mock_response
    context.llm_caller._llm = mock_llm
    context.llm_response = context.llm_caller.invoke(
        prompt="encoded tool call test",
        tool_schemas=[],
    )


@then("the LLMResponse contains the decoded tool calls")
def step_response_contains_decoded_tool_calls(context: Any) -> None:
    assert len(context.llm_response.tool_calls) == 2, (
        f"Expected 2 tool calls, got {len(context.llm_response.tool_calls)}"
    )
    names = [tc.name for tc in context.llm_response.tool_calls]
    assert "builtin/file-read" in names, (
        f"Expected 'builtin/file-read' in tool call names, got {names}"
    )
    assert "server:local/my-tool" in names, (
        f"Expected 'server:local/my-tool' in tool call names, got {names}"
    )


# ---------------------------------------------------------------------------
# Scenarios V: ToolCallingLLMCaller options block forwarding (#11243)
# ---------------------------------------------------------------------------


@given(
    "a ToolCallingLLMCaller with actor config options containing openai_api_base and openai_api_key"
)
def step_caller_with_options_api_base_and_key(context: Any) -> None:
    context.options_caller = ToolCallingLLMCaller(
        actor_config={
            "provider": "openai",
            "model": "gpt-4",
            "options": {
                "openai_api_base": "http://localhost:8080/v1",
                "openai_api_key": "none",
            },
        }
    )
    context.options_mock_llm = MagicMock()
    context.options_mock_llm.bind_tools.return_value = context.options_mock_llm


@when("_resolve_llm is called on the ToolCallingLLMCaller with empty tool_schemas")
def step_resolve_llm_options_empty_schemas(context: Any) -> None:
    with patch("cleveragents.reactive.tool_caller.get_provider_registry") as mock_reg:
        mock_reg.return_value.create_llm.return_value = context.options_mock_llm
        context.options_caller._resolve_llm([])
        context.options_create_llm_kwargs = (
            mock_reg.return_value.create_llm.call_args.kwargs
        )


@then("create_llm was called with openai_api_base forwarded from options")
def step_create_llm_called_with_api_base(context: Any) -> None:
    kwargs = context.options_create_llm_kwargs
    assert "openai_api_base" in kwargs, (
        f"Expected 'openai_api_base' in create_llm kwargs, got {kwargs}"
    )
    assert kwargs["openai_api_base"] == "http://localhost:8080/v1", (
        f"Expected openai_api_base='http://localhost:8080/v1', got {kwargs['openai_api_base']}"
    )


@then(
    "create_llm was called with __api_key_sentinel extracted from options openai_api_key"
)
def step_create_llm_called_with_api_key_sentinel(context: Any) -> None:
    kwargs = context.options_create_llm_kwargs
    assert kwargs.get("__api_key_sentinel") == "none", (
        f"Expected __api_key_sentinel='none' from options.openai_api_key, got {kwargs}"
    )
    assert "openai_api_key" not in kwargs, (
        "openai_api_key must be extracted to __api_key_sentinel, not passed directly"
    )


@given("a ToolCallingLLMCaller with actor config options containing allowed extra keys")
def step_caller_with_allowed_options_keys(context: Any) -> None:
    context.options_caller = ToolCallingLLMCaller(
        actor_config={
            "provider": "openai",
            "model": "gpt-4",
            "options": {
                "timeout": 30,
                "top_p": 0.9,
                "frequency_penalty": 0.1,
                "presence_penalty": 0.2,
            },
        }
    )
    context.options_mock_llm = MagicMock()
    context.options_mock_llm.bind_tools.return_value = context.options_mock_llm


@then("create_llm was called with the allowed options keys forwarded")
def step_create_llm_called_with_allowed_keys(context: Any) -> None:
    kwargs = context.options_create_llm_kwargs
    assert kwargs.get("timeout") == 30, (
        f"Expected timeout=30, got {kwargs.get('timeout')}"
    )
    assert kwargs.get("top_p") == 0.9, f"Expected top_p=0.9, got {kwargs.get('top_p')}"
    assert kwargs.get("frequency_penalty") == 0.1, (
        f"Expected frequency_penalty=0.1, got {kwargs.get('frequency_penalty')}"
    )
    assert kwargs.get("presence_penalty") == 0.2, (
        f"Expected presence_penalty=0.2, got {kwargs.get('presence_penalty')}"
    )


@given(
    "a ToolCallingLLMCaller with actor config options containing reserved key provider_type"
)
def step_caller_with_reserved_key(context: Any) -> None:
    context.options_caller = ToolCallingLLMCaller(
        actor_config={
            "provider": "openai",
            "model": "gpt-4",
            "options": {
                "provider_type": "should-be-rejected",
            },
        }
    )
    context.options_mock_llm = MagicMock()
    context.options_mock_llm.bind_tools.return_value = context.options_mock_llm


@then("create_llm was NOT called with the reserved key provider_type")
def step_create_llm_not_called_with_reserved(context: Any) -> None:
    kwargs = context.options_create_llm_kwargs
    # create_llm is always called with provider_type (the actor's real provider).
    # What must NOT happen is the options value ("should-be-rejected") overriding it.
    # The call must have succeeded without a TypeError from duplicate keyword arguments,
    # and the provider_type must be the actor's real value, not the options value.
    assert context.options_create_llm_kwargs is not None, (
        "_resolve_llm must succeed even when options contains reserved keys"
    )
    assert kwargs.get("provider_type") != "should-be-rejected", (
        f"Reserved key 'provider_type' from options must not override the actor provider; "
        f"got provider_type={kwargs.get('provider_type')!r}"
    )


@given(
    "a ToolCallingLLMCaller with actor config options containing unknown key foo_bar"
)
def step_caller_with_unknown_key(context: Any) -> None:
    context.options_caller = ToolCallingLLMCaller(
        actor_config={
            "provider": "openai",
            "model": "gpt-4",
            "options": {
                "foo_bar": "should-be-rejected",
            },
        }
    )
    context.options_mock_llm = MagicMock()
    context.options_mock_llm.bind_tools.return_value = context.options_mock_llm


@then("create_llm was NOT called with the unknown key foo_bar")
def step_create_llm_not_called_with_unknown(context: Any) -> None:
    kwargs = context.options_create_llm_kwargs
    assert "foo_bar" not in kwargs, (
        f"Unknown key 'foo_bar' must not be forwarded to create_llm, got {kwargs}"
    )


@given(
    "a ToolCallingLLMCaller with actor config with top-level temperature and options temperature"
)
def step_caller_top_level_temperature_vs_options(context: Any) -> None:
    context.options_caller = ToolCallingLLMCaller(
        actor_config={
            "provider": "openai",
            "model": "gpt-4",
            "temperature": 0.5,  # top-level value — must take precedence
            "options": {
                "temperature": 0.99,  # options value — must be ignored
            },
        }
    )
    context.options_mock_llm = MagicMock()
    context.options_mock_llm.bind_tools.return_value = context.options_mock_llm


@then("create_llm was called with the top-level temperature value")
def step_create_llm_called_with_top_level_temp(context: Any) -> None:
    kwargs = context.options_create_llm_kwargs
    assert kwargs.get("temperature") == 0.5, (
        f"Expected top-level temperature=0.5 to take precedence over options, "
        f"got temperature={kwargs.get('temperature')}"
    )
