"""Tests for agent ctl tool registration and MCP tool detection.

Tests for remediation.fix-combined-mode-detection:
1. detect_mcp_ctl_tools detects ctl_* tools from MCP toolsets
2. create_agent skips built-in ctl tools when MCP provides them
3. Non-overlapping tools coexist (MCP ctl_finish + builtin ctl_request_approval)
"""

import asyncio
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock

import pytest
from pydantic_ai import Agent
from pydantic_ai.models.test import TestModel
from pydantic_ai.toolsets import AbstractToolset, ToolsetTool
from returns.result import Success

from anima.core.lifecycle import LifecycleEvent
from anima.core.schemas import AgentState, AgentStateData, LifecycleType, PersonaSpec
from anima.shell.agent import (
    AgentRunner,
    create_agent,
)
from anima.shell.agent_helpers import (
    detect_mcp_ctl_tools,
    _MockModel,
)
from anima.shell.state import read_state, transition_state, write_state
from tests.helpers.mcp_fakes import FakeMCPServer


class MockToolset(AbstractToolset[Any]):
    """Mock MCP toolset for testing ctl tool detection."""

    def __init__(self, tool_names: list[str]):
        """Initialize with tool names to expose.

        Args:
            tool_names: List of tool names this toolset provides.
        """
        self._tool_names = tool_names
        self._id = f"mock-toolset-{id(self)}"

    @property
    def id(self) -> str:
        return self._id

    async def get_tools(self, ctx: Any) -> dict[str, ToolsetTool[Any]]:
        """Return mock tools with specified names."""
        tools: dict[str, ToolsetTool[Any]] = {}
        for name in self._tool_names:
            # Create a mock ToolsetTool for each name
            mock_tool = MagicMock(spec=ToolsetTool)
            mock_tool.name = name
            tools[name] = mock_tool
        return tools

    async def call_tool(
        self, name: str, tool_args: dict[str, Any], ctx: Any, tool: ToolsetTool[Any]
    ) -> Any:
        """Mock tool call implementation."""
        return f"mock result for {name}"


class FailingToolset(AbstractToolset[Any]):
    """Toolset that raises ExceptionGroup during introspection."""

    @property
    def id(self) -> str:
        return "failing-toolset"

    async def get_tools(self, ctx: Any) -> dict[str, ToolsetTool[Any]]:
        _ = ctx
        raise ExceptionGroup("mcp init failed", [RuntimeError("Connection closed")])

    async def call_tool(
        self, name: str, tool_args: dict[str, Any], ctx: Any, tool: ToolsetTool[Any]
    ) -> Any:
        _ = (name, tool_args, ctx, tool)
        raise RuntimeError("not used")


class TestDetectMcpCtTools:
    """Tests for detect_mcp_ctl_tools function."""

    @pytest.mark.asyncio
    async def test_detect_mcp_ctl_tools_none_input(self):
        """detect_mcp_ctl_tools with None returns empty list."""
        result = await detect_mcp_ctl_tools(None)
        assert result == []

    @pytest.mark.asyncio
    async def test_detect_mcp_ctl_tools_empty_list(self):
        """detect_mcp_ctl_tools with empty list returns empty list."""
        result = await detect_mcp_ctl_tools([])
        assert result == []

    @pytest.mark.asyncio
    async def test_detect_mcp_ctl_tools_no_ctl_tools(self):
        """Toolset without ctl tools returns empty list."""
        toolset = MockToolset(tool_names=["other_tool", "another_tool"])
        result = await detect_mcp_ctl_tools([toolset])
        assert result == []

    @pytest.mark.asyncio
    async def test_detect_mcp_ctl_tools_with_ctl_tools(self):
        """Toolset with ctl tools returns their names."""
        toolset = MockToolset(tool_names=["ctl_finish", "ctl_fail", "other_tool"])
        result = await detect_mcp_ctl_tools([toolset])
        assert set(result) == {"ctl_finish", "ctl_fail"}

    @pytest.mark.asyncio
    async def test_detect_mcp_ctl_tools_multiple_toolsets(self):
        """Multiple toolsets are combined."""
        toolset1 = MockToolset(tool_names=["ctl_finish", "other1"])
        toolset2 = MockToolset(
            tool_names=["ctl_fail", "ctl_request_approval", "other2"]
        )
        result = await detect_mcp_ctl_tools([toolset1, toolset2])
        assert set(result) == {"ctl_finish", "ctl_fail", "ctl_request_approval"}

    @pytest.mark.asyncio
    async def test_detect_mcp_ctl_tools_filters_non_ctl(self):
        """Only ctl_* prefixed tools are detected."""
        toolset = MockToolset(
            tool_names=[
                "anima.status",
                "anima.interact",
                "ctl_finish",
                "ctl_fail",
                "other_tool",
            ]
        )
        result = await detect_mcp_ctl_tools([toolset])
        assert set(result) == {"ctl_finish", "ctl_fail"}

    @pytest.mark.asyncio
    async def test_detect_mcp_ctl_tools_ignores_exception_group(self):
        """ExceptionGroup from one toolset does not fail detection."""
        failing = FailingToolset()
        healthy = MockToolset(tool_names=["ctl_finish", "other"])
        result = await detect_mcp_ctl_tools([failing, healthy])
        assert set(result) == {"ctl_finish"}


