"""Unit tests for MCP server context helpers and wiring."""

from __future__ import annotations

import asyncio
from pathlib import Path
from types import SimpleNamespace
from typing import Any

import pytest
from returns.result import Success

from anima.core.schemas import AgentState, AgentStateData, PersonaSpec
from anima.shell.agent_helpers import (
    _get_server_display_name,
    _get_server_tool_names,
    _get_server_version,
    collect_mcp_server_context,
)


class _RaiseOnAttr:
    """Object that raises on any attribute access."""

    def __getattribute__(self, _name: str) -> Any:
        raise RuntimeError("boom")


class _CountingServer:
    """Minimal external MCP server object that tracks calls and context."""

    def __init__(self, instructions: str) -> None:
        self._instructions = instructions
        self.read_count = 0
        self.tool_ctx_seen: list[Any] = []
        self.server_info = SimpleNamespace(name="counting")

    @property
    def instructions(self) -> str:
        self.read_count += 1
        return self._instructions

    async def get_tools(self, ctx: Any) -> dict[str, Any]:
        self.tool_ctx_seen.append(ctx)
        return {"tool_b": object(), "tool_a": object()}


class _ToolServer:
    """Minimal MCP server test double with configurable get_tools behavior."""

    def __init__(
        self,
        tools: dict[str, Any] | Exception,
        *,
        name: str = "server",
        version: Any = None,
        instructions: str | None = None,
    ) -> None:
        self._tools = tools
        self.ctx_seen: list[Any] = []
        self.server_info = SimpleNamespace(name=name, version=version)
        self.instructions = instructions

    async def get_tools(self, ctx: Any) -> dict[str, Any]:
        self.ctx_seen.append(ctx)
        if isinstance(self._tools, Exception):
            raise self._tools
        return self._tools


def test_get_server_display_name_prefers_server_info_name() -> None:
    """server_info.name has highest priority over command/url."""
    server = SimpleNamespace(
        server_info=SimpleNamespace(name="friendly-name"),
        command=["/usr/bin/tool"],
        url="https://example.com/mcp",
    )

    assert _get_server_display_name(server) == "friendly-name"


def test_get_server_display_name_falls_back_to_command_basename_from_list() -> None:
    """List command uses first element basename."""
    server = SimpleNamespace(server_info=None, command=["/opt/bin/mcp-server"])

    assert _get_server_display_name(server) == "mcp-server"


def test_get_server_display_name_falls_back_to_command_basename_from_string() -> None:
    """String command uses basename directly."""
    server = SimpleNamespace(server_info=None, command="/usr/local/bin/toolx")

    assert _get_server_display_name(server) == "toolx"


def test_get_server_display_name_falls_back_to_url_then_unknown() -> None:
    """URL is third priority and unknown is final fallback."""
    url_server = SimpleNamespace(server_info=None, command=None, url="http://mcp.local")
    unknown_server = SimpleNamespace(server_info=None, command=None, url=None)

    assert _get_server_display_name(url_server) == "http://mcp.local"
    assert _get_server_display_name(unknown_server) == "unknown"


def test_get_server_display_name_returns_unknown_on_attribute_error() -> None:
    """Defensive wrapper returns unknown on introspection errors."""
    assert _get_server_display_name(_RaiseOnAttr()) == "unknown"


def test_get_server_version_returns_version_as_string() -> None:
    """server_info.version is normalized to string."""
    server = SimpleNamespace(server_info=SimpleNamespace(version=123))

    assert _get_server_version(server) == "123"


def test_get_server_version_returns_none_when_missing_or_invalid() -> None:
    """Missing/invalid server metadata is treated as absent version."""
    no_info = SimpleNamespace(server_info=None)
    no_version = SimpleNamespace(server_info=SimpleNamespace(version=None))

    assert _get_server_version(no_info) is None
    assert _get_server_version(no_version) is None
    assert _get_server_version(_RaiseOnAttr()) is None


def test_get_server_tool_names_returns_sorted_keys_and_forwards_ctx() -> None:
    """Tool names are sorted and get_tools receives the same context."""
    ctx = object()
    server = _ToolServer({"zeta": object(), "alpha": object()})

    result = asyncio.run(_get_server_tool_names(server, ctx))

    assert result == ["alpha", "zeta"]
    assert server.ctx_seen == [ctx]


def test_get_server_tool_names_returns_empty_on_get_tools_error() -> None:
    """Defensive fallback returns empty list when get_tools fails."""
    server = _ToolServer(RuntimeError("boom"))

    assert asyncio.run(_get_server_tool_names(server, object())) == []


