"""Tests for _meta injection into outgoing MCP tool calls.

Per docs/INTERFACES.md Section E, anima injects _meta into every outgoing
MCP tool call's arguments. These tests verify the injection callback
and the wrapper functions work correctly.
"""

import pytest
from pydantic_ai.mcp import ToolResult
from pydantic_ai.tools import RunContext

from anima.core.schemas import AgentStateData
from anima.shell.meta_injection import (
    build_call_meta,
    create_meta_injection_callback,
    wrap_mcp_servers_with_meta,
)


class TestBuildCallMeta:
    """Tests for build_call_meta function."""

    def test_basic_fields_always_present(self) -> None:
        """trace_id and instance_id are always in _meta (per meta.schema.json)."""
        state = AgentStateData(
            instance_id="inst_abc",
            persona_name="test",
            trace_id="tr_xyz",
            depth=0,
            parent_id=None,
        )
        meta = build_call_meta(state)

        assert meta["trace_id"] == "tr_xyz"
        assert meta["instance_id"] == "inst_abc"
        # depth and parent_id are internal fields, NOT in _meta wire contract
        assert "depth" not in meta
        assert "parent_id" not in meta

    def test_persona_id_optional(self) -> None:
        """persona_id is only present when state.spec_id is set."""
        # Without persona source
        state1 = AgentStateData(
            instance_id="inst_1",
            persona_name="test",
            trace_id="tr_1",
        )
        meta1 = build_call_meta(state1)
        assert "persona_id" not in meta1

        # With persona source
        state2 = AgentStateData(
            instance_id="inst_2",
            persona_name="test",
            trace_id="tr_2",
            spec_id="persona@v1",
        )
        meta2 = build_call_meta(state2)
        assert meta2["persona_id"] == "persona@v1"
        assert "spec_id" not in meta2

    def test_internal_fields_not_in_meta(self) -> None:
        """depth and parent_id are internal fields, NOT in _meta wire contract."""
        state = AgentStateData(
            instance_id="inst_child",
            persona_name="test",
            trace_id="tr_child",
            depth=1,
            parent_id="inst_parent",
        )
        meta = build_call_meta(state)
        # Per meta.schema.json, depth and parent_id are NOT part of _meta
        assert "depth" not in meta
        assert "parent_id" not in meta
        assert meta["trace_id"] == "tr_child"
        assert meta["instance_id"] == "inst_child"

    def test_spec_digest_not_included(self) -> None:
        """spec_digest is omitted from _meta even when present in state."""
        state = AgentStateData(
            instance_id="inst_digest",
            persona_name="test",
            trace_id="tr_digest",
            spec_id="persona@v1",
            spec_digest="sha256:abc123",
        )
        meta = build_call_meta(state)
        assert meta["persona_id"] == "persona@v1"
        assert "spec_id" not in meta
        assert "spec_digest" not in meta


class TestMetaInjectionCallback:
    """Tests for create_meta_injection_callback function."""

    @pytest.mark.asyncio
    async def test_callback_injects_meta_into_metadata(self) -> None:
        """Callback passes _meta dict as metadata parameter."""
        from unittest.mock import MagicMock

        state = AgentStateData(
            instance_id="inst_123",
            persona_name="test-agent",
            trace_id="tr_abc",
            depth=0,
        )
        callback = create_meta_injection_callback(state)

        # Track what args were passed to call_tool
        captured_calls: list[tuple[str, dict, dict | None]] = []

        async def mock_call_tool(
            name: str,
            args: dict[str, object],
            metadata: dict[str, object] | None = None,
        ) -> ToolResult:
            captured_calls.append((name, args, metadata))
            return "test result"

        # Create a minimal RunContext with mocked model/usage
        ctx = MagicMock(spec=RunContext)
        ctx.deps = None

        # Call the callback
        result = await callback(
            ctx=ctx,
            call_tool=mock_call_tool,
            name="read_file",
            args={"path": "/test/file.txt"},
        )

        # Verify result is forwarded
        assert result == "test result"

        # Verify _meta was passed as metadata
        assert len(captured_calls) == 1
        name, args, metadata = captured_calls[0]
        assert name == "read_file"
        assert args == {"path": "/test/file.txt"}
        assert metadata is not None
        assert metadata["trace_id"] == "tr_abc"
        assert metadata["instance_id"] == "inst_123"
        # depth and parent_id are NOT in _meta wire contract
        assert "depth" not in metadata
        assert "parent_id" not in metadata

    @pytest.mark.asyncio
    async def test_callback_preserves_existing_args(self) -> None:
        """Callback preserves all existing tool arguments."""
        from unittest.mock import MagicMock

        state = AgentStateData(
            instance_id="inst_456",
            persona_name="test",
            trace_id="tr_def",
        )
        callback = create_meta_injection_callback(state)

        captured_calls: list[tuple[str, dict, dict | None]] = []

        async def mock_call_tool(
            name: str,
            args: dict[str, object],
            metadata: dict[str, object] | None = None,
        ) -> ToolResult:
            captured_calls.append((name, args, metadata))
            return {"status": "ok"}

        ctx = MagicMock(spec=RunContext)
        ctx.deps = None

        # Call with multiple arguments
        result = await callback(
            ctx=ctx,
            call_tool=mock_call_tool,
            name="write_file",
            args={
                "path": "/test/out.txt",
                "content": "Hello world",
                "mode": "overwrite",
            },
        )

        assert result == {"status": "ok"}
        assert len(captured_calls) == 1
        name, args, metadata = captured_calls[0]
        assert name == "write_file"
        assert args["path"] == "/test/out.txt"
        assert args["content"] == "Hello world"
        assert args["mode"] == "overwrite"
        assert metadata is not None
        assert metadata["instance_id"] == "inst_456"