class TestMockModel:
    """Tests for _MockModel used in RunContext creation."""

    def test_mock_model_has_required_attributes(self):
        """Mock model provides id and model_name for RunContext."""
        model = _MockModel()
        assert model.id == "introspection-mock"
        assert model.model_name == "introspection-mock"


class TestCreateAgentAsync:
    """Tests for async create_agent with MCP detection."""

    @pytest.fixture
    def work_dir(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
        """Create personas directory and switch cwd for relative persona refs."""
        root = tmp_path / "work"
        personas = root / "personas"
        personas.mkdir(parents=True)

        parent = PersonaSpec(
            id="parent",
            description="A test persona.",
            prompt="Parent persona",
            model="gpt-4",
            can_spawn=True,
        )
        helper = PersonaSpec(
            id="helper",
            description="A test persona.",
            prompt="Helper persona",
            model="gpt-4",
        )
        (personas / "parent.json").write_text(parent.model_dump_json())
        (personas / "helper.json").write_text(helper.model_dump_json())

        monkeypatch.chdir(root)
        return root

    @pytest.mark.asyncio
    async def test_create_agent_no_mcp_no_runner(self, tmp_path: Path, monkeypatch):
        """create_agent without MCP or runner creates agent with no ctl tools."""
        monkeypatch.setenv("ANIMA_STATE", str(tmp_path / ".anima"))

        persona = PersonaSpec(
            id="test",
            description="A test persona.",
            prompt="Test agent",
            model="gpt-4",
        )

        result = await create_agent(persona)

        assert isinstance(result, Success)
        agent = result.unwrap()
        assert isinstance(agent, Agent)
        # No ctl tools registered since no agent_runner
        agent_any = agent  # type: Any
        assert not hasattr(agent_any, "_tools") or not agent_any._tools

    @pytest.mark.asyncio
    async def test_create_agent_with_runner_registers_ctl(
        self, tmp_path: Path, monkeypatch
    ):
        """create_agent with runner registers all built-in ctl tools."""
        monkeypatch.setenv("ANIMA_STATE", str(tmp_path / ".anima"))

        persona = PersonaSpec(
            id="test",
            description="A test persona.",
            prompt="Test agent",
            model="gpt-4",
        )

        # Create a mock runner
        runner = MagicMock(spec=AgentRunner)
        runner.instance_id = "test-agent"

        result = await create_agent(persona, agent_runner=runner)

        assert isinstance(result, Success)
        agent = result.unwrap()
        assert isinstance(agent, Agent)
        # Verify ctl tools are in agent

    @pytest.mark.asyncio
    async def test_create_agent_exposes_ctl_spawn_runtime_schema(
        self, tmp_path: Path, monkeypatch
    ):
        """Wrapped ctl_spawn keeps full runtime JSON schema."""
        monkeypatch.setenv("ANIMA_STATE", str(tmp_path / ".anima"))

        persona = PersonaSpec(
            id="test",
            description="A test persona.",
            prompt="Test agent",
            model="gpt-4",
        )

        runner = MagicMock(spec=AgentRunner)
        runner.instance_id = "test-agent"

        result = await create_agent(persona, agent_runner=runner)

        assert isinstance(result, Success)
        agent = result.unwrap()
        agent_any = agent  # type: Any
        tool = agent_any._function_toolset.tools["ctl_spawn"]
        schema = tool.function_schema.json_schema
        properties = schema["properties"]

        assert list(properties) == [
            "persona_ref",
            "task",
            "lifecycle",
            "context_mode",
            "max_turns",
            "max_tokens",
        ]
        assert properties["persona_ref"]["default"] is None
        assert schema["additionalProperties"] is False
        assert schema["required"] == ["task"]
        assert properties["context_mode"]["default"] == "isolated"
        assert properties["max_turns"]["default"] is None
        assert properties["max_tokens"]["default"] is None

    @pytest.mark.asyncio
    async def test_create_agent_preserves_runtime_schema_for_all_builtin_ctl_tools(
        self, tmp_path: Path, monkeypatch
    ):
        """All policy-wrapped built-in ctl tools keep their runtime args."""
        monkeypatch.setenv("ANIMA_STATE", str(tmp_path / ".anima"))

        persona = PersonaSpec(
            id="test",
            description="A test persona.",
            prompt="Test agent",
            model="gpt-4",
        )

        runner = MagicMock(spec=AgentRunner)
        runner.instance_id = "test-agent"

        result = await create_agent(persona, agent_runner=runner)

        assert isinstance(result, Success)
        agent = result.unwrap()
        agent_any = agent  # type: Any
        tools = agent_any._function_toolset.tools

        expected_properties = {
            "ctl_await": ["child_ids", "timeout", "max_response_chars"],
            "ctl_cancel": ["child_id"],
            "ctl_child_logs": ["child_id", "last_n"],
            "ctl_delegate": [
                "task",
                "persona_ref",
                "context_mode",
                "max_turns",
                "max_tokens",
                "timeout",
                "max_response_chars",
            ],
            "ctl_finish": ["result_ref", "error"],
            "ctl_poll": ["child_id"],
            "ctl_request_approval": ["reason"],
            "ctl_spawn": [
                "persona_ref",
                "task",
                "lifecycle",
                "context_mode",
                "max_turns",
                "max_tokens",
            ],
        }

        for tool_name, property_names in expected_properties.items():
            assert tool_name in tools
            schema = tools[tool_name].function_schema.json_schema
            assert list(schema["properties"]) == property_names
            assert schema["additionalProperties"] is False
        assert tools["ctl_spawn"].function_schema.json_schema["required"] == [
            "task"
        ]

    @pytest.mark.asyncio
    async def test_create_agent_builtin_ctl_schema_snapshot(
        self, tmp_path: Path, monkeypatch
    ) -> None:
        """Built-in CTL runtime schemas stay stable across wrapper refactors."""
        monkeypatch.setenv("ANIMA_STATE", str(tmp_path / ".anima"))

        persona = PersonaSpec(
            id="test",
            description="A test persona.",
            prompt="Test agent",
            model="gpt-4",
        )

        runner = MagicMock(spec=AgentRunner)
        runner.instance_id = "test-agent"

        result = await create_agent(persona, agent_runner=runner)

        assert isinstance(result, Success)
        agent = result.unwrap()
        agent_any = agent  # type: Any
        tools = agent_any._function_toolset.tools

        schema_snapshot = {
            name: tools[name].function_schema.json_schema for name in sorted(tools)
        }

        assert schema_snapshot == {
            "ctl_await": {
                "additionalProperties": False,
                "properties": {
                    "child_ids": {
                        "description": "List of child_id strings from ctl_spawn. Use the\nEXACT strings returned \u2014 do not modify or reconstruct them.",
                        "items": {"type": "string"},
                        "type": "array",
                    },
                    "timeout": {
                        "anyOf": [{"type": "number"}, {"type": "null"}],
                        "default": None,
                        "description": "Max seconds to wait. None = wait indefinitely.",
                    },
                    "max_response_chars": {
                        "anyOf": [{"type": "integer"}, {"type": "null"}],
                        "default": 4000,
                        "description": "Truncate each child's response. Default 4000.",
                    },
                },
                "required": ["child_ids"],
                "type": "object",
            },
            "ctl_cancel": {
                "additionalProperties": False,
                "properties": {
                    "child_id": {
                        "description": "The child's instance_id from ctl_spawn.",
                        "type": "string",
                    }
                },
                "required": ["child_id"],
                "type": "object",
            },
            "ctl_child_logs": {
                "additionalProperties": False,
                "properties": {
                    "child_id": {
                        "description": "The child's instance_id from ctl_spawn.",
                        "type": "string",
                    },
                    "last_n": {
                        "anyOf": [{"type": "integer"}, {"type": "null"}],
                        "default": 10,
                        "description": "Return last N events. Default 10. None for all (caution: large).",
                    },
                },
                "required": ["child_id"],
                "type": "object",
            },
            "ctl_delegate": {
                "additionalProperties": False,
                "properties": {
                    "task": {
                        "description": "Self-contained instruction for the child. Include all\ncontext \u2014 the child has no memory of your conversation.",
                        "type": "string",
                    },
                    "persona_ref": {
                        "anyOf": [{"type": "string"}, {"type": "null"}],
                        "default": None,
                        "description": "Persona identifier for the child agent. Accepts:\n- Relative file path: \"personas/helper.json\"\n- Registry spec_id: \"python-executor\" (resolved via larva if available)\n- None: inherit the parent's persona (default)\nDo NOT use absolute paths or add @ prefix.",
                    },
                    "context_mode": {
                        "anyOf": [{"type": "string"}, {"type": "null"}],
                        "default": None,
                        "description": '"isolated" (default, child starts fresh) or "shared" (child receives parent\'s conversation history).',
                    },
                    "max_turns": {
                        "anyOf": [{"type": "integer"}, {"type": "null"}],
                        "default": None,
                        "description": "Leave as None (unlimited). Do NOT set unless user asks.",
                    },
                    "max_tokens": {
                        "anyOf": [{"type": "integer"}, {"type": "null"}],
                        "default": None,
                        "description": "Leave as None (unlimited). Do NOT set unless user asks.",
                    },
                    "timeout": {
                        "anyOf": [{"type": "number"}, {"type": "null"}],
                        "default": None,
                        "description": "Max seconds to wait. None = wait indefinitely.",
                    },
                    "max_response_chars": {
                        "anyOf": [{"type": "integer"}, {"type": "null"}],
                        "default": 4000,
                        "description": "Truncate response to this length. Default 4000.",
                    },
                },
                "required": ["task"],
                "type": "object",
            },
            "ctl_finish": {
                "additionalProperties": False,
                "properties": {
                    "result_ref": {
                        "anyOf": [{"type": "string"}, {"type": "null"}],
                        "default": None,
                        "description": "Optional path to a result file you produced.",
                    },
                    "error": {
                        "anyOf": [{"type": "string"}, {"type": "null"}],
                        "default": None,
                        "description": "Optional error message for unrecoverable internal errors.",
                    },
                },
                "type": "object",
            },
            "ctl_poll": {
                "additionalProperties": False,
                "properties": {
                    "child_id": {
                        "description": "The child's instance_id from ctl_spawn.",
                        "type": "string",
                    }
                },
                "required": ["child_id"],
                "type": "object",
            },
            "ctl_request_approval": {
                "additionalProperties": False,
                "properties": {
                    "reason": {
                        "default": "Approval requested by tool policy",
                        "description": "Human-readable explanation of what you need approval for.",
                        "type": "string",
                    }
                },
                "type": "object",
            },
            "ctl_spawn": {
                "additionalProperties": False,
                "properties": {
                    "persona_ref": {
                        "anyOf": [{"type": "string"}, {"type": "null"}],
                        "default": None,
                        "description": "Persona identifier for the child agent. Accepts:\n- Relative file path: \"personas/helper.json\"\n- Registry spec_id: \"python-executor\" (resolved via larva if available)\n- None: inherit the parent's persona (default)\nDo NOT use absolute paths or add @ prefix.",
                    },
                    "task": {
                        "description": "Self-contained instruction for the child. Include all\ncontext \u2014 the child has no memory of your conversation.",
                        "type": "string",
                    },
                    "lifecycle": {
                        "anyOf": [{"type": "string"}, {"type": "null"}],
                        "default": None,
                        "description": '"ephemeral" (default, auto-exits on completion) or "daemon" (stays alive).',
                    },
                    "context_mode": {
                        "default": "isolated",
                        "description": '"isolated" (default, child starts fresh) or "shared" (child receives parent\'s conversation history).',
                        "type": "string",
                    },
                    "max_turns": {
                        "anyOf": [{"type": "integer"}, {"type": "null"}],
                        "default": None,
                        "description": "Leave as None (unlimited). Do NOT set unless user asks.",
                    },
                    "max_tokens": {
                        "anyOf": [{"type": "integer"}, {"type": "null"}],
                        "default": None,
                        "description": "Leave as None (unlimited). Do NOT set unless user asks.",
                    },
                },
                "required": ["task"],
                "type": "object",
            },
            "ctl_switch_persona": {
                "additionalProperties": False,
                "properties": {
                    "persona_ref": {
                        "description": "Persona identifier to switch to. Accepts:\n- Relative file path: \"personas/reviewer.json\"\n- Registry spec_id: \"code-reviewer\" (if larva available)\nDo NOT use absolute paths or add @ prefix.",
                        "type": "string",
                    },
                },
                "required": ["persona_ref"],
                "type": "object",
            },
        }

    @pytest.mark.asyncio
    async def test_runner_executes_ctl_spawn_via_model_tool_call(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, work_dir: Path
    ) -> None:
        """Real model tool-call path can invoke ctl_spawn with preserved schema."""
        monkeypatch.setenv("ANIMA_STATE", str(tmp_path / ".anima"))

        def _launch_stub(**_kwargs: object) -> Success[int]:
            return Success(4242)

        monkeypatch.setattr(
            "anima.shell.ctl_spawn.launch_agent_subprocess", _launch_stub
        )

        _ = work_dir
        instance_id = "inst_spawn_runtime"
        state = AgentStateData(
            trace_id="tr_test",
            instance_id=instance_id,
            persona_name="parent",
            persona_ref="personas/parent.json",
            state=AgentState.pending,
            lifecycle=LifecycleType.ephemeral,
            depth=0,
            max_depth=3,
        )
        write_state(instance_id, state)

        persona = PersonaSpec(
            id="parent",
            description="A test persona.",
            prompt="You are a parent agent.",
            model="test-model",
            can_spawn=True,
        )
        runner = AgentRunner(
            instance_id=instance_id,
            task="Spawn a helper agent.",
            persona=persona,
            model_instance=TestModel(call_tools=["ctl_spawn"]),
        )
        runner.mcp_server = FakeMCPServer(instance_id, runner)  # type: ignore[assignment]

        result = await create_agent(
            persona,
            agent_runner=runner,
            model_instance=runner.model_instance,
        )
        assert isinstance(result, Success)
        agent = result.unwrap()

        started = transition_state(instance_id, LifecycleEvent.start)
        assert isinstance(started, Success)

        state_result = read_state(instance_id)
        assert isinstance(state_result, Success)

        run_result = await runner._run_agent_loop(agent, state_result.unwrap())
        assert isinstance(run_result, Success)

        parent_result = read_state(instance_id)
        assert isinstance(parent_result, Success)
        parent_state = parent_result.unwrap()
        assert len(parent_state.children) == 1

        child_id = parent_state.children[0]
        child_result = read_state(child_id)
        assert isinstance(child_result, Success)
        child_state = child_result.unwrap()
        assert child_state.parent_id == instance_id
        assert child_state.depth == 1
        assert child_state.max_depth == 3

    @pytest.mark.asyncio
    async def test_create_agent_mcp_skips_builtin_ctl_finish(
        self, tmp_path: Path, monkeypatch
    ):
        """When MCP provides ctl_finish, built-in ctl_finish is skipped."""
        monkeypatch.setenv("ANIMA_STATE", str(tmp_path / ".anima"))

        persona = PersonaSpec(
            id="test",
            description="A test persona.",
            prompt="Test agent",
            model="gpt-4",
        )

        # Mock toolset that provides ctl_finish
        mcp_toolset = MockToolset(tool_names=["ctl_finish"])

        runner = MagicMock(spec=AgentRunner)
        runner.instance_id = "test-agent"

        result = await create_agent(
            persona,
            mcp_servers=[mcp_toolset],
            agent_runner=runner,
        )

        assert isinstance(result, Success)
        result.unwrap()
        # ctl_finish should come from MCP, not built-in

    @pytest.mark.asyncio
    async def test_create_agent_mcp_and_builtin_coexist(
        self, tmp_path: Path, monkeypatch
    ):
        """MCP ctl_finish + built-in ctl_request_approval (non-overlapping) coexist."""
        monkeypatch.setenv("ANIMA_STATE", str(tmp_path / ".anima"))

        persona = PersonaSpec(
            id="test",
            description="A test persona.",
            prompt="Test agent",
            model="gpt-4",
        )

        # MCP provides only ctl_finish
        mcp_toolset = MockToolset(tool_names=["ctl_finish"])

        runner = MagicMock(spec=AgentRunner)
        runner.instance_id = "test-agent"

        result = await create_agent(
            persona,
            mcp_servers=[mcp_toolset],
            agent_runner=runner,
        )

        assert isinstance(result, Success)
        # Built-in ctl tools EXCEPT ctl_finish should be registered


class TestCtlToolPrecedence:
    """Tests for tool precedence (MCP over built-in)."""

    @pytest.mark.asyncio
    async def test_ctl_finish_from_mcp_has_precedence(
        self, tmp_path: Path, monkeypatch
    ):
        """When both MCP and built-in provide ctl_finish, MCP version wins."""
        monkeypatch.setenv("ANIMA_STATE", str(tmp_path / ".anima"))

        persona = PersonaSpec(
            id="test", description="A test persona.", prompt="Test", model="gpt-4"
        )

        # MCP provides ALL ctl tools
        mcp_toolset = MockToolset(
            tool_names=[
                "ctl_finish",
                "ctl_request_approval",
                "ctl_spawn",
            ]
        )

        runner = MagicMock(spec=AgentRunner)
        runner.instance_id = "test-agent"

        result = await create_agent(
            persona,
            mcp_servers=[mcp_toolset],
            agent_runner=runner,
        )

        assert isinstance(result, Success)
        # All ctl tools should come from MCP, no built-in registered

    @pytest.mark.asyncio
    async def test_partial_mcp_ctl_subset(self, tmp_path: Path, monkeypatch):
        """MCP provides subset of ctl tools, built-in provides the rest."""
        monkeypatch.setenv("ANIMA_STATE", str(tmp_path / ".anima"))

        persona = PersonaSpec(
            id="test", description="A test persona.", prompt="Test", model="gpt-4"
        )

        # MCP provides only ctl_finish and ctl_request_approval
        mcp_toolset = MockToolset(tool_names=["ctl_finish", "ctl_request_approval"])

        runner = MagicMock(spec=AgentRunner)
        runner.instance_id = "test-agent"

        result = await create_agent(
            persona,
            mcp_servers=[mcp_toolset],
            agent_runner=runner,
        )

        assert isinstance(result, Success)
        # Built-in provides: ctl_spawn, ctl_delegate, ctl_await, etc.
        # MCP provides: ctl_finish, ctl_request_approval


class TestIntegrationL2:
    """Integration tests from test_integration_l2 for backward compatibility."""

    def test_create_agent_basic(self, tmp_path: Path, monkeypatch):
        """Basic create_agent call still works (backward compatibility)."""
        monkeypatch.setenv("ANIMA_STATE", str(tmp_path / ".anima"))

        persona = PersonaSpec(
            id="test",
            description="A test persona.",
            prompt="Test agent",
            model="gpt-4",
        )

        # Sync wrapper would be needed for backward compatibility
        # For now, we test the async version directly
        result = asyncio.run(create_agent(persona))

        assert isinstance(result, Success)

    def test_grace_injection_point_exists(self):
        """AgentRunner has grace message injection point in _run_agent_loop."""
        runner = AgentRunner(instance_id="inst_test", task="Test task")

        # Verify grace state exists
        assert hasattr(runner, "in_grace_window")
        assert hasattr(runner, "grace_exhausted_without_finish")
        assert hasattr(runner, "grace_message_injected")

        # Initially not in grace
        assert runner.in_grace_window is False
        assert runner.grace_exhausted_without_finish is False
        assert runner.grace_message_injected is False