def test_get_server_tool_names_returns_empty_when_tools_shape_invalid() -> None:
    """Non-dict tool payload is safely treated as no tools."""

    class _InvalidToolsServer:
        async def get_tools(self, _ctx: Any) -> list[str]:
            return ["not", "a", "dict"]

    assert asyncio.run(_get_server_tool_names(_InvalidToolsServer(), object())) == []


def test_collect_mcp_server_context_returns_none_for_none_and_empty() -> None:
    """No servers means no context block."""
    ctx = object()

    assert asyncio.run(collect_mcp_server_context(None, ctx)) is None
    assert asyncio.run(collect_mcp_server_context([], ctx)) is None


def test_collect_mcp_server_context_single_server_block_and_ctx_passthrough() -> None:
    """Single server renders context with version/tools and forwards ctx."""
    ctx = object()
    server = _ToolServer(
        {"beta": object(), "alpha": object()},
        name="alpha",
        version="1.2.3",
        instructions="Use this MCP server carefully.",
    )

    result = asyncio.run(collect_mcp_server_context([server], ctx))

    assert result is not None
    assert result.startswith("## Available Tool Sources")
    assert "**alpha** (v1.2.3): alpha, beta" in result
    assert "Instructions: Use this MCP server carefully." in result
    assert server.ctx_seen == [ctx]


def test_collect_mcp_server_context_multi_server_ordering() -> None:
    """Multiple servers are appended in input order."""
    alpha = _ToolServer(
        {"alpha_tool": object()},
        name="alpha",
        instructions="alpha instructions",
    )
    beta = _ToolServer(
        {"beta_tool": object()},
        name="beta",
        instructions="beta instructions",
    )

    result = asyncio.run(collect_mcp_server_context([alpha, beta], object()))

    assert result is not None
    assert result.index("**alpha**") < result.index("**beta**")


def test_collect_mcp_server_context_adds_conflict_section_for_overlapping_tools() -> (
    None
):
    """Conflict section appears when tool names overlap across servers."""
    alpha = _ToolServer({"shared": object(), "alpha_only": object()}, name="alpha")
    beta = _ToolServer({"shared": object(), "beta_only": object()}, name="beta")

    result = asyncio.run(collect_mcp_server_context([alpha, beta], object()))

    assert result is not None
    assert "Tool name conflicts detected:" in result
    assert "  - 'shared' defined in: alpha, beta" in result


def test_collect_mcp_server_context_omits_conflict_section_without_overlap() -> None:
    """Conflict section is absent when all tool names are unique."""
    alpha = _ToolServer({"alpha_only": object()}, name="alpha")
    beta = _ToolServer({"beta_only": object()}, name="beta")

    result = asyncio.run(collect_mcp_server_context([alpha, beta], object()))

    assert result is not None
    assert "Tool name conflicts detected:" not in result


def test_collect_mcp_server_context_unwraps_wrapped_server() -> None:
    """Wrapper objects use the inner server's metadata and instructions."""
    wrapped = _ToolServer(
        {"wrapped_tool": object()},
        name="wrapped-name",
        instructions="wrapped instructions",
    )
    wrapper = SimpleNamespace(
        _wrapped=wrapped,
        get_tools=wrapped.get_tools,
    )

    result = asyncio.run(collect_mcp_server_context([wrapper], object()))

    assert result is not None
    assert "**wrapped-name**" in result
    assert "wrapped instructions" in result


def test_collect_mcp_server_context_preserves_full_instructions() -> None:
    """Instructions are included in full without truncation."""
    long_text = "x" * 700
    server = _ToolServer(
        {"alpha": object()},
        name="long",
        instructions=long_text,
    )

    result = asyncio.run(collect_mcp_server_context([server], object()))

    assert result is not None
    marker = "Instructions: "
    instructions = result.split(marker, maxsplit=1)[1]
    assert len(instructions) == 700
    assert "..." not in instructions


def test_collect_mcp_server_context_skips_server_that_raises_on_access() -> None:
    """Servers that raise during introspection are skipped defensively."""
    good = _ToolServer(
        {"good_tool": object()},
        name="good",
        instructions="good instructions",
    )

    result = asyncio.run(collect_mcp_server_context([_RaiseOnAttr(), good], object()))

    assert result is not None
    assert "**good**" in result
    assert "good instructions" in result


