"""Component tests for MCP handlers and control paths.

This suite contains handler-level assertions and targeted seam stubs.
Live boundary seam proofs are covered in tests/test_e2e_mcp.py.

Requirements:
- Per-agent MCP: socket lifecycle + tool invocability
- Serve MCP: startup + spawn/list/status/kill tool invocability
- Error handling: nonexistent status, terminal interact

Minimum 12 tests.
"""

import asyncio
import signal
import shutil
import socket
import uuid
from pathlib import Path
from typing import Generator
from unittest.mock import MagicMock

import pytest
from returns.result import Failure, Success

import anima.shell.mcp_serve as mcp_serve
from anima.core.lifecycle import LifecycleEvent
from anima.core.schemas import (
    AgentState,
    AgentStateData,
    JobSpec,
)
from anima.shell.agent import AgentRunner
from anima.shell.mcp_agent import AgentMCPServer, AgentMCPError
from anima.shell.mcp_serve import ServeMCPServer
from anima.shell.state import (
    create_agent_state,
    delete_agent_state,
    get_agent_dir,
    transition_state,
    write_state,
)


# =============================================================================
# Fixtures
# =============================================================================


@pytest.fixture
def short_state_dir(monkeypatch: pytest.MonkeyPatch) -> Generator[Path, None, None]:
    """Create a short-path state directory for Unix socket tests.

    macOS has a 104-char limit on Unix socket paths, so we use /tmp.
    """
    # Use /tmp for short paths on macOS/Linux
    short_path = Path(f"/tmp/anima_test_{uuid.uuid4().hex[:8]}")
    short_path.mkdir(parents=True, exist_ok=True)
    monkeypatch.setenv("ANIMA_STATE", str(short_path))
    yield short_path
    # Cleanup
    if short_path.exists():
        shutil.rmtree(short_path, ignore_errors=True)


@pytest.fixture
def state_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
    """Create a temporary state directory for tests that don't need Unix sockets."""
    state = tmp_path / ".anima"
    state.mkdir(parents=True, exist_ok=True)
    monkeypatch.setenv("ANIMA_STATE", str(state))
    return state


@pytest.fixture
def mock_agent_runner() -> MagicMock:
    """Create a mock agent runner for AgentMCPServer."""
    runner = MagicMock()
    runner.instance_id = "ag_test_agent_001"
    return runner


@pytest.fixture
def free_port() -> int:
    """Find a free port for testing."""
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.bind(("", 0))
        return s.getsockname()[1]


# =============================================================================
# Test Class 1: Per-Agent MCP Server Socket Lifecycle
# =============================================================================


