"""Per-agent MCP server.

Each running agent exposes an MCP server on a Unix domain
socket at `$ANIMA_STATE/agents/<instance_id>/mcp.sock`.

Used by `anima talk`, `anima status`, `anima kill`.

docs/INTERFACES.md Section B defines the API:
- anima.status() - live agent state and metrics
- anima.interact(message) - send message to agent
- anima.interrupt() - request graceful shutdown
"""

# @invar:allow file_size: Agent-local MCP tool registration, stream transport, and queue lifecycle are tightly coupled by single-instance state invariants
from __future__ import annotations

import asyncio
import json
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, Protocol

from fastmcp import FastMCP
from returns.result import Failure, Result, Success
from sse_starlette.sse import EventSourceResponse
from starlette.requests import Request
from starlette.responses import Response

from anima.core.schemas import PersonaSpec
from anima.shell.meta import build_meta

if TYPE_CHECKING:
    from anima.shell.state import Path


@dataclass
class StreamChunkEvent:
    """Stream event for text chunk.

    Spec: docs/proposals/talk-streaming-transport.md SSE event schema
    """

    turn: int
    seq: int
    chunk: str


@dataclass
class StreamDoneEvent:
    """Stream event for completion.

    Spec: docs/proposals/talk-streaming-transport.md SSE event schema
    """

    turn: int
    seq: int
    stop_reason: str
    final_response_chars: int
    prompt_tokens: int
    completion_tokens: int
    total_tokens: int


@dataclass
class StreamErrorEvent:
    """Stream event for errors.

    Spec: docs/proposals/talk-streaming-transport.md SSE event schema
    """

    turn: int
    seq: int
    error: str
    code: str


@dataclass
class StreamToolCallEvent:
    """Stream event for tool call.

    Spec: docs/proposals/talk-streaming-transport.md SSE event schema
    """

    turn: int
    seq: int
    tool_name: str
    tool_arg_preview: str


StreamEvent = (
    StreamChunkEvent | StreamDoneEvent | StreamErrorEvent | StreamToolCallEvent
)


class AgentRunnerProtocol(Protocol):
    """Protocol for the agent runner that owns this MCP server.

    This protocol defines the interface expected from the agent runner.
    The actual AgentRunner implementation should conform to this interface.
    """

    instance_id: str
    _approval_event: asyncio.Event
    _resume_event: asyncio.Event

    def set_interactive_approval_mode(self, enabled: bool) -> None: ...

    def resolve_interactive_approval_future(
        self,
        decision: dict[str, bool | str | None],
    ) -> None: ...


class AgentMCPError(Exception):
    """Error type for anima shell operations.

    Uses string codes for categorization (e.g., "MCP_START_FAILED").
    For numeric codes, use AnimaError from core.schemas.

    Attributes:
        code: Error code string for categorization.
        message: Human-readable error description.
    """

    def __init__(self, code: str, message: str) -> None:
        self.code = code
        self.message = message
        super().__init__(f"[{code}] {message}")