def test_get_dynamic_instructions_wiring_caches_mcp_collection(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Agent path get_dynamic_instructions -> collect_mcp_server_context is exercised."""
    from anima.shell import agent as agent_module

    persona = PersonaSpec(
        id="tester",
        description="A tester persona.",
        prompt="You are a tester.",
        model="test-model",
    )
    counting_server = _CountingServer("cached instruction payload")
    runner = agent_module.AgentRunner(
        instance_id="inst_mcp_metadata",
        task="run this",
        persona=persona,
        mcp_servers=[counting_server],
    )

    monkeypatch.setattr(
        agent_module, "append_log", lambda *_args, **_kwargs: Success(None)
    )
    monkeypatch.setattr(
        agent_module, "transition_state", lambda *_args, **_kwargs: Success(None)
    )

    calls: list[tuple[list[Any] | None, Any]] = []

    async def _fake_collect(servers: list[Any] | None, ctx: Any) -> str:
        calls.append((servers, ctx))
        return "## Available Tool Sources\n\n**counting**: tool_a, tool_b"

    monkeypatch.setattr(agent_module, "collect_mcp_server_context", _fake_collect)

    instructions_seen: list[str | None] = []

    class _FakeRun:
        async def __aenter__(self) -> _FakeRun:
            instructions_seen.append(await self._instructions_fn(None))
            instructions_seen.append(await self._instructions_fn(None))
            return self

        async def __aexit__(
            self,
            _exc_type: type[BaseException] | None,
            _exc: BaseException | None,
            _tb: Any,
        ) -> None:
            return None

        def __init__(self, instructions_fn: Any) -> None:
            self._instructions_fn = instructions_fn

        def __aiter__(self) -> _FakeRun:
            return self

        async def __anext__(self) -> Any:
            raise StopAsyncIteration

        def all_messages(self) -> list[Any]:
            return []

    class _FakeAgent:
        def iter(self, **kwargs: Any) -> _FakeRun:
            return _FakeRun(kwargs["instructions"])

    state = AgentStateData(
        instance_id="inst_mcp_metadata",
        trace_id="tr_mcp_metadata",
        persona_name="tester",
    )

    result = asyncio.run(runner._run_agent_loop(_FakeAgent(), state))

    assert isinstance(result, Success)
    assert result.unwrap().final_state == AgentState.completed
    assert len(instructions_seen) == 2
    assert instructions_seen[0] == instructions_seen[1]
    assert instructions_seen[0] is not None
    assert "## Available Tool Sources" in instructions_seen[0]
    assert len(calls) == 1
    # MCP servers are wrapped by _ToolNameSanitizedToolset, so check length
    assert len(calls[0][0]) == 1
    assert calls[0][1] is None
    assert counting_server.read_count == 0


def test_get_dynamic_instructions_includes_overlap_warning_from_live_mcp_context(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Dynamic instructions include conflict warning from overlapping tool names."""
    from anima.shell import agent as agent_module

    persona = PersonaSpec(
        id="tester",
        description="A tester persona.",
        prompt="You are a tester.",
        model="test-model",
    )
    runner = agent_module.AgentRunner(
        instance_id="inst_mcp_overlap_warning",
        task="run this",
        persona=persona,
        mcp_servers=[
            _ToolServer({"shared": object(), "alpha_only": object()}, name="alpha"),
            _ToolServer({"shared": object(), "beta_only": object()}, name="beta"),
        ],
    )

    monkeypatch.setattr(
        agent_module, "append_log", lambda *_args, **_kwargs: Success(None)
    )
    monkeypatch.setattr(
        agent_module, "transition_state", lambda *_args, **_kwargs: Success(None)
    )

    instructions_seen: list[str | None] = []

    class _CaptureRun:
        async def __aenter__(self) -> _CaptureRun:
            instructions_seen.append(await self._instructions_fn(None))
            return self

        async def __aexit__(
            self,
            _exc_type: type[BaseException] | None,
            _exc: BaseException | None,
            _tb: Any,
        ) -> None:
            return None

        def __init__(self, instructions_fn: Any) -> None:
            self._instructions_fn = instructions_fn

        def __aiter__(self) -> _CaptureRun:
            return self

        async def __anext__(self) -> Any:
            raise StopAsyncIteration

        def all_messages(self) -> list[Any]:
            return []

    class _CaptureAgent:
        def iter(self, **kwargs: Any) -> _CaptureRun:
            return _CaptureRun(kwargs["instructions"])

    state = AgentStateData(
        instance_id="inst_mcp_overlap_warning",
        trace_id="tr_mcp_overlap_warning",
        persona_name="tester",
    )

    result = asyncio.run(runner._run_agent_loop(_CaptureAgent(), state))

    assert isinstance(result, Success)
    assert len(instructions_seen) == 1
    assert instructions_seen[0] is not None
    assert "Tool name conflicts detected:" in instructions_seen[0]
    assert "  - 'shared' defined in: alpha, beta" in instructions_seen[0]