class TestPerAgentMCPServerSocketLifecycle:
    """Test AgentMCPServer socket creation and cleanup."""

    @pytest.mark.asyncio
    async def test_server_starts_and_creates_socket(
        self, short_state_dir: Path, mock_agent_runner: MagicMock
    ) -> None:
        """AgentMCPServer.start() creates mcp.sock file."""
        instance_id = "ag_sock_001"
        server = AgentMCPServer(instance_id, mock_agent_runner)

        result = await server.start()

        assert isinstance(result, Success), (
            f"Failed to start server: {result.failure()}"
        )

        # Socket file should exist
        expected_socket = short_state_dir / "agents" / instance_id / "mcp.sock"
        assert expected_socket.exists(), f"Socket not created at {expected_socket}"

        # Server should report running
        assert server.is_running

        # Cleanup
        await server.stop()

    @pytest.mark.asyncio
    async def test_server_stops_and_removes_socket(
        self, short_state_dir: Path, mock_agent_runner: MagicMock
    ) -> None:
        """AgentMCPServer.stop() removes socket file."""
        instance_id = "ag_sock_002"
        server = AgentMCPServer(instance_id, mock_agent_runner)

        await server.start()
        socket_path = server.socket_path
        assert socket_path is not None
        assert socket_path.exists()

        await server.stop()

        # Socket should be removed
        assert not socket_path.exists()
        assert not server.is_running

    @pytest.mark.asyncio
    async def test_start_without_anima_state_fails(
        self, mock_agent_runner: MagicMock, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """AgentMCPServer.start() fails cleanly without ANIMA_STATE."""
        # Remove ANIMA_STATE
        monkeypatch.delenv("ANIMA_STATE", raising=False)

        server = AgentMCPServer("ag_test_noenv", mock_agent_runner)
        result = await server.start()

        assert isinstance(result, Failure)
        error = result.failure()
        assert isinstance(error, AgentMCPError)
        assert error.code == "ANIMA_STATE_NOT_SET"

    @pytest.mark.asyncio
    async def test_double_start_returns_failure(
        self, short_state_dir: Path, mock_agent_runner: MagicMock
    ) -> None:
        """Starting an already-running server returns Failure."""
        instance_id = "ag_sock_003"
        server = AgentMCPServer(instance_id, mock_agent_runner)

        result1 = await server.start()
        assert isinstance(result1, Success)

        result2 = await server.start()
        assert isinstance(result2, Failure)
        error = result2.failure()
        assert error.code == "MCP_ALREADY_RUNNING"

        await server.stop()

    @pytest.mark.asyncio
    async def test_stop_when_not_running_is_success(
        self, short_state_dir: Path, mock_agent_runner: MagicMock
    ) -> None:
        """Stopping a non-running server returns Success."""
        instance_id = "ag_sock_004"
        server = AgentMCPServer(instance_id, mock_agent_runner)

        result = await server.stop()
        assert isinstance(result, Success)


# =============================================================================
# Test Class 2: Per-Agent MCP Tool Invocability
# =============================================================================


class TestPerAgentMCPToolInvocability:
    """Test per-agent MCP tools are callable via MCP client."""

    @pytest.mark.asyncio
    async def test_status_tool_callable(
        self, short_state_dir: Path, mock_agent_runner: MagicMock
    ) -> None:
        """anima.status() tool is callable and returns required fields."""
        # Create agent state file
        job = JobSpec(persona_ref="test@v1", instance_id="inst_test")
        state_result = create_agent_state("inst_test", "test", job)
        assert isinstance(state_result, Success)
        agent_state = state_result.unwrap()

        server = AgentMCPServer(agent_state.instance_id, mock_agent_runner)
        await server.start()

        try:
            # Give server time to bind
            await asyncio.sleep(0.2)

            # Invoke status tool via internal handler (direct call, not MCP)
            # This tests the tool handler, not full MCP transport
            result = await server._handle_status()

            # Should have all required fields per docs/INTERFACES.md Section B
            assert "instance_id" in result
            assert "state" in result
            assert "persona_name" in result
            assert "lifecycle" in result
            assert "token_usage" in result
            assert result["instance_id"] == agent_state.instance_id
            assert result["state"] == "pending"
            assert result["persona_name"] == "test"
        finally:
            await server.stop()
            delete_agent_state(agent_state.instance_id)

    @pytest.mark.asyncio
    async def test_interrupt_tool_callable(
        self, short_state_dir: Path, mock_agent_runner: MagicMock
    ) -> None:
        """anima.interrupt() tool is callable and returns acknowledged."""
        job = JobSpec(persona_ref="test@v1", instance_id="inst_test")
        state_result = create_agent_state("inst_test", "test", job)
        assert isinstance(state_result, Success)
        agent_state = state_result.unwrap()

        server = AgentMCPServer(agent_state.instance_id, mock_agent_runner)
        await server.start()

        try:
            await asyncio.sleep(0.2)

            # Invoke interrupt tool via internal handler
            result = await server._handle_interrupt()

            # Per docs/INTERFACES.md Section B, returns {acknowledged: true}
            assert "acknowledged" in result
            assert result["acknowledged"] is True
        finally:
            await server.stop()
            delete_agent_state(agent_state.instance_id)


# =============================================================================
# Test Class 3: Serve MCP Server Startup
# =============================================================================


class TestServeMCPServerStartup:
    """Test serve-mode MCP server starts and accepts connections."""

    @pytest.mark.asyncio
    async def test_serve_starts_on_port(self, free_port: int) -> None:
        """ServeMCPServer starts on specified port."""
        server = ServeMCPServer(host="127.0.0.1", port=free_port)

        result = await server.start()
        assert isinstance(result, Success), f"Failed to start: {result.failure()}"
        assert server.is_running

        # Verify port is listening
        await asyncio.sleep(0.3)  # Give server time to bind

        # Try to connect
        try:
            reader, writer = await asyncio.wait_for(
                asyncio.open_connection("127.0.0.1", free_port), timeout=2.0
            )
            writer.close()
            await writer.wait_closed()
            port_listening = True
        except Exception:
            port_listening = False

        assert port_listening, f"Port {free_port} not listening"

        await server.stop()

    @pytest.mark.asyncio
    async def test_serve_stop_is_idempotent(self) -> None:
        """Stopping a non-running server is a no-op."""
        server = ServeMCPServer()

        # Stop without start
        result = await server.stop()
        assert isinstance(result, Success)

    @pytest.mark.asyncio
    async def test_serve_double_start_fails(self, free_port: int) -> None:
        """Starting an already-running server returns Failure."""
        server = ServeMCPServer(host="127.0.0.1", port=free_port)

        result1 = await server.start()
        assert isinstance(result1, Success)

        result2 = await server.start()
        assert isinstance(result2, Failure)
        assert result2.failure().code == "SERVE_ALREADY_RUNNING"

        await server.stop()


# =============================================================================
# Test Class 4: Serve MCP Tool Invocability
# =============================================================================


class TestServeMCPToolInvocability:
    """Test serve-mode MCP tools are callable."""

    @pytest.mark.asyncio
    async def test_serve_spawn_tool(self, state_dir: Path, free_port: int) -> None:
        """anima.spawn() creates agent and returns instance_id and state."""
        server = ServeMCPServer(host="127.0.0.1", port=free_port)
        result = await server.start()
        assert isinstance(result, Success)

        try:
            spawn_result = await server._handle_spawn(
                persona_spec={
                    "id": "test",
                    "description": "Persona test",
                    "prompt": "Test",
                    "model": "gpt-4",
                },
                job_spec={"lifecycle": "ephemeral"},
                task="Test task",
            )

            assert "instance_id" in spawn_result
            assert "state" in spawn_result
            assert spawn_result["state"] == "pending"
            assert spawn_result["instance_id"].startswith(
                "inst_"
            )  # instance_id is alias for instance_id

            # Cleanup
            delete_agent_state(spawn_result["instance_id"])
        finally:
            await server.stop()

    @pytest.mark.asyncio
    async def test_serve_list_tool(self, state_dir: Path, free_port: int) -> None:
        """anima.list() returns array of agents."""
        server = ServeMCPServer(host="127.0.0.1", port=free_port)
        result = await server.start()
        assert isinstance(result, Success)

        try:
            # Spawn an agent first
            spawn_result = await server._handle_spawn(
                persona_spec={
                    "id": "test",
                    "description": "Persona test",
                    "prompt": "Test",
                    "model": "gpt-4",
                },
                job_spec={"lifecycle": "ephemeral"},
                task="Test task",
            )
            instance_id = spawn_result["instance_id"]
            assert instance_id is not None

            # List agents
            list_result = await server._handle_list()

            assert isinstance(list_result, list)
            assert len(list_result) >= 1
            agent_ids = [a["instance_id"] for a in list_result]
            assert instance_id in agent_ids

            # Each agent should have required fields
            for agent in list_result:
                assert "instance_id" in agent
                assert "state" in agent
                assert "persona_name" in agent

            # Cleanup
            delete_agent_state(instance_id)
        finally:
            await server.stop()

    @pytest.mark.asyncio
    async def test_serve_status_tool(self, state_dir: Path, free_port: int) -> None:
        """anima.status(instance_id) returns agent status dict."""
        server = ServeMCPServer(host="127.0.0.1", port=free_port)
        result = await server.start()
        assert isinstance(result, Success)

        try:
            # Spawn an agent first
            spawn_result = await server._handle_spawn(
                persona_spec={
                    "id": "test",
                    "description": "Persona test",
                    "prompt": "Test",
                    "model": "gpt-4",
                },
                job_spec={"lifecycle": "ephemeral"},
                task="Test task",
            )
            instance_id = spawn_result["instance_id"]

            # Get status
            status_result = await server._handle_status(instance_id)

            assert status_result["instance_id"] == instance_id
            assert status_result["state"] == "pending"
            assert status_result["persona_name"] == "test"
            assert "token_usage" in status_result
            assert "uptime_seconds" in status_result

            # Cleanup
            delete_agent_state(instance_id)
        finally:
            await server.stop()

    @pytest.mark.asyncio
    async def test_serve_kill_tool(self, state_dir: Path, free_port: int) -> None:
        """anima.kill(instance_id) transitions agent to killed state."""
        server = ServeMCPServer(host="127.0.0.1", port=free_port)
        result = await server.start()
        assert isinstance(result, Success)

        try:
            # Spawn an agent first
            spawn_result = await server._handle_spawn(
                persona_spec={
                    "id": "test",
                    "description": "Persona test",
                    "prompt": "Test",
                    "model": "gpt-4",
                },
                job_spec={"lifecycle": "ephemeral"},
                task="Test task",
            )
            instance_id = spawn_result["instance_id"]

            # Transition to running first (kill requires non-pending state)
            transition_state(instance_id, LifecycleEvent.start)

            # Kill the agent
            kill_result = await server._handle_kill(instance_id)

            assert kill_result["acknowledged"] is True

            # Verify state is killed
            status_result = await server._handle_status(instance_id)
            assert status_result["state"] == "killed"

            # Cleanup
            delete_agent_state(instance_id)
        finally:
            await server.stop()

    @pytest.mark.asyncio
    async def test_serve_kill_cascades_to_children(
        self,
        state_dir: Path,
        free_port: int,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """anima.kill(parent) transitions parent and children to killed."""
        server = ServeMCPServer(host="127.0.0.1", port=free_port)
        result = await server.start()
        assert isinstance(result, Success)

        try:
            parent_result = create_agent_state(
                "inst_parent_mcp",
                "parent",
                JobSpec(persona_ref="parent@test", instance_id="inst_parent_mcp"),
            )
            child1_result = create_agent_state(
                "inst_child1_mcp",
                "child1",
                JobSpec(persona_ref="child1@test", instance_id="inst_child1_mcp"),
            )
            child2_result = create_agent_state(
                "inst_child2_mcp",
                "child2",
                JobSpec(persona_ref="child2@test", instance_id="inst_child2_mcp"),
            )
            assert isinstance(parent_result, Success)
            assert isinstance(child1_result, Success)
            assert isinstance(child2_result, Success)

            parent = parent_result.unwrap()
            child1 = child1_result.unwrap()
            child2 = child2_result.unwrap()

            parent_running = AgentStateData(
                **{
                    **parent.model_dump(),
                    "state": AgentState.running,
                    "pid": 4441,
                    "children": [child1.instance_id, child2.instance_id],
                }
            )
            child1_running = AgentStateData(
                **{
                    **child1.model_dump(),
                    "state": AgentState.running,
                    "pid": 4442,
                    "parent_id": parent.instance_id,
                }
            )
            child2_running = AgentStateData(
                **{
                    **child2.model_dump(),
                    "state": AgentState.running,
                    "pid": 4443,
                    "parent_id": parent.instance_id,
                }
            )
            assert isinstance(write_state(parent.instance_id, parent_running), Success)
            assert isinstance(write_state(child1.instance_id, child1_running), Success)
            assert isinstance(write_state(child2.instance_id, child2_running), Success)

            killed_pids: set[int] = set()

            def _fake_kill(pid: int, sig: int) -> None:
                if sig == signal.SIGKILL:
                    killed_pids.add(pid)
                    return
                if sig == 0 and pid in killed_pids:
                    raise ProcessLookupError()

            monkeypatch.setattr("anima.shell.agent_kill.os.kill", _fake_kill)
            monkeypatch.setattr("anima.shell.agent_kill.time.sleep", lambda _n: None)

            kill_result = await server._handle_kill(parent.instance_id)
            assert kill_result["acknowledged"] is True

            parent_after = await server._handle_status(parent.instance_id)
            child1_after = await server._handle_status(child1.instance_id)
            child2_after = await server._handle_status(child2.instance_id)
            assert parent_after["state"] == "killed"
            assert child1_after["state"] == "killed"
            assert child2_after["state"] == "killed"

            delete_agent_state(parent.instance_id)
            delete_agent_state(child1.instance_id)
            delete_agent_state(child2.instance_id)
        finally:
            await server.stop()


# =============================================================================
# Test Class 5: MCP Error Handling
# =============================================================================


class TestMCPErrorHandling:
    """Test error handling in MCP tools."""

    @pytest.mark.asyncio
    async def test_status_nonexistent_agent(self, free_port: int) -> None:
        """anima.status() on nonexistent agent returns error response, not crash."""
        server = ServeMCPServer(host="127.0.0.1", port=free_port)
        result = await server.start()
        assert isinstance(result, Success)

        try:
            status = await server._handle_status("ag_nonexistent_xyz")

            # Should return error envelope per docs/INTERFACES.md Section G
            assert "error" in status
            assert status["error"]["code"] == "AGENT_NOT_FOUND"
            assert "not found" in status["error"]["message"].lower()
        finally:
            await server.stop()

    @pytest.mark.asyncio
    async def test_status_with_short_prefix(
        self, state_dir: Path, free_port: int
    ) -> None:
        """anima.status() accepts short prefix and resolves it."""
        server = ServeMCPServer(host="127.0.0.1", port=free_port)
        result = await server.start()
        assert isinstance(result, Success)

        try:
            # Spawn an agent
            spawn_result = await server._handle_spawn(
                persona_spec={
                    "id": "test",
                    "description": "Persona test",
                    "prompt": "Test",
                    "model": "gpt-4",
                },
                job_spec={"lifecycle": "ephemeral"},
                task="Test task",
            )
            instance_id = spawn_result["instance_id"]

            # Use first 8 chars as prefix
            prefix = instance_id[:8]
            status = await server._handle_status(prefix)

            assert status["instance_id"] == instance_id

            # Cleanup
            delete_agent_state(instance_id)
        finally:
            await server.stop()

    @pytest.mark.asyncio
    async def test_interact_nonexistent_agent(self, free_port: int) -> None:
        """anima.interact() on nonexistent agent returns error response."""
        server = ServeMCPServer(host="127.0.0.1", port=free_port)
        result = await server.start()
        assert isinstance(result, Success)

        try:
            interact = await server._handle_interact("ag_nonexistent_xyz", "hello")

            # Should return docs/INTERFACES.md Section G error envelope
            assert interact["error"]["code"] == "AGENT_NOT_FOUND"
            assert interact["error"]["numeric_code"] == 300
            assert "not found" in interact["error"]["message"].lower()
        finally:
            await server.stop()

    @pytest.mark.asyncio
    async def test_interact_terminal_agent(
        self, state_dir: Path, free_port: int
    ) -> None:
        """anima.interact() on completed agent returns error response."""
        server = ServeMCPServer(host="127.0.0.1", port=free_port)
        result = await server.start()
        assert isinstance(result, Success)

        try:
            # Spawn an agent
            spawn_result = await server._handle_spawn(
                persona_spec={
                    "id": "test",
                    "description": "Persona test",
                    "prompt": "Test",
                    "model": "gpt-4",
                },
                job_spec={"lifecycle": "ephemeral"},
                task="Test task",
            )
            instance_id = spawn_result["instance_id"]

            # Transition to completed
            transition_state(instance_id, LifecycleEvent.finish)

            # Try to interact
            interact = await server._handle_interact(instance_id, "hello")

            # Should return error about not running
            assert interact["error"]["code"] == "AGENT_NOT_INTERACTIVE"
            assert interact["error"]["numeric_code"] == 314
            assert "not running" in interact["error"]["message"].lower()

            # Cleanup
            delete_agent_state(instance_id)
        finally:
            await server.stop()

    @pytest.mark.asyncio
    async def test_interact_awaiting_approval_agent(
        self,
        short_state_dir: Path,
    ) -> None:
        """anima.interact() queue/response works while awaiting approval."""
        _ = short_state_dir

        class RunnerStub:
            def __init__(self, instance_id: str) -> None:
                self.instance_id = instance_id

        agent_server: AgentMCPServer | None = None
        instance_id: str | None = None
        try:
            state_result = create_agent_state(
                "inst_interact_awaiting_approval",
                "test",
                JobSpec(
                    persona_ref="test@v1", instance_id="inst_interact_awaiting_approval"
                ),
            )
            assert isinstance(state_result, Success)
            state = state_result.unwrap()
            instance_id = state.instance_id

            transition_state(instance_id, LifecycleEvent.start)
            transition_state(instance_id, LifecycleEvent.request_approval)

            agent_server = AgentMCPServer(instance_id, RunnerStub(instance_id))
            start_result = await agent_server.start()
            assert isinstance(start_result, Success)

            interact_task = asyncio.create_task(
                agent_server._handle_interact("hello while pending")
            )
            await asyncio.sleep(0.2)
            pending = agent_server.get_pending_message()
            assert pending is not None
            message = await asyncio.wait_for(pending.get(), timeout=1.0)
            assert message == "hello while pending"

            agent_server.set_response("still awaiting approval", turn_number=7)
            interact = await asyncio.wait_for(interact_task, timeout=1.0)

            assert interact["response"] == "still awaiting approval", interact
            assert isinstance(interact["turn_number"], int)
            assert "error" not in interact
        finally:
            if agent_server is not None:
                await agent_server.stop()
            if instance_id is not None:
                delete_agent_state(instance_id)

    @pytest.mark.asyncio
    async def test_kill_nonexistent_agent(self, free_port: int) -> None:
        """anima.kill() on nonexistent agent returns error response."""
        server = ServeMCPServer(host="127.0.0.1", port=free_port)
        result = await server.start()
        assert isinstance(result, Success)

        try:
            kill_result = await server._handle_kill("ag_nonexistent_xyz")

            # Should return error dict
            assert kill_result["acknowledged"] is False
            assert kill_result["error"]["code"] == "AGENT_NOT_FOUND"
            assert kill_result["error"]["numeric_code"] == 300
        finally:
            await server.stop()

    @pytest.mark.asyncio
    async def test_kill_already_terminal_agent(
        self, state_dir: Path, free_port: int
    ) -> None:
        """anima.kill() on already-completed agent returns error."""
        server = ServeMCPServer(host="127.0.0.1", port=free_port)
        result = await server.start()
        assert isinstance(result, Success)

        try:
            # Spawn an agent
            spawn_result = await server._handle_spawn(
                persona_spec={
                    "id": "test",
                    "description": "Persona test",
                    "prompt": "Test",
                    "model": "gpt-4",
                },
                job_spec={"lifecycle": "ephemeral"},
                task="Test task",
            )
            instance_id = spawn_result["instance_id"]

            # Transition to running, then to completed (terminal)
            transition_state(instance_id, LifecycleEvent.start)
            transition_state(instance_id, LifecycleEvent.finish)

            # Try to kill
            kill_result = await server._handle_kill(instance_id)

            # Should indicate already terminal
            assert kill_result["acknowledged"] is False
            assert kill_result["error"]["code"] == "AGENT_TERMINATED"
            assert kill_result["error"]["numeric_code"] == 313
            assert "terminal" in kill_result["error"]["message"].lower()

            # Cleanup
            delete_agent_state(instance_id)
        finally:
            await server.stop()

    @pytest.mark.asyncio
    async def test_spawn_invalid_personaspec(self, free_port: int) -> None:
        """anima.spawn() with invalid persona_spec returns error response."""
        server = ServeMCPServer(host="127.0.0.1", port=free_port)
        result = await server.start()
        assert isinstance(result, Success)

        try:
            # Missing required fields
            spawn_result = await server._handle_spawn(
                persona_spec={"id": "test"},  # Missing model
                job_spec={"lifecycle": "ephemeral"},
                task="Test task",
            )

            # Should return error
            assert spawn_result["error"]["code"] == "INVALID_INPUT"
            assert spawn_result["error"]["numeric_code"] == 1
            assert spawn_result["state"] == "error"
        finally:
            await server.stop()

    @pytest.mark.asyncio
    async def test_spawn_task_write_oserror_returns_structured_error(
        self,
        state_dir: Path,
        free_port: int,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """anima.spawn() surfaces task write OSError as structured error response."""
        _ = state_dir

        class _BrokenTaskDir:
            def __truediv__(self, _name: str) -> "_BrokenTaskDir":
                return self

            def write_text(self, _text: str) -> int:
                raise OSError("disk full")

        monkeypatch.setattr(
            mcp_serve, "get_agent_dir", lambda _agent_id: _BrokenTaskDir()
        )

        server = ServeMCPServer(host="127.0.0.1", port=free_port)
        result = await server.start()
        assert isinstance(result, Success)

        try:
            spawn_result = await server._handle_spawn(
                persona_spec={
                    "id": "test",
                    "description": "Persona test",
                    "prompt": "Test",
                    "model": "gpt-4",
                },
                job_spec={"lifecycle": "ephemeral"},
                task="Test task",
            )

            assert spawn_result["state"] == "error"
            assert spawn_result["instance_id"].startswith(
                "inst_"
            )  # instance_id is alias
            assert spawn_result["error"]["code"] == "INTERNAL"
            assert spawn_result["error"]["numeric_code"] == 10
            assert "Failed to write task file" in spawn_result["error"]["message"]
            assert "disk full" in spawn_result["error"]["message"]

            delete_agent_state(spawn_result["instance_id"])
        finally:
            await server.stop()

    @pytest.mark.asyncio
    async def test_collect_nonexistent_agent(self, free_port: int) -> None:
        """anima.collect() on nonexistent agent returns error response."""
        server = ServeMCPServer(host="127.0.0.1", port=free_port)
        result = await server.start()
        assert isinstance(result, Success)

        try:
            collect_result = await server._handle_collect("ag_nonexistent_xyz")

            # Should return error envelope per docs/INTERFACES.md Section G
            assert "error" in collect_result
            assert collect_result["error"]["code"] == "AGENT_NOT_FOUND"
            assert "not found" in collect_result["error"]["message"].lower()
        finally:
            await server.stop()

    @pytest.mark.asyncio
    async def test_collect_running_agent(self, state_dir: Path, free_port: int) -> None:
        """anima.collect() on running (non-terminal) agent returns error."""
        server = ServeMCPServer(host="127.0.0.1", port=free_port)
        result = await server.start()
        assert isinstance(result, Success)

        try:
            # Spawn an agent
            spawn_result = await server._handle_spawn(
                persona_spec={
                    "id": "test",
                    "description": "Persona test",
                    "prompt": "Test",
                    "model": "gpt-4",
                },
                job_spec={"lifecycle": "ephemeral"},
                task="Test task",
            )
            instance_id = spawn_result["instance_id"]

            # Transition to running (non-terminal)
            transition_state(instance_id, LifecycleEvent.start)

            # Try to collect
            collect_result = await server._handle_collect(instance_id)

            # Should error - not terminal (error envelope per docs/INTERFACES.md Section G)
            assert "error" in collect_result
            assert collect_result["error"]["code"] == "AGENT_NOT_TERMINAL"
            assert "terminal" in collect_result["error"]["message"].lower()

            # Cleanup
            delete_agent_state(instance_id)
        finally:
            await server.stop()

    @pytest.mark.asyncio
    async def test_collect_completed_agent(
        self, state_dir: Path, free_port: int
    ) -> None:
        """anima.collect() on completed agent returns result."""
        server = ServeMCPServer(host="127.0.0.1", port=free_port)
        result = await server.start()
        assert isinstance(result, Success)

        try:
            # Spawn an agent
            spawn_result = await server._handle_spawn(
                persona_spec={
                    "id": "test",
                    "description": "Persona test",
                    "prompt": "Test",
                    "model": "gpt-4",
                },
                job_spec={"lifecycle": "ephemeral"},
                task="Test task",
            )
            instance_id = spawn_result["instance_id"]

            # Transition: pending -> running -> completed
            transition_state(instance_id, LifecycleEvent.start)
            transition_state(instance_id, LifecycleEvent.finish)

            # Write a result file
            result_file = get_agent_dir(instance_id) / "result.json"
            result_file.write_text('{"output": "Task completed successfully"}')

            # Collect
            collect_result = await server._handle_collect(instance_id)

            assert collect_result["status"] == "completed"
            assert collect_result["output"] == "Task completed successfully"

            # Cleanup
            delete_agent_state(instance_id)
        finally:
            await server.stop()


# =============================================================================
# Test Class 6: Interact Message Queue
# =============================================================================


class TestInteractMessageQueue:
    """Test interact message queue integration."""

    @pytest.mark.asyncio
    async def test_message_queue_queueing(self, mock_agent_runner: MagicMock) -> None:
        """Messages are queued via _handle_interact and retrievable via get_pending_message."""
        server = AgentMCPServer("ag_queue_test", mock_agent_runner)

        # Queue is initialized in start() - test queue availability after start
        # Note: In real usage, AgentRunner calls start() before interact tool calls
        # Initialize queue manually for this unit test
        server._pending_message = asyncio.Queue()
        server._response_event = asyncio.Event()

        # Now queue should exist
        queue = server.get_pending_message()
        assert queue is not None

        # Queue a message via _handle_interact
        interact_task = asyncio.create_task(server._handle_interact("test message"))

        # Give it a moment to queue the message
        await asyncio.sleep(0.05)

        # Drain pending messages to verify it was queued
        drained = server.drain_pending_messages()
        assert len(drained) == 1
        assert drained[0] == "test message"

        # Set response to complete the interact
        server.set_response("test response", turn_number=1)
        await interact_task

    @pytest.mark.asyncio
    async def test_set_response_signals_event(
        self, mock_agent_runner: MagicMock
    ) -> None:
        """set_response sets response and signals the event."""
        server = AgentMCPServer("ag_response_test", mock_agent_runner)

        # Initialize queue and event manually (normally done in start())
        server._pending_message = asyncio.Queue()
        server._response_event = asyncio.Event()

        # Initialize interact call
        interact_task = asyncio.create_task(server._handle_interact("test message"))
        await asyncio.sleep(0.05)

        # Set the response
        server.set_response("test response", turn_number=5)

        # _response_event should now be set
        assert server._response_event is not None
        assert server._response_event.is_set()
        assert server._last_response == "test response"
        assert server._response_turn == 5

        # Complete the task
        payload = await interact_task
        assert payload["response"] == "test response"

        # Cancel any pending task
        interact_task.cancel()
        try:
            await interact_task
        except asyncio.CancelledError:
            pass

    @pytest.mark.asyncio
    async def test_handle_interact_while_awaiting_approval(
        self,
        state_dir: Path,
    ) -> None:
        """AgentMCPServer anima.interact works while approval is pending."""
        create_result = create_agent_state(
            "inst_awaiting_approval_interact",
            "test-persona",
            JobSpec(
                persona_ref="test@test", instance_id="inst_awaiting_approval_interact"
            ),
        )
        assert isinstance(create_result, Success)
        state = create_result.unwrap()

        transition_state(state.instance_id, LifecycleEvent.start)
        transition_state(state.instance_id, LifecycleEvent.request_approval)

        runner = AgentRunner(
            instance_id=state.instance_id,
            task="Approval pending direct interact test",
        )
        server = AgentMCPServer(state.instance_id, runner)
        # Initialize queue and event (normally done in start())
        server._pending_message = asyncio.Queue()
        server._response_event = asyncio.Event()

        interact_task = asyncio.create_task(
            server._handle_interact("Can we continue while approval is pending?")
        )

        await asyncio.sleep(0.05)
        server.set_response("Yes, talk still works during approval.", turn_number=4)

        payload = await interact_task

        assert payload["response"] == "Yes, talk still works during approval."
        assert "error" not in payload

        delete_agent_state(state.instance_id)

    @pytest.mark.asyncio
    async def test_handle_interact_returns_agent_busy_during_active_turn(
        self,
        state_dir: Path,
    ) -> None:
        """anima.interact returns AGENT_BUSY when a turn is already processing."""
        create_result = create_agent_state(
            "inst_busy_interact",
            "test-persona",
            JobSpec(persona_ref="test@test", instance_id="inst_busy_interact"),
        )
        assert isinstance(create_result, Success)
        state = create_result.unwrap()
        transition_state(state.instance_id, LifecycleEvent.start)

        runner = AgentRunner(
            instance_id="inst_test",
            task="Busy interact response test",
        )
        server = AgentMCPServer(state.instance_id, runner)
        # Initialize queue and event (normally done in start())
        server._pending_message = asyncio.Queue()
        server._response_event = asyncio.Event()
        server.set_busy(True)

        payload = await server._handle_interact("second message while busy")

        assert payload["error"]["code"] == "AGENT_BUSY"
        assert payload["error"]["numeric_code"] == 10
        assert "processing" in payload["error"]["message"].lower()
        assert payload["turn_number"] == 0

        queue = server.get_pending_message()
        assert queue is not None
        assert queue.empty() is True

        delete_agent_state(state.instance_id)

    @pytest.mark.asyncio
    async def test_handle_stop_turn_sets_control_event(
        self, mock_agent_runner: MagicMock
    ) -> None:
        """anima.stop_turn sets current-turn control event and acknowledges."""
        server = AgentMCPServer("ag_stop_turn_test", mock_agent_runner)

        stop_event = server.get_stop_turn_event()
        assert stop_event.is_set() is False

        payload = await server._handle_stop_turn()

        assert payload["acknowledged"] is True
        assert payload["scope"] == "current_turn"
        assert stop_event.is_set() is True


class TestNoMockSeamBoundaries:
    """Companion seam proofs using real socket + state boundaries (no mocks)."""

    @pytest.mark.asyncio
    async def test_agent_mcp_real_runner_socket_and_status(
        self, short_state_dir: Path
    ) -> None:
        """Start AgentMCPServer with real AgentRunner and verify socket/status seam."""
        _ = short_state_dir
        instance_id = "inst_real_runner_socket"
        created = create_agent_state(
            instance_id,
            "real-runner-persona",
            JobSpec(persona_ref="real@test", instance_id=instance_id),
        )
        assert isinstance(created, Success)

        transition_state(instance_id, LifecycleEvent.start)

        runner = AgentRunner(instance_id=instance_id, task="real seam socket test")
        server = AgentMCPServer(instance_id, runner)
        start_result = await server.start()
        assert isinstance(start_result, Success)
        try:
            socket_path = short_state_dir / "agents" / instance_id / "mcp.sock"
            assert socket_path.exists(), f"Expected MCP socket at {socket_path}"

            status = await server._handle_status()
            assert status["instance_id"] == instance_id
            assert status["persona_name"] == "real-runner-persona"
        finally:
            await server.stop()
            delete_agent_state(instance_id)