class TestWrapMcpServersWithMeta:
    """Tests for wrap_mcp_servers_with_meta function."""

    def test_sets_callback_on_servers_without_one(self) -> None:
        """wrap_mcp_servers_with_meta sets process_tool_call on servers that don't have one."""
        from pydantic_ai.mcp import MCPServerStdio

        state = AgentStateData(
            instance_id="inst_789",
            persona_name="test",
            trace_id="tr_ghi",
        )

        # Create servers without process_tool_call
        server1 = MCPServerStdio("echo", [])
        server2 = MCPServerStdio("cat", [])

        # Verify they start without callback
        assert server1.process_tool_call is None
        assert server2.process_tool_call is None

        # Wrap with meta injection
        servers = wrap_mcp_servers_with_meta([server1, server2], state)

        # Verify callback is set
        assert servers[0].process_tool_call is not None
        assert servers[1].process_tool_call is not None

    def test_preserves_existing_callback(self) -> None:
        """wrap_mcp_servers_with_meta preserves existing process_tool_call."""
        from pydantic_ai.mcp import MCPServerStdio

        state = AgentStateData(
            instance_id="inst_preserve",
            persona_name="test",
            trace_id="tr_preserve",
        )

        # Create a server with existing callback
        async def existing_callback(ctx, call_tool, name, args):
            return await call_tool(name, args)

        server = MCPServerStdio("echo", [])
        server.process_tool_call = existing_callback

        # Wrap should not modify
        servers = wrap_mcp_servers_with_meta([server], state)

        # Verify existing callback preserved
        assert servers[0].process_tool_call is existing_callback

    def test_returns_same_servers(self) -> None:
        """wrap_mcp_servers_with_meta returns the same server objects (modified in-place)."""
        from pydantic_ai.mcp import MCPServerStdio

        state = AgentStateData(
            instance_id="inst_same",
            persona_name="test",
            trace_id="tr_same",
        )

        server1 = MCPServerStdio("echo", [])
        server2 = MCPServerStdio("cat", [])

        servers = wrap_mcp_servers_with_meta([server1, server2], state)

        # Same objects returned
        assert servers[0] is server1
        assert servers[1] is server2


class TestMetaFieldPresence:
    """Tests for _meta field presence rules per docs/INTERFACES.md Section E."""

    @pytest.mark.asyncio
    async def test_all_fields_present_when_available(self) -> None:
        """Meta fields present per updated schema when state has all values."""
        from unittest.mock import MagicMock

        state = AgentStateData(
            instance_id="inst_full",
            persona_name="test",
            trace_id="tr_full",
            depth=2,
            parent_id="inst_parent",
            spec_id="persona@sha256:abc",
            spec_digest="sha256:def456",
        )
        callback = create_meta_injection_callback(state)

        captured_calls: list[tuple[str, dict, dict | None]] = []

        async def mock_call_tool(
            name: str,
            args: dict[str, object],
            metadata: dict[str, object] | None = None,
        ) -> ToolResult:
            captured_calls.append((name, args, metadata))
            return "ok"

        ctx = MagicMock(spec=RunContext)
        ctx.deps = None

        await callback(
            ctx=ctx,
            call_tool=mock_call_tool,
            name="some_tool",
            args={},
        )

        assert len(captured_calls) == 1
        name, args, metadata = captured_calls[0]
        assert metadata is not None
        # Per Meta schema updates
        assert metadata["trace_id"] == "tr_full"
        assert metadata["instance_id"] == "inst_full"
        # persona_id present when state.spec_id is set
        assert metadata["persona_id"] == "persona@sha256:abc"
        # legacy fields are omitted
        assert "spec_id" not in metadata
        assert "spec_digest" not in metadata
        # depth and parent_id are NOT part of _meta wire contract
        assert "depth" not in metadata
        assert "parent_id" not in metadata

    @pytest.mark.asyncio
    async def test_no_persona_id_when_not_set(self) -> None:
        """persona_id is NOT present when state.spec_id is not set."""
        from unittest.mock import MagicMock

        state = AgentStateData(
            instance_id="inst_no_spec",
            persona_name="test",
            trace_id="tr_no_spec",
            depth=0,
            parent_id=None,
        )
        callback = create_meta_injection_callback(state)

        captured_calls: list[tuple[str, dict, dict | None]] = []

        async def mock_call_tool(
            name: str,
            args: dict[str, object],
            metadata: dict[str, object] | None = None,
        ) -> ToolResult:
            captured_calls.append((name, args, metadata))
            return "ok"

        ctx = MagicMock(spec=RunContext)
        ctx.deps = None

        await callback(
            ctx=ctx,
            call_tool=mock_call_tool,
            name="some_tool",
            args={},
        )

        assert len(captured_calls) == 1
        name, args, metadata = captured_calls[0]
        assert metadata is not None
        # Per Meta schema updates
        assert metadata["trace_id"] == "tr_no_spec"
        assert metadata["instance_id"] == "inst_no_spec"
        # depth and parent_id are NOT in _meta wire contract
        assert "depth" not in metadata
        assert "parent_id" not in metadata
        # persona_id absent when state has no spec_id
        assert "persona_id" not in metadata
        # legacy fields also absent
        assert "spec_id" not in metadata
        assert "spec_digest" not in metadata