class AgentMCPServer:
    """FastMCP server for a single agent instance.

    Each agent process creates one AgentMCPServer to expose MCP tools
    for external control (anima talk, anima kill, etc.).

    Attributes:
        instance_id: The agent's unique identifier (used for state lookups).
        agent_runner: Reference to the agent runner (for tool implementations).
        mcp: The FastMCP server instance.
        _socket_path: Path to the Unix domain socket (set after start()).
        _running: Whether the server is currently running.
        _server_task: The asyncio task running the FastMCP server.
        _interrupt_event: Event to signal graceful shutdown.
        _pending_message: Queue for incoming messages to process (always initialized).
        _response_event: Event to signal response availability (always initialized).
        _last_response: The last response from agent processing.
        _busy: Whether the agent is currently processing an interact turn.
        interact_timeout_seconds: Timeout for interact responses (default 300).
        _stream_subscribers: Per-consumer stream queues keyed by consumer id.
        _stream_seq: Sequence counter for stream events within current turn.
        _stream_active: Whether a stream session is active.
        _stream_cancel: Flag to signal stream cancellation.
    """

    def __init__(
        self,
        instance_id: str,
        agent_runner: AgentRunnerProtocol,
        interact_timeout_seconds: float = 300.0,
    ) -> None:
        """Initialize the MCP server for an agent.

        Args:
            instance_id: Unique identifier for this agent (used for state lookups).
            agent_runner: The agent runner that owns this server.
            interact_timeout_seconds: Timeout for interact message responses.
                Default 300 seconds (5 min) for slow LLMs; tests use shorter values.
        """
        self.instance_id = instance_id
        self.agent_runner = agent_runner
        self.interact_timeout_seconds = interact_timeout_seconds
        # Use first 8 characters for a shorter, readable server name
        server_name = f"anima-agent-{instance_id[:8]}"
        self.mcp: FastMCP = FastMCP(name=server_name)
        self._socket_path: Path | None = None
        self._running: bool = False
        self._server_task: asyncio.Task[None] | None = None
        self._interrupt_event: asyncio.Event | None = None
        self._stop_turn_event: asyncio.Event | None = None
        self._resume_event: asyncio.Event | None = None
        # Initialize both queue and event in __init__ for immediate availability.
        # Avoids assertion failures when tests call _handle_interact before start().
        # Spec source: async-poll-hardening review - maintain initialization invariant.
        self._pending_message: asyncio.Queue[str] = asyncio.Queue()
        self._response_event: asyncio.Event = asyncio.Event()
        self._last_response: str = ""
        self._response_turn: int = 0
        self._busy: bool = False

        # Spec source: bridge-and-fanout.stream-fanout
        # Multi-consumer SSE fan-out with bounded per-consumer queues.
        self._stream_subscribers: dict[str, asyncio.Queue[StreamEvent | None]] = {}
        self._stream_subscribers_lock = asyncio.Lock()
        self._stream_seq: int = 0
        self._stream_active: bool = False
        self._stream_cancel: bool = False
        self._stream_consumer_active: bool = False

        # Pending persona switch slot (single-pending, turn-boundary cooperative).
        # Spec source: docs/INTERFACES.md D.5 + docs/DESIGN.md Section 8
        self._pending_persona_switch: PersonaSpec | None = None
        self._pending_persona_switch_ref: str | None = None

        # Register MCP tools
        self._register_tools()

    def _register_tools(self) -> None:
        """Register FastMCP tools for agent interaction.

        Tool names follow docs/INTERFACES.md Section B naming convention:
        - anima.status
        - anima.interact
        - anima.interrupt
        """

        @self.mcp.tool(name="anima.status")
        async def _anima_status() -> dict[str, Any]:
            """Return the agent's current state and metrics (live, from process).

            Returns a dict with instance_id, state, persona_name, lifecycle,
            token_usage, tools_called, uptime_seconds, parent_id, current_turn,
            max_turns fields as defined in docs/INTERFACES.md Section B.
            """
            return await self._handle_status()

        @self.mcp.tool(name="anima.interact")
        async def _anima_interact(message: str) -> dict[str, Any]:
            """Send a message to the agent and wait for response.

            The message is injected into the agent's conversation as a user
            message and the agent processes one turn.

            Args:
                message: The message to send to the agent.

            Returns:
                Dict with response and turn_number fields.
            """
            return await self._handle_interact(message)

        @self.mcp.tool(name="anima.interrupt")
        async def _anima_interrupt() -> dict[str, Any]:
            """Request the agent to stop gracefully.

            The agent finishes any currently executing tool call,
            enters grace finalization, then transitions to killed state.

            Returns:
                Dict with acknowledged field set to True.
            """
            return await self._handle_interrupt()

        @self.mcp.tool(name="anima.stop_turn")
        async def _anima_stop_turn() -> dict[str, Any]:
            """Request cancellation of the current interact turn only.

            Unlike anima.interrupt, this does not kill the agent process.

            Returns:
                Dict with acknowledged and scope fields.
            """
            return await self._handle_stop_turn()

        @self.mcp.tool(name="anima.logs")
        async def _anima_logs(tail: int | None = None) -> dict[str, Any]:
            """Return the agent's log entries from log.jsonl.

            Reads the agent's log file from $ANIMA_STATE/agents/<instance_id>/log.jsonl
            and returns parsed JSON entries.

            Args:
                tail: Optional number of most recent lines to return.

            Returns:
                Dict with instance_id, entries (list of log dicts), and count.
            """
            return await self._handle_logs(tail)

        @self.mcp.tool(name="anima.approve")
        async def _anima_approve(note: str | None = None) -> dict[str, Any]:
            """Approve a pending agent approval request.

            Validates that the agent is in awaiting_approval state, writes
            the approval response to the state channel, signals the approval
            event, and transitions the agent to running state.

            Args:
                note: Optional note explaining the approval decision.

            Returns:
                Dict with instance_id and acknowledged fields.
            """
            return await self._handle_approve(note)

        @self.mcp.tool(name="anima.reject")
        async def _anima_reject(note: str | None = None) -> dict[str, Any]:
            """Reject a pending agent approval request.

            Validates that the agent is in awaiting_approval state, writes
            the rejection response to the state channel, signals the approval
            event, and transitions the agent to failed state.

            Args:
                note: Optional note explaining the rejection reason.

            Returns:
                Dict with instance_id and acknowledged fields.
            """
            return await self._handle_reject(note)

        @self.mcp.tool(name="anima.resume")
        async def _anima_resume() -> dict[str, Any]:
            """Resume a suspended agent.

            Validates that the agent is in suspended state, transitions
            the agent to running state, and signals the resume event.

            Returns:
                Dict with instance_id and acknowledged fields.
            """
            return await self._handle_resume()

        # Register custom HTTP route for SSE streaming
        # Spec: talk-streaming-transport.md - SSE endpoint for real-time LLM output
        @self.mcp.custom_route("/stream", methods=["GET"])
        async def _stream_endpoint(request: Request) -> Response:
            """SSE endpoint for streaming LLM output during interact turns.

            Allows up to 8 concurrent consumers and yields chunk/done/error events.

            Args:
                request: HTTP request from Starlette.

            Returns:
                EventSourceResponse with SSE events, or 409 at consumer cap.
            """
            return await self._handle_stream_request(request)

    async def _handle_status(self) -> dict[str, Any]:
        """Handle anima.status() MCP tool call.

        Returns:
            Dict with instance_id, state, persona_name, lifecycle, token_usage,
            tools_called, uptime_seconds, parent_id, current_turn, max_turns
            per docs/INTERFACES.md Section B.

            On error (agent not found), returns error envelope with AGENT_NOT_FOUND code.
        """
        from datetime import datetime, timezone

        from anima.shell.meta import build_meta, build_meta_from_instance_id
        from anima.shell.state import read_state

        result = read_state(self.instance_id)
        if isinstance(result, Failure):
            # Agent not found - return proper error envelope per docs/INTERFACES.md Section G
            return {
                "error": {
                    "code": "AGENT_NOT_FOUND",
                    "numeric_code": 300,  # per contracts/errors.yaml
                    "message": f"Agent not found: {self.instance_id}",
                    "details": {"instance_id": self.instance_id},
                },
                "_meta": build_meta_from_instance_id(self.instance_id),
            }

        state = result.unwrap()

        # Calculate uptime_seconds from created_at
        uptime_seconds = 0
        if state.created_at:
            try:
                created = datetime.fromisoformat(state.created_at)
                uptime_seconds = int(
                    (datetime.now(timezone.utc) - created).total_seconds()
                )
            except (ValueError, TypeError):
                pass

        # Build response per docs/INTERFACES.md Section B specification
        # Include _meta per docs/INTERFACES.md Section E
        return {
            "instance_id": state.instance_id,
            "agent_id": state.instance_id,  # Backward compat alias
            "state": state.state.value,
            "persona_name": state.persona_name,
            "model": state.model,
            "lifecycle": state.lifecycle.value,
            "token_usage": {
                "prompt_tokens": state.token_usage.prompt_tokens,
                "completion_tokens": state.token_usage.completion_tokens,
                "total_tokens": state.token_usage.total_tokens,
            },
            "tools_called": [
                {"name": tc.name, "count": tc.count} for tc in state.tools_called
            ],
            "uptime_seconds": uptime_seconds,
            "parent_id": state.parent_id,
            "current_turn": state.current_turn,
            "max_turns": state.max_turns,
            "_meta": build_meta(state),
        }

    async def _handle_interact(self, message: str) -> dict[str, Any]:
        """Handle anima.interact(message) MCP tool call.

        Args:
            message: User message to send to the agent.

        Returns:
            Dict with response and turn_number fields per docs/INTERFACES.md Section B.
        """
        from anima.shell.meta import build_meta, build_meta_from_instance_id
        from anima.shell.state import read_state

        # Spec source: vectl step
        # agent-loop-resilience.return-agent-busy-status-instead-of-hard-timeout
        # requires fast busy response when an interact arrives mid-turn.
        if self._busy:
            result = read_state(self.instance_id)
            if isinstance(result, Success):
                state = result.unwrap()
                return {
                    "error": {
                        "code": "AGENT_BUSY",
                        "numeric_code": 10,
                        "message": "Agent is processing another interact turn",
                        "details": {"instance_id": self.instance_id},
                    },
                    "turn_number": self._response_turn,
                    "_meta": build_meta(state),
                }
            return {
                "error": {
                    "code": "AGENT_BUSY",
                    "numeric_code": 10,
                    "message": "Agent is processing another interact turn",
                    "details": {"instance_id": self.instance_id},
                },
                "turn_number": self._response_turn,
                "_meta": build_meta_from_instance_id(self.instance_id),
            }

        # Queue the message for the agent loop to process
        # Both queue and event initialized in __init__ for immediate availability.
        await self._pending_message.put(message)
        self._response_event.clear()
        if hasattr(self.agent_runner, "set_interactive_approval_mode"):
            self.agent_runner.set_interactive_approval_mode(True)

        # Wait for the agent to respond (with timeout)
        # Uses configurable timeout (default 30s)
        # Per spec, timeout should return error envelope, not hang
        try:
            await asyncio.wait_for(
                self._response_event.wait(), timeout=self.interact_timeout_seconds
            )
        except asyncio.TimeoutError:
            # Get state for _meta
            result = read_state(self.instance_id)
            if isinstance(result, Success):
                state = result.unwrap()
                return {
                    "error": {
                        "code": "TIMEOUT",
                        "numeric_code": 3,  # per contracts/errors.yaml
                        "message": "Agent interact timed out waiting for response",
                        "details": {"instance_id": self.instance_id},
                    },
                    "turn_number": self._response_turn,
                    "_meta": build_meta(state),
                }
            return {
                "error": {
                    "code": "TIMEOUT",
                    "numeric_code": 3,  # per contracts/errors.yaml
                    "message": "Agent interact timed out waiting for response",
                    "details": {"instance_id": self.instance_id},
                },
                "turn_number": self._response_turn,
                "_meta": build_meta_from_instance_id(self.instance_id),
            }
        finally:
            if hasattr(self.agent_runner, "set_interactive_approval_mode"):
                self.agent_runner.set_interactive_approval_mode(False)

        # Get current turn number after response
        turn_number = self._response_turn
        result = read_state(self.instance_id)
        if isinstance(result, Success):
            state = result.unwrap()
            turn_number = state.current_turn
            return {
                "response": self._last_response,
                "turn_number": turn_number,
                "_meta": build_meta(state),
            }

        return {
            "response": self._last_response,
            "turn_number": turn_number,
            "_meta": build_meta_from_instance_id(self.instance_id),
        }

    async def _handle_interrupt(self) -> dict[str, Any]:
        """Handle anima.interrupt() MCP tool call.

        Transitions the agent to killed state and returns confirmation.

        Returns:
            Dict with acknowledged: true per docs/INTERFACES.md Section B.
        """
        from anima.core.lifecycle import LifecycleEvent
        from anima.shell.state import read_state, transition_state

        # Signal interrupt to agent loop
        if self._interrupt_event is not None:
            self._interrupt_event.set()

        # Transition state to killed
        transition_state(self.instance_id, LifecycleEvent.kill)

        # Get current state for _meta
        result = read_state(self.instance_id)
        if isinstance(result, Success):
            state = result.unwrap()
            return {
                "acknowledged": True,
                "_meta": build_meta(state),
            }

        # Always return per docs/INTERFACES.md Section B specification
        return {"acknowledged": True}

    async def _handle_stop_turn(self) -> dict[str, Any]:
        """Handle anima.stop_turn() MCP tool call.

        Sets a control event consumed by AgentRunner between PydanticAI nodes.

        Returns:
            Dict with acknowledged: true and scope: current_turn.
        """
        self.get_stop_turn_event().set()
        return {"acknowledged": True, "scope": "current_turn"}

    async def _handle_logs(self, tail: int | None = None) -> dict[str, Any]:
        """Handle anima.logs() MCP tool call.

        Reads the agent's log.jsonl file and returns parsed entries.

        Args:
            tail: Optional number of most recent lines to return.

        Returns:
            Dict with instance_id, entries (list of log dicts), and count.
        """
        from anima.shell.state import get_agent_dir

        log_path = get_agent_dir(self.instance_id) / "log.jsonl"
        entries: list[dict[str, Any]] = []

        if log_path.exists():
            try:
                content = log_path.read_text()
                lines = content.strip().split("\n") if content.strip() else []
                if tail is not None and tail > 0:
                    lines = lines[-tail:]
                for line in lines:
                    if line.strip():
                        try:
                            entries.append(json.loads(line))
                        except json.JSONDecodeError:
                            # Skip malformed lines gracefully
                            pass
            except OSError:
                # File read error - return empty entries
                pass

        return {
            "instance_id": self.instance_id,
            "entries": entries,
            "count": len(entries),
        }

    async def _handle_approve(self, note: str | None = None) -> dict[str, Any]:
        """Handle anima.approve() MCP tool call.

        Validates agent is in awaiting_approval state, writes approval response,
        signals approval event, and transitions agent to running state.

        Args:
            note: Optional note explaining the approval decision.

        Returns:
            Dict with instance_id and acknowledged fields.
        """
        from anima.core.lifecycle import LifecycleEvent
        from anima.shell.state import (
            read_state,
            transition_state,
            write_approval_response,
        )

        # Validate agent is in awaiting_approval state
        result = read_state(self.instance_id)
        if isinstance(result, Failure):
            return {
                "error": {
                    "code": "AGENT_NOT_FOUND",
                    "numeric_code": 300,
                    "message": f"Agent not found: {self.instance_id}",
                    "details": {"instance_id": self.instance_id},
                },
            }

        state = result.unwrap()
        if state.state.value != "awaiting_approval":
            return {
                "error": {
                    "code": "INVALID_STATE",
                    "numeric_code": 400,
                    "message": f"Agent is not awaiting approval (state: {state.state.value})",
                    "details": {
                        "instance_id": self.instance_id,
                        "state": state.state.value,
                    },
                },
            }

        # Write approval response to state channel
        write_result = write_approval_response(
            self.instance_id, approved=True, note=note
        )
        if isinstance(write_result, Failure):
            return {
                "error": {
                    "code": "WRITE_FAILED",
                    "numeric_code": 500,
                    "message": f"Failed to write approval response: {write_result.failure()}",
                    "details": {"instance_id": self.instance_id},
                },
            }

        # Transition state to running
        transition_result = transition_state(self.instance_id, LifecycleEvent.approve)
        if isinstance(transition_result, Failure):
            return {
                "error": {
                    "code": "STATE_TRANSITION_FAILED",
                    "numeric_code": 500,
                    "message": f"Failed to transition state: {transition_result.failure()}",
                    "details": {
                        "instance_id": self.instance_id,
                        "state": state.state.value,
                    },
                },
            }

        # Signal approval event to wake the agent loop
        if hasattr(self.agent_runner, "resolve_interactive_approval_future"):
            self.agent_runner.resolve_interactive_approval_future(
                {"approved": True, "note": note}
            )
        if hasattr(self.agent_runner, "_approval_event"):
            self.agent_runner._approval_event.set()

        return {
            "instance_id": self.instance_id,
            "acknowledged": True,
        }

    async def _handle_reject(self, note: str | None = None) -> dict[str, Any]:
        """Handle anima.reject() MCP tool call.

        Validates agent is in awaiting_approval state, writes rejection response,
        signals approval event, and transitions agent back to running state.

        Args:
            note: Optional note explaining the rejection reason.

        Returns:
            Dict with instance_id and acknowledged fields.
        """
        from anima.core.lifecycle import LifecycleEvent
        from anima.shell.state import (
            read_state,
            transition_state,
            write_approval_response,
        )

        # Validate agent is in awaiting_approval state
        result = read_state(self.instance_id)
        if isinstance(result, Failure):
            return {
                "error": {
                    "code": "AGENT_NOT_FOUND",
                    "numeric_code": 300,
                    "message": f"Agent not found: {self.instance_id}",
                    "details": {"instance_id": self.instance_id},
                },
            }

        state = result.unwrap()
        if state.state.value != "awaiting_approval":
            return {
                "error": {
                    "code": "INVALID_STATE",
                    "numeric_code": 400,
                    "message": f"Agent is not awaiting approval (state: {state.state.value})",
                    "details": {
                        "instance_id": self.instance_id,
                        "state": state.state.value,
                    },
                },
            }

        # Write rejection response to state channel
        write_result = write_approval_response(
            self.instance_id, approved=False, note=note
        )
        if isinstance(write_result, Failure):
            return {
                "error": {
                    "code": "WRITE_FAILED",
                    "numeric_code": 500,
                    "message": f"Failed to write rejection response: {write_result.failure()}",
                    "details": {"instance_id": self.instance_id},
                },
            }

        # Transition state to running (reject resumes agent, not kills it)
        transition_result = transition_state(
            self.instance_id, LifecycleEvent.reject
        )
        if isinstance(transition_result, Failure):
            return {
                "error": {
                    "code": "STATE_TRANSITION_FAILED",
                    "numeric_code": 500,
                    "message": f"Failed to transition state: {transition_result.failure()}",
                    "details": {
                        "instance_id": self.instance_id,
                        "state": state.state.value,
                    },
                },
            }

        # Signal approval event to wake the agent loop
        if hasattr(self.agent_runner, "resolve_interactive_approval_future"):
            self.agent_runner.resolve_interactive_approval_future(
                {"approved": False, "note": note}
            )
        if hasattr(self.agent_runner, "_approval_event"):
            self.agent_runner._approval_event.set()

        return {
            "instance_id": self.instance_id,
            "acknowledged": True,
        }

    async def _handle_resume(self) -> dict[str, Any]:
        """Handle anima.resume() MCP tool call.

        Validates agent is in suspended state, signals the resume event
        to wake the agent loop, and transitions agent to running state.

        Returns:
            Dict with instance_id and acknowledged fields.
        """
        from anima.core.lifecycle import LifecycleEvent
        from anima.shell.state import read_state, transition_state

        # Validate agent is in suspended state
        result = read_state(self.instance_id)
        if isinstance(result, Failure):
            return {
                "error": {
                    "code": "AGENT_NOT_FOUND",
                    "numeric_code": 300,
                    "message": f"Agent not found: {self.instance_id}",
                    "details": {"instance_id": self.instance_id},
                },
            }

        state = result.unwrap()
        if state.state.value != "suspended":
            return {
                "error": {
                    "code": "INVALID_STATE",
                    "numeric_code": 400,
                    "message": f"Agent is not suspended (state: {state.state.value})",
                    "details": {
                        "instance_id": self.instance_id,
                        "state": state.state.value,
                    },
                },
            }

        # Signal resume event to wake the agent loop
        if hasattr(self.agent_runner, "_resume_event"):
            self.agent_runner._resume_event.set()

        # Transition state to running
        transition_result = transition_state(self.instance_id, LifecycleEvent.resume)
        if isinstance(transition_result, Failure):
            return {
                "error": {
                    "code": "STATE_TRANSITION_FAILED",
                    "numeric_code": 500,
                    "message": f"Failed to transition state: {transition_result.failure()}",
                    "details": {
                        "instance_id": self.instance_id,
                        "state": state.state.value,
                    },
                },
            }

        return {
            "instance_id": self.instance_id,
            "acknowledged": True,
        }

    async def _handle_stream_request(self, request: Request) -> Response:
        """Handle SSE stream request for streaming LLM output.

        Enforces max-8 consumer fan-out constraint. Yields SSE events from queue.

        Spec: talk-streaming-transport.md Event Schema

        Args:
            request: HTTP request object (unused but required by FastMCP custom_route).

        Returns:
            SSE stream response yielding chunk/done/error events,
            or 409 Conflict if consumer limit is reached.
        """
        del request

        async with self._stream_subscribers_lock:
            if len(self._stream_subscribers) >= 8:
                return Response(
                    content=json.dumps(
                        {
                            "error": {
                                "code": "STREAM_CONFLICT",
                                "message": "Maximum stream consumers reached for this agent",
                            }
                        }
                    ),
                    status_code=409,
                    media_type="application/json",
                )
            consumer_id = uuid.uuid4().hex
            consumer_queue: asyncio.Queue[StreamEvent | None] = asyncio.Queue(
                maxsize=32
            )
            self._stream_subscribers[consumer_id] = consumer_queue
            self._stream_consumer_active = True
            self._stream_active = True
            self._stream_cancel = False

        async def event_generator():
            """Generate SSE events from stream queue."""
            try:
                while True:
                    if self._stream_cancel:
                        # Client disconnected, stop streaming
                        break

                    # Get event from queue (blocking)
                    event = await consumer_queue.get()

                    # Check for terminal event or sentinel
                    if event is None:
                        # Stream end sentinel
                        break

                    # Format as SSE
                    if isinstance(event, StreamChunkEvent):
                        data = json.dumps(
                            {
                                "turn": event.turn,
                                "seq": event.seq,
                                "chunk": event.chunk,
                            }
                        )
                        yield {
                            "event": "chunk",
                            "data": data,
                        }
                    elif isinstance(event, StreamDoneEvent):
                        data = json.dumps(
                            {
                                "turn": event.turn,
                                "seq": event.seq,
                                "done": True,
                                "stop_reason": event.stop_reason,
                                "final_response_chars": event.final_response_chars,
                                "usage": {
                                    "prompt_tokens": event.prompt_tokens,
                                    "completion_tokens": event.completion_tokens,
                                    "total_tokens": event.total_tokens,
                                },
                            }
                        )
                        yield {
                            "event": "done",
                            "data": data,
                        }
                        # Terminal event - break after done
                        break
                    elif isinstance(event, StreamErrorEvent):
                        data = json.dumps(
                            {
                                "turn": event.turn,
                                "seq": event.seq,
                                "error": event.error,
                                "code": event.code,
                            }
                        )
                        yield {
                            "event": "error",
                            "data": data,
                        }
                        # Terminal event - break after error
                        break
                    elif isinstance(event, StreamToolCallEvent):
                        data = json.dumps(
                            {
                                "turn": event.turn,
                                "seq": event.seq,
                                "tool_name": event.tool_name,
                                "tool_arg_preview": event.tool_arg_preview,
                            }
                        )
                        yield {
                            "event": "tool_call",
                            "data": data,
                        }
                        # Non-terminal event - continue streaming
            finally:
                # Always cleanup on exit
                async with self._stream_subscribers_lock:
                    self._stream_subscribers.pop(consumer_id, None)
                    self._stream_consumer_active = bool(self._stream_subscribers)
                    if not self._stream_consumer_active:
                        self._stream_active = False

        return EventSourceResponse(event_generator())

    def get_pending_message(self) -> asyncio.Queue[str]:
        """Return the pending message queue for agent loop integration.

        Returns:
            The message queue (initialized in __init__).
        """
        return self._pending_message

    def drain_pending_messages(self) -> list[str]:
        """Drain and return currently queued interact messages.

        Spec source: vectl step ``agent-loop-resilience.drain-pending-messages-after-phase-1-completes``.
        This snapshot is used by AgentRunner after phase-1 completion so messages
        queued during initial execution are processed before terminal transition.

        Returns:
            Messages available at drain time, oldest first.
        """
        drained: list[str] = []
        while True:
            try:
                drained.append(self._pending_message.get_nowait())
            except asyncio.QueueEmpty:
                break
        return drained

    def request_persona_switch(
        self, persona: PersonaSpec, persona_ref: str
    ) -> bool:
        """Request a persona hot-swap at the next turn boundary.

        Only one pending switch is allowed. If a switch is already pending,
        this returns False and the request is rejected.

        Spec source: docs/INTERFACES.md D.5 — single-pending slot.

        Args:
            persona: The new PersonaSpec to switch to.
            persona_ref: File path reference for the new persona.

        Returns:
            True if the switch was accepted, False if one is already pending.
        """
        if self._pending_persona_switch is not None:
            return False
        self._pending_persona_switch = persona
        self._pending_persona_switch_ref = persona_ref
        return True

    def consume_pending_persona_switch(
        self,
    ) -> tuple[PersonaSpec, str] | None:
        """Consume and return pending persona switch, or None.

        Called by AgentRunner at turn boundaries to check for pending switches.
        Clears the slot after consumption.

        Spec source: docs/INTERFACES.md D.5 — turn-boundary cooperative switch.

        Returns:
            Tuple of (PersonaSpec, persona_ref) if pending, None otherwise.
        """
        if self._pending_persona_switch is None:
            return None
        result = (self._pending_persona_switch, self._pending_persona_switch_ref)
        self._pending_persona_switch = None
        self._pending_persona_switch_ref = None
        # result[1] is always str when _pending_persona_switch is not None
        # because request_persona_switch sets both fields together
        return result  # type: ignore[return-value]

    def set_response(self, response: str, turn_number: int) -> None:
        """Set the response for the last interact call.

        Called by the agent loop after processing a message.

        Args:
            response: The agent's response text.
            turn_number: The turn number after processing.
        """
        self._last_response = response
        self._response_turn = turn_number
        self._response_event.set()

    def set_busy(self, is_busy: bool) -> None:
        """Set whether agent is actively processing an interact turn.

        Spec source: vectl step
        ``agent-loop-resilience.return-agent-busy-status-instead-of-hard-timeout``.

        Args:
            is_busy: True while processing interact turn, else False.
        """
        self._busy = is_busy

    def get_interrupt_event(self) -> asyncio.Event:
        """Return or create the interrupt event for agent loop integration.

        Returns:
            The interrupt event that can be awaited to detect interrupt requests.
        """
        if self._interrupt_event is None:
            self._interrupt_event = asyncio.Event()
        return self._interrupt_event

    def get_stop_turn_event(self) -> asyncio.Event:
        """Return or create the stop-turn event for control channel integration."""
        if self._stop_turn_event is None:
            self._stop_turn_event = asyncio.Event()
        return self._stop_turn_event

    # Stream management methods (Spec: talk-streaming-transport.md)

    # @invar:allow dead_param: Reserved for future stream event correlation; turn number available from AgentStateData
    async def start_stream_session(self, turn_number: int) -> None:
        """Initialize stream state for a new interact turn.

        Args:
            turn_number: The turn number for this stream session.
        """
        async with self._stream_subscribers_lock:
            self._stream_seq = 0
            self._stream_active = True
            self._stream_cancel = False
            # Spec source: bridge-and-fanout.fix-gate-blockers (B3)
            # Reset and drain each subscriber queue while holding subscriber map lock.
            for subscriber_queue in self._stream_subscribers.values():
                while not subscriber_queue.empty():
                    try:
                        subscriber_queue.get_nowait()
                    except asyncio.QueueEmpty:
                        break

    async def _fanout_non_terminal(self, event: StreamEvent) -> None:
        async with self._stream_subscribers_lock:
            subscriber_queues = list(self._stream_subscribers.values())
        for subscriber_queue in subscriber_queues:
            try:
                subscriber_queue.put_nowait(event)
            except asyncio.QueueFull:
                # Spec source: bridge-and-fanout.stream-fanout
                # Non-terminal events are dropped when consumer queue is full.
                continue

    async def _fanout_terminal(self, event: StreamDoneEvent | StreamErrorEvent) -> None:
        # Spec source: bridge-and-fanout.stream-fanout
        # Terminal events are guaranteed delivery for connected consumers.
        delivered: set[str] = set()
        while True:
            async with self._stream_subscribers_lock:
                pending = [
                    (consumer_id, queue)
                    for consumer_id, queue in self._stream_subscribers.items()
                    if consumer_id not in delivered
                ]
            if not pending:
                break

            for consumer_id, subscriber_queue in pending:
                try:
                    subscriber_queue.put_nowait(event)
                    delivered.add(consumer_id)
                except asyncio.QueueFull:
                    continue

            await asyncio.sleep(0.01)

    async def enqueue_stream_chunk(self, turn_number: int, chunk: str) -> None:
        """Enqueue a text chunk for streaming.

        Uses best-effort fan-out (drops non-terminal events on full queues).
        Skips enqueue if stream is cancelled or inactive.

        Args:
            turn_number: Current turn number for event correlation.
            chunk: Text delta from accumulated assistant output.
        """
        if not self._stream_active or self._stream_cancel:
            return

        self._stream_seq += 1
        event = StreamChunkEvent(
            turn=turn_number,
            seq=self._stream_seq,
            chunk=chunk,
        )
        await self._fanout_non_terminal(event)

    async def enqueue_stream_done(
        self,
        turn_number: int,
        stop_reason: str,
        final_response_chars: int,
        prompt_tokens: int,
        completion_tokens: int,
        total_tokens: int,
    ) -> None:
        """Enqueue done event with usage snapshot.

        Terminal event - never dropped. Retries until accepted or stream cancelled.

        Args:
            turn_number: Current turn number for event correlation.
            stop_reason: "complete" or "stopped" per design doc.
            final_response_chars: Character count of final response text.
            prompt_tokens: Prompt token count.
            completion_tokens: Completion token count.
            total_tokens: Total token count.
        """
        self._stream_seq += 1
        event = StreamDoneEvent(
            turn=turn_number,
            seq=self._stream_seq,
            stop_reason=stop_reason,
            final_response_chars=final_response_chars,
            prompt_tokens=prompt_tokens,
            completion_tokens=completion_tokens,
            total_tokens=total_tokens,
        )
        await self._fanout_terminal(event)
        self._stream_active = False

    async def enqueue_stream_error(
        self,
        turn_number: int,
        error: str,
        code: str = "INTERNAL",
    ) -> None:
        """Enqueue error event.

        Terminal event - never dropped. Retries until accepted or stream cancelled.

        Args:
            turn_number: Current turn number for event correlation.
            error: Human-readable error message.
            code: Error code (default "INTERNAL").
        """
        self._stream_seq += 1
        event = StreamErrorEvent(
            turn=turn_number,
            seq=self._stream_seq,
            error=error,
            code=code,
        )
        await self._fanout_terminal(event)
        self._stream_active = False

    async def enqueue_stream_tool_call(
        self,
        turn_number: int,
        tool_name: str,
        tool_arg_preview: str,
    ) -> None:
        """Enqueue tool call event for streaming.

        Non-terminal event - can be dropped if stream is cancelled or inactive.
        Same active/cancel guard pattern as stream chunk enqueue.

        Args:
            turn_number: Current turn number for event correlation.
            tool_name: Name of the tool being called.
            tool_arg_preview: Preview of tool arguments (truncated).
        """
        if not self._stream_active or self._stream_cancel:
            return

        self._stream_seq += 1
        event = StreamToolCallEvent(
            turn=turn_number,
            seq=self._stream_seq,
            tool_name=tool_name,
            tool_arg_preview=tool_arg_preview,
        )
        await self._fanout_non_terminal(event)

    def cancel_stream(self) -> None:
        """Signal stream cancellation (client disconnected)."""
        self._stream_cancel = True

    def is_stream_active(self) -> bool:
        """Check if stream is currently active."""
        return self._stream_active

    # Sentinel for stream end
    _STREAM_SENTINEL: None = None

    def _get_socket_path(self) -> Path:
        """Resolve the socket path using shared state directory logic.

        Requires ANIMA_STATE environment variable to be set.

        Returns:
            Path to the Unix domain socket.

        Raises:
            AgentMCPError: If ANIMA_STATE is not set.
        """
        import os

        if not os.environ.get("ANIMA_STATE"):
            raise AgentMCPError(
                code="ANIMA_STATE_NOT_SET",
                message="ANIMA_STATE environment variable not set. MCP server requires explicit state directory.",
            )

        from anima.shell.state import get_state_dir

        return get_state_dir() / "agents" / self.instance_id / "mcp.sock"

    async def start(self) -> Result[None, AgentMCPError]:
        """Start the MCP server on a Unix domain socket.

        Creates the socket directory if needed, starts the FastMCP server
        with HTTP over Unix socket transport.

        Returns:
            Success on successful start, Failure with AgentMCPError on error.
        """
        if self._running:
            return Failure(
                AgentMCPError(
                    code="MCP_ALREADY_RUNNING",
                    message=f"MCP server for {self.instance_id[:8]} is already running",
                )
            )

        try:
            socket_path = self._get_socket_path()
        except AgentMCPError as e:
            return Failure(e)

        # Ensure the directory exists
        socket_dir = socket_path.parent
        try:
            socket_dir.mkdir(parents=True, exist_ok=True)
        except OSError as e:
            return Failure(
                AgentMCPError(
                    code="MCP_SOCKET_DIR_FAILED",
                    message=f"Failed to create socket directory {socket_dir}: {e}",
                )
            )

        # Remove stale socket file if it exists
        if socket_path.exists():
            try:
                socket_path.unlink()
            except OSError as e:
                return Failure(
                    AgentMCPError(
                        code="MCP_SOCKET_UNLINK_FAILED",
                        message=f"Failed to remove stale socket {socket_path}: {e}",
                    )
                )

        self._socket_path = socket_path

        # Queue and event initialized in __init__ for immediate availability.
        # Spec source: async-poll-hardening review - maintain initialization invariant.

        # Start the FastMCP server with Unix domain socket
        # Suppress noisy logs from uvicorn/fastmcp during startup and shutdown
        import logging

        for _logger_name in ("uvicorn.error", "uvicorn", "fastmcp"):
            logging.getLogger(_logger_name).setLevel(logging.CRITICAL)
        try:
            self._server_task = asyncio.create_task(
                self.mcp.run_http_async(
                    transport="http",
                    show_banner=False,
                    uvicorn_config={"uds": str(socket_path), "log_level": "critical"},
                )
            )
        except RuntimeError as e:
            return Failure(
                AgentMCPError(
                    code="MCP_START_FAILED",
                    message=f"Failed to create server task: {e}",
                )
            )

        # Allow server to bind and verify startup
        await asyncio.sleep(0.1)

        # Check if server task failed immediately
        if self._server_task.done():
            exc = self._server_task.exception()
            if exc is not None:
                self._server_task = None
                return Failure(
                    AgentMCPError(
                        code="MCP_BIND_FAILED",
                        message=f"Failed to bind to socket {socket_path}: {exc}",
                    )
                )

        self._running = True
        return Success(None)

    async def stop(self) -> Result[None, AgentMCPError]:
        """Stop the MCP server and clean up resources.

        Performs graceful shutdown, cancels the server task, and removes
        the socket file.

        Returns:
            Success on successful stop, Failure with AgentMCPError on error.
        """
        if not self._running:
            return Success(None)

        self._running = False

        # Cancel the server task if running
        if self._server_task is not None and not self._server_task.done():
            self._server_task.cancel()
            try:
                await self._server_task
            except asyncio.CancelledError:
                pass  # Expected when cancelling
            self._server_task = None

        # Remove socket file on shutdown
        socket_to_clean = self._socket_path
        self._socket_path = None

        if socket_to_clean is not None:
            try:
                if socket_to_clean.exists():
                    socket_to_clean.unlink()
            except OSError as e:
                return Failure(
                    AgentMCPError(
                        code="MCP_SOCKET_CLEANUP_FAILED",
                        message=f"Failed to remove socket {socket_to_clean}: {e}",
                    )
                )

        return Success(None)

    @property
    def socket_path(self) -> Path | None:
        """Return the socket path, or None if server hasn't started."""
        return self._socket_path

    @property
    def is_running(self) -> bool:
        """Return whether the server is currently running."""
        return self._running
