"""Serve-mode MCP server.

`anima serve` exposes SSE MCP for remote agent management:
anima.spawn(), anima.list(), anima.status(), anima.interact(),
anima.collect(), anima.kill().

See docs/INTERFACES.md Section C for the full API specification.

Adapter boundary note: this module owns MCP transport and response envelopes.
Execution launch/control orchestration belongs in ``anima.app`` services.
"""

# @invar:allow shell_result: MCP handlers use Result types
# @shell_orchestration: Error envelope helper for MCP responses
# @invar:allow file_size: MCP tool handlers and envelope wiring are co-located by API surface

from __future__ import annotations

import asyncio
import json
import logging
import os
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, cast

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

from anima.app import DefaultResultService, TerminalResultPolicyProjection
from anima.core.ids import generate_instance_id, generate_trace_id
from anima.app.control_service import (
    ApprovalRequest,
    KillRequest,
    ResumeRequest,
)
from anima.core.execution import (
    ExecutionOutputPolicyView,
    ExecutionIntent,
    ExecutionIntentDefaults,
    ExecutionIntentOverrides,
    resolve_execution_contract,
)
from anima.core.lifecycle import LifecycleEvent
from anima.core.schemas import (
    AgentNotFoundError,
    AgentNotInteractiveError,
    AgentNotTerminalError,
    AgentState,
    AgentTerminatedError,
    InternalError,
    JobSpec,
    LifecycleType,
    MCPSocketNotFoundError,
    MCPConnectionError,
    PersonaSpec,
    InvalidInputError,
    build_error_envelope,
    numeric_code_to_string,
)
from anima.core.contracts import RuntimeOutputPolicy
from anima.shell.meta import build_meta
from anima.shell.contract_compat import normalize_legacy_persona_payload
from anima.shell.contract_compat import load_persisted_or_legacy_contract
from anima.shell.model_config import (
    load_global_config,
    resolve_execution_output_policy_defaults,
)
from anima.shell.mcp_bridge import (
    call_agent_mcp_tool_async,
    get_mcp_socket_path,
    is_agent_alive,
    read_agent_state_with_fallback_async,
)
from anima.shell.control_service_adapter import (
    build_shell_control_service,
)
from anima.shell.state import (
    check_and_reap_stale_agents,
    create_agent_state,
    find_agent_by_prefix,
    get_agent_dir,
    list_agents,
    transition_state,
)

logger = logging.getLogger(__name__)

# Terminal states for collect validation
_TERMINAL_STATES = {AgentState.completed, AgentState.failed, AgentState.killed}
_TERMINAL_RESULT_TRUNCATED_MARKER = "\n...[truncated for terminal output]"


@dataclass(frozen=True)
class _FallbackOutputPolicyContract:
    """Minimal output-policy view used when legacy contract loading fails."""

    output_policy: RuntimeOutputPolicy


# @invar:allow shell_result: JSON envelope helper for MCP tool payloads
# @shell_orchestration: Error envelope formatting helper
def _with_error_envelope(
    payload: dict[str, Any],
    error: InvalidInputError
    | InternalError
    | AgentNotFoundError
    | AgentNotInteractiveError
    | AgentNotTerminalError
    | AgentTerminatedError
    | MCPSocketNotFoundError
    | MCPConnectionError,
) -> dict[str, Any]:
    """Attach docs/INTERFACES.md Section G error envelope to payload.

    Source: coverage-tech-debt.fix-anima-error-class-shadow requires
    mcp_serve error typing to be unambiguous (always structured envelope).
    """
    payload.update(build_error_envelope(error))
    return payload


# @invar:allow shell_result: MCP helper returns shaped payload tuple for response envelope
# @shell_orchestration: Uses app-projected output policy to shape terminal collect payload
def _shape_terminal_output(
    output: Any,
    *,
    projection: TerminalResultPolicyProjection,
) -> tuple[Any, bool]:
    """Shape terminal output values using projected output-policy limits."""
    if not isinstance(output, str):
        return output, False

    cap = projection.terminal_result_char_cap
    if cap is None or cap <= 0 or len(output) <= cap:
        return output, False

    marker = _TERMINAL_RESULT_TRUNCATED_MARKER
    if cap <= len(marker):
        return marker[:cap], True
    return output[: cap - len(marker)] + marker, True


class _ServeLaunchStoreAdapter:
    """Adapter binding serve-mode spawn calls to state persistence boundary.

    Spec source: execution-architecture-refactor.md#repository-target-architecture/adapters
    Routes spawn through app launch service instead of duplicate adapter orchestration.
    """

    def create_agent_state(
        self,
        *,
        instance_id: str,
        persona_name: str,
        job,
        parent_id: str | None = None,
        trace_id: str | None = None,
        spec_id: str | None = None,
        spec_digest: str | None = None,
        model: str | None = None,
        resolved_contract=None,
    ):
        return create_agent_state(
            instance_id=instance_id,
            persona_name=persona_name,
            job=job,
            parent_id=parent_id,
            trace_id=trace_id,
            spec_id=spec_id,
            spec_digest=spec_digest,
            model=model,
            resolved_contract=resolved_contract,
        )


class ServeError(Exception):
    """Error type for anima serve operations.

    Uses string codes for categorization (e.g., "SERVE_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 ServeMCPServer:
    """FastMCP server for remote agent management (serve mode).

    Exposes MCP tools via SSE transport for nervus or other orchestrators.
    See docs/INTERFACES.md Section C.
    """

    def __init__(self, host: str = "127.0.0.1", port: int = 8420) -> None:
        self.host = host
        self.port = port
        self.mcp: FastMCP = FastMCP(name="anima-serve")
        self._running: bool = False
        self._server_task: asyncio.Task[None] | None = None
        self._register_tools()

    def _register_tools(self) -> None:
        """Register FastMCP tools for serve mode MCP surface.

        Registered tools: anima.spawn, anima.list, anima.status,
        anima.interact, anima.collect, anima.kill, anima.logs,
        anima.approve, anima.reject, anima.resume.
        """

        @self.mcp.tool(name="anima.spawn")
        async def _anima_spawn(
            persona_spec: dict[str, Any],
            job_spec: dict[str, Any],
            task: str,
        ) -> dict[str, Any]:
            return await self._handle_spawn(persona_spec, job_spec, task)

        @self.mcp.tool(name="anima.list")
        async def _anima_list() -> list[dict[str, Any]]:
            return await self._handle_list()

        @self.mcp.tool(name="anima.status")
        async def _anima_status(agent_id: str) -> dict[str, Any]:
            return await self._handle_status(agent_id)

        @self.mcp.tool(name="anima.interact")
        async def _anima_interact(agent_id: str, message: str) -> dict[str, Any]:
            return await self._handle_interact(agent_id, message)

        @self.mcp.tool(name="anima.collect")
        async def _anima_collect(agent_id: str) -> dict[str, Any]:
            return await self._handle_collect(agent_id)

        @self.mcp.tool(name="anima.kill")
        async def _anima_kill(agent_id: str) -> dict[str, Any]:
            return await self._handle_kill(agent_id)

        @self.mcp.tool(name="anima.logs")
        async def _anima_logs(agent_id: str, tail: int | None = None) -> dict[str, Any]:
            return await self._handle_logs(agent_id, tail)

        @self.mcp.tool(name="anima.approve")
        async def _anima_approve(
            agent_id: str, note: str | None = None
        ) -> dict[str, Any]:
            return await self._handle_approve(agent_id, note)

        @self.mcp.tool(name="anima.reject")
        async def _anima_reject(
            agent_id: str, reason: str | None = None
        ) -> dict[str, Any]:
            return await self._handle_reject(agent_id, reason)

        @self.mcp.tool(name="anima.resume")
        async def _anima_resume(agent_id: str) -> dict[str, Any]:
            return await self._handle_resume(agent_id)

        # Register custom HTTP route for SSE stream proxying
        # Spec: sse-bridge-cli-dedup.sse-bridge-impl
        @self.mcp.custom_route("/agent/{agent_id}/stream", methods=["GET"])
        async def _stream_bridge(request: Request) -> Response:
            """SSE stream proxy endpoint for agent streaming output.

            Proxies SSE events from agent's /stream endpoint to serve clients.
            Transparent forwarding - events are not modified.

            Args:
                request: HTTP request from Starlette.

            Returns:
                EventSourceResponse with SSE events, or error JSON (404/502).
            """
            return await self._handle_stream_bridge(request)

    async def _handle_stream_bridge(self, request: Request) -> Response:
        """Handle SSE stream proxy request for an agent.

        Proxies SSE events from agent's /stream endpoint to serve clients.
        Transparent forwarding - events are not modified.

        Spec: sse-bridge-cli-dedup.sse-bridge-impl

        Args:
            request: HTTP request containing agent_id path parameter.

        Returns:
            EventSourceResponse with SSE events from agent, or error JSON.
        """
        # 1. Extract agent_id from path parameter
        agent_id = request.path_params.get("agent_id")
        if not agent_id:
            return JSONResponse(
                status_code=400,
                content={
                    "error": {"code": "INVALID_REQUEST", "message": "Missing agent_id"}
                },
            )

        # 2. Resolve agent_id (prefix match via find_agent_by_prefix)
        resolve_result = find_agent_by_prefix(agent_id)
        if isinstance(resolve_result, Failure):
            return JSONResponse(
                status_code=404,
                content={
                    "error": {
                        "code": "AGENT_NOT_FOUND",
                        "numeric_code": 300,
                        "message": f"Agent not found: {agent_id}",
                        "details": {"instance_id": agent_id},
                    }
                },
            )

        agent_state = resolve_result.unwrap()
        instance_id = agent_state.instance_id

        # 3. Verify agent is alive
        if not is_agent_alive(instance_id):
            return JSONResponse(
                status_code=404,
                content={
                    "error": {
                        "code": "AGENT_NOT_FOUND",
                        "numeric_code": 300,
                        "message": f"Agent not alive: {instance_id}",
                        "details": {"instance_id": instance_id},
                    }
                },
            )

        # 4. Get socket path
        socket_path = get_mcp_socket_path(instance_id)

        # 5. Open httpx.AsyncClient with UDS transport to agent socket
        try:
            # Use explicit send(..., stream=True) so upstream stream remains open
            # after this method returns EventSourceResponse.
            transport = httpx.AsyncHTTPTransport(uds=str(socket_path))
            client = httpx.AsyncClient(
                transport=transport,
                timeout=30.0,
            )
            request_upstream = client.build_request("GET", "http://localhost/stream")
            response = await client.send(request_upstream, stream=True)

            if response.status_code == 404:
                await response.aclose()
                await client.aclose()
                return JSONResponse(
                    status_code=502,
                    content={
                        "error": {
                            "code": "UPSTREAM_NOT_FOUND",
                            "numeric_code": 502,
                            "message": "Agent stream endpoint not available",
                            "details": {"instance_id": instance_id},
                        }
                    },
                )
            if response.status_code != 200:
                await response.aclose()
                await client.aclose()
                return JSONResponse(
                    status_code=502,
                    content={
                        "error": {
                            "code": "UPSTREAM_ERROR",
                            "numeric_code": 502,
                            "message": f"Agent returned status {response.status_code}",
                            "details": {"instance_id": instance_id},
                        }
                    },
                )

            # Forward SSE events to client via EventSourceResponse.
            async def event_generator():
                pending_event_type = ""  # Track event type for next data line
                try:
                    async for line in response.aiter_lines():
                        # Forward SSE events as properly formatted dict for EventSourceResponse
                        if line.strip():
                            # Parse SSE format: "event: xxx" or "data: xxx"
                            if line.startswith("event:"):
                                pending_event_type = line[6:].strip()
                                # Don't yield yet - wait for data line
                            elif line.startswith("data:"):
                                data_value = line[5:].strip()
                                yield {
                                    "event": pending_event_type,
                                    "data": data_value,
                                }
                                pending_event_type = ""  # Reset after yielding
                            else:
                                # Forward as data line with current pending event type
                                yield {
                                    "event": pending_event_type,
                                    "data": line,
                                }
                                pending_event_type = ""
                except httpx.ConnectError as e:
                    # Agent disconnected mid-stream
                    logger.debug("Agent stream disconnected for %s: %s", instance_id, e)
                    yield {
                        "event": "error",
                        "data": '{"error": "Agent disconnected"}',
                    }
                except Exception as e:
                    # Stream error
                    logger.debug("Agent stream error for %s: %s", instance_id, e)
                    yield {
                        "event": "error",
                        "data": f'{{"error": "{type(e).__name__}: {e}"}}',
                    }
                finally:
                    await response.aclose()
                    await client.aclose()

            return EventSourceResponse(event_generator())

        except httpx.ConnectError as e:
            logger.debug("Failed to connect to agent socket %s: %s", socket_path, e)
            return JSONResponse(
                status_code=502,
                content={
                    "error": {
                        "code": "UPSTREAM_CONNECTION_FAILED",
                        "numeric_code": 502,
                        "message": f"Failed to connect to agent: {e}",
                        "details": {"instance_id": instance_id},
                    }
                },
            )
        except Exception as e:
            logger.exception("Unexpected error in stream bridge for %s", instance_id)
            return JSONResponse(
                status_code=502,
                content={
                    "error": {
                        "code": "UPSTREAM_ERROR",
                        "numeric_code": 502,
                        "message": f"Stream error: {e}",
                        "details": {"instance_id": instance_id},
                    }
                },
            )

    @staticmethod
    def _spawn_error_payload(
        instance_id: str = "", state: str = "error"
    ) -> dict[str, Any]:
        return {"instance_id": instance_id, "state": state}

    @staticmethod
    def _parse_spawn_specs(
        persona_spec: dict[str, Any],
        job_spec: dict[str, Any],
    ) -> Result[tuple[PersonaSpec, LifecycleType], InvalidInputError]:
        try:
            # Spec source: contract-alignment-integration.fix-seam-proof-blockers (B1)
            # Legacy aliases are quarantined in shell.contract_compat.
            normalized_persona_spec = normalize_legacy_persona_payload(persona_spec)

            persona = PersonaSpec(**normalized_persona_spec)
            lifecycle_str = job_spec.get("lifecycle", "ephemeral")
            lifecycle_type = LifecycleType(lifecycle_str)
        except (ValidationError, ValueError, TypeError) as e:
            return Failure(InvalidInputError(f"Invalid spec: {e}"))
        return Success((persona, lifecycle_type))

    @staticmethod
    def _create_spawn_job(
        *,
        persona: PersonaSpec,
        lifecycle_type: LifecycleType,
        instance_id: str,
        job_spec: dict[str, Any],
    ) -> JobSpec:
        return JobSpec(
            persona_ref=persona.id,
            instance_id=instance_id,
            lifecycle=lifecycle_type,
            max_tokens=job_spec.get("max_tokens"),
        )

    @staticmethod
    def _resolve_spawn_trace_and_parent() -> tuple[str | None, str]:
        parent_agent_id = os.environ.get("ANIMA_AGENT_ID")
        parent_trace_id = os.environ.get("ANIMA_TRACE_ID")
        trace_id = parent_trace_id if parent_trace_id else generate_trace_id()
        return parent_agent_id, trace_id

    def _persist_spawn_task_file(
        self,
        *,
        instance_id: str,
        task: str,
    ) -> Result[None, InternalError]:
        try:
            (get_agent_dir(instance_id) / "task.txt").write_text(task)
        except OSError as e:
            transition_state(
                instance_id,
                LifecycleEvent.fail,
                error=f"Task file write error ({type(e).__name__}): {e}",
            )
            return Failure(InternalError(f"Failed to write task file: {e}"))
        return Success(None)

    @staticmethod
    def _build_spawn_success_payload(
        *,
        agent_state: Any,
        pid: int,
    ) -> dict[str, Any]:
        return {
            "instance_id": agent_state.instance_id,
            "agent_id": agent_state.instance_id,
            "state": "pending",
            "pid": pid,
            "_meta": build_meta(agent_state),
        }

    async def _handle_spawn(
        self,
        persona_spec: dict[str, Any],
        job_spec: dict[str, Any],
        task: str,
    ) -> dict[str, Any]:
        """Handle anima.spawn() - launches child subprocess via launch_service.

        Per docs/INTERFACES.md Section C:
        - Creates agent state with own instance_id
        - Launches detached subprocess via launch service
        - Child inherits MCP connection settings (ANIMA_STATE, OPENAI_BASE_URL)
        - Preserves trace linkage to parent if invoked from running agent

        Adapter note: Routes spawn through app.launch_service rather than
        duplicate orchestrating code in the adapter layer.

        Args:
            persona_spec: PersonaSpec dict (see docs/INTERFACES.md Section E)
            job_spec: JobSpec dict (see docs/INTERFACES.md Section E)
            task: Task string for the agent

        Returns:
            Dict with instance_id and state per docs/INTERFACES.md Section C.
        """
        from anima.app.launch_service import DefaultLaunchService, LaunchRequest

        parsed_specs = self._parse_spawn_specs(persona_spec, job_spec)
        if isinstance(parsed_specs, Failure):
            return _with_error_envelope(
                self._spawn_error_payload(),
                parsed_specs.failure(),
            )
        persona, lifecycle_type = parsed_specs.unwrap()

        instance_id = generate_instance_id()
        job = self._create_spawn_job(
            persona=persona,
            lifecycle_type=lifecycle_type,
            instance_id=instance_id,
            job_spec=job_spec,
        )
        parent_agent_id, trace_id = self._resolve_spawn_trace_and_parent()

        output_policy_defaults: dict[str, object] = {}
        config_result = load_global_config()
        if isinstance(config_result, Success):
            config = config_result.unwrap()
            output_policy_defaults = resolve_execution_output_policy_defaults(
                cli=None,
                env={
                    "profile": os.environ.get("ANIMA_TOOL_OUTPUT_PROFILE"),
                    "per_call_cap_bytes": os.environ.get(
                        "ANIMA_TOOL_OUTPUT_PER_CALL_CAP_BYTES"
                    ),
                    "per_turn_cap_bytes": os.environ.get(
                        "ANIMA_TOOL_OUTPUT_PER_TURN_CAP_BYTES"
                    ),
                },
                config=(
                    config.tool_output.model_dump()
                    if hasattr(config.tool_output, "model_dump")
                    else None
                ),
                model_hint=persona.model,
                provider_hint=None,
                context_window_tokens=getattr(
                    config.defaults, "context_window_tokens", None
                ),
            )

        # Resolve persona source for contract resolution
        persona_source = "file" if persona.id else "inline"

        # Use launch service for consistent spawn orchestration
        # Spec source: execution-architecture-refactor.md#repository-target-architecture/adapters
        # Adapters route through launch_service rather than duplicate orchestration.
        launch_service = DefaultLaunchService(
            contract_resolver=lambda intent: resolve_execution_contract(
                intent,
                persona_source=persona_source,
            ),
            launch_store=_ServeLaunchStoreAdapter(),
        )

        launch_request = LaunchRequest(
            origin="serve_spawn",
            task=task,
            execution_intent=ExecutionIntent(
                persona_spec=persona,
                job_spec=job,
                overrides=ExecutionIntentOverrides(
                    requested_lifecycle=lifecycle_type,
                ),
                defaults=ExecutionIntentDefaults(**output_policy_defaults),
            ),
            instance_id=instance_id,
            trace_id=trace_id,
            parent_instance_id=parent_agent_id,
        )

        launch_result = launch_service.launch(launch_request)
        if isinstance(launch_result, Failure):
            return _with_error_envelope(
                self._spawn_error_payload(),
                launch_result.failure(),
            )

        agent_state = launch_result.unwrap().initial_state

        # Store task for runner (required by agent_runner but not part of launch_service contract)
        persist_result = self._persist_spawn_task_file(
            instance_id=agent_state.instance_id,
            task=task,
        )
        if isinstance(persist_result, Failure):
            return _with_error_envelope(
                self._spawn_error_payload(agent_state.instance_id),
                persist_result.failure(),
            )

        return self._build_spawn_success_payload(
            agent_state=agent_state,
            pid=agent_state.pid or 0,
        )

    async def _handle_list(self) -> list[dict[str, Any]]:
        """Handle anima.list() - lists agents.

        Returns:
            Plain array of agent info dicts per docs/INTERFACES.md Section C.
        """
        result = list_agents(None)
        if isinstance(result, Failure):
            return []

        agents = check_and_reap_stale_agents(result.unwrap())
        agent_list = []
        for agent in agents:
            uptime_seconds = 0
            if agent.created_at:
                try:
                    created = datetime.fromisoformat(agent.created_at)
                    uptime_seconds = int(
                        (datetime.now(timezone.utc) - created).total_seconds()
                    )
                except (ValueError, TypeError) as e:
                    # Malformed timestamp - use 0 uptime
                    logger.debug(
                        "Failed to parse created_at timestamp for %s: %s",
                        agent.instance_id,
                        agent.created_at,
                        exc_info=e,
                    )
            agent_list.append(
                {
                    "instance_id": agent.instance_id,
                    "agent_id": agent.instance_id,  # Backward compat alias
                    "state": agent.state.value,
                    "persona_name": agent.persona_name,
                    "lifecycle": agent.lifecycle.value,
                    "uptime_seconds": uptime_seconds,
                    "token_usage": {"total_tokens": agent.token_usage.total_tokens},
                    "_meta": build_meta(agent),
                }
            )

        return agent_list

    async def _handle_status(self, agent_id: str) -> dict[str, Any]:
        """Handle anima.status() - returns agent status per docs/INTERFACES.md Section B.

        Uses bridge for alive agents (MCP call) and falls back to file for dead.
        On error (agent not found), returns error envelope with AGENT_NOT_FOUND code.
        """
        from anima.shell.meta import build_meta_from_instance_id

        # Resolve prefix to full instance_id for parity with other handlers.
        resolved_instance_id = agent_id
        resolved = find_agent_by_prefix(agent_id)
        if isinstance(resolved, Success):
            resolved_instance_id = resolved.unwrap().instance_id

        # Try bridge first (MCP for alive agents, file fallback for dead)
        result = await read_agent_state_with_fallback_async(resolved_instance_id)
        if isinstance(result, Failure):
            error = result.failure()
            # Return proper error envelope per docs/INTERFACES.md Section G
            # Use numeric_code_to_string to convert numeric code to string code
            error_code = numeric_code_to_string(error.code)
            return {
                "error": {
                    "code": error_code,
                    "numeric_code": error.code,
                    "message": str(error),
                    "details": {"instance_id": resolved_instance_id},
                },
                "_meta": build_meta_from_instance_id(resolved_instance_id),
            }

        # Bridge/fallback succeeded - use the response directly
        state_data = result.unwrap()
        resolved_instance_id = state_data.get("instance_id", resolved_instance_id)
        meta_trace_id = None
        if isinstance(state_data.get("_meta"), dict):
            meta_trace_id = state_data.get("_meta", {}).get("trace_id")
        resolved_trace_id = state_data.get("trace_id") or meta_trace_id
        if not isinstance(resolved_trace_id, str) or len(resolved_trace_id) == 0:
            logger.debug(
                "Status response for %s missing trace_id; generating fallback trace_id",
                resolved_instance_id,
            )
            resolved_trace_id = generate_trace_id()
        return {
            "instance_id": resolved_instance_id,
            "agent_id": resolved_instance_id,
            "state": state_data.get("state", "unknown"),
            "persona_name": state_data.get("persona_name", ""),
            "lifecycle": state_data.get("lifecycle", "ephemeral"),
            "token_usage": {
                "prompt_tokens": state_data.get("token_usage", {}).get(
                    "prompt_tokens", 0
                ),
                "completion_tokens": state_data.get("token_usage", {}).get(
                    "completion_tokens", 0
                ),
                "total_tokens": state_data.get("token_usage", {}).get(
                    "total_tokens", 0
                ),
            },
            "tools_called": state_data.get("tools_called", []),
            "uptime_seconds": state_data.get("uptime_seconds", 0),
            "parent_id": state_data.get("parent_id"),
            "current_turn": state_data.get("current_turn", 0),
            "max_turns": state_data.get("max_turns", 0),
            "_meta": {
                "instance_id": resolved_instance_id,
                "agent_id": resolved_instance_id,
                "trace_id": resolved_trace_id,
            },
        }

    def _interactive_states(self) -> set[AgentState]:
        # Source: vectl step approval-mechanism-fix.impl-talk-during-approval
        # requires interact/talk to remain available while approval is pending.
        return {
            AgentState.running,
            AgentState.suspended,
            AgentState.awaiting_approval,
        }

    async def _handle_interact(self, agent_id: str, message: str) -> dict[str, Any]:
        """Handle anima.interact() - sends message to agent via MCP bridge.

        Returns dict with response and turn_number per docs/INTERFACES.md Section C.
        """
        from anima.shell.meta import build_meta_from_instance_id

        # First resolve agent for consistent metadata and prefix support.
        result = find_agent_by_prefix(agent_id)
        if isinstance(result, Failure):
            return _with_error_envelope(
                {
                    "response": "",
                    "turn_number": 0,
                    "_meta": build_meta_from_instance_id(agent_id),
                },
                AgentNotFoundError(f"Agent not found: {agent_id}"),
            )

        agent_state = result.unwrap()
        interactive_states = self._interactive_states()
        if agent_state.state not in interactive_states:
            return _with_error_envelope(
                {
                    "response": "",
                    "turn_number": agent_state.current_turn,
                    "_meta": build_meta(agent_state),
                },
                AgentNotInteractiveError(
                    f"Agent not running (state: {agent_state.state.value})",
                    details={
                        "instance_id": agent_state.instance_id,
                        "current_state": agent_state.state.value,
                    },
                ),
            )

        # Use bridge to call anima.interact on the agent's MCP server
        bridge_result = await call_agent_mcp_tool_async(
            agent_state.instance_id,
            "anima.interact",
            arguments={"message": message},
        )

        if isinstance(bridge_result, Failure):
            error = bridge_result.failure()
            return _with_error_envelope(
                {
                    "response": "",
                    "turn_number": agent_state.current_turn,
                    "_meta": build_meta(agent_state),
                },
                MCPConnectionError(f"Failed to interact: {error}"),
            )

        # Bridge succeeded - extract response
        data = bridge_result.unwrap()
        return {
            "response": data.get("response", ""),
            "turn_number": data.get("turn_number", agent_state.current_turn),
            "_meta": data.get("_meta", build_meta(agent_state)),
        }

    async def _handle_collect(self, agent_id: str) -> dict[str, Any]:
        """Handle anima.collect() - returns result from terminal-state agent.

        Per docs/INTERFACES.md Section C, status must be one of: completed, failed, killed.
        On error (agent not found or not terminal), returns error envelope.
        """
        result = find_agent_by_prefix(agent_id)
        if isinstance(result, Failure):
            # Agent not found - return error envelope per docs/INTERFACES.md Section G
            return _with_error_envelope(
                {},
                AgentNotFoundError(
                    f"Agent not found: {agent_id}", details={"instance_id": agent_id}
                ),
            )

        agent_state = result.unwrap()
        if agent_state.state not in _TERMINAL_STATES:
            # Agent not terminal - return error envelope
            return _with_error_envelope(
                {},
                AgentNotTerminalError(
                    f"Agent not in terminal state (state: {agent_state.state.value})",
                    details={
                        "instance_id": agent_state.instance_id,
                        "current_state": agent_state.state.value,
                    },
                ),
            )

        try:
            contract = load_persisted_or_legacy_contract(agent_state)
            result_projection = DefaultResultService().project_terminal_result_policy(
                contract,
                adapter_surface="mcp_response_presenter",
            )
        except (TypeError, ValueError, ValidationError) as error:
            logger.debug(
                "Failed to load contract for collect shaping for %s: %s",
                agent_state.instance_id,
                error,
            )
            fallback_policy = RuntimeOutputPolicy(
                provider_profile="legacy-default",
                per_call_tool_output_chars=None,
                per_turn_tool_output_chars=None,
                truncation_strategy="truncate",
            )
            result_projection = DefaultResultService().project_terminal_result_policy(
                cast(
                    ExecutionOutputPolicyView,
                    _FallbackOutputPolicyContract(output_policy=fallback_policy),
                ),
                adapter_surface="mcp_response_presenter",
            )

        output = None
        result_file = get_agent_dir(agent_state.instance_id) / "result.json"
        if result_file.exists():
            try:
                output = json.loads(result_file.read_text()).get("output")
            except (json.JSONDecodeError, OSError) as e:
                # Corrupted or unreadable result file - treated as no output
                logger.debug(
                    "Failed to read result file for %s: %s",
                    agent_state.instance_id,
                    result_file,
                    exc_info=e,
                )

        shaped_output, _ = _shape_terminal_output(
            output,
            projection=result_projection,
        )

        return {
            "instance_id": agent_state.instance_id,
            "result_ref": agent_state.result_ref,
            "status": agent_state.state.value,
            "output": shaped_output,
            "error": agent_state.error,
            "_meta": build_meta(agent_state),
        }

    async def _handle_kill(self, agent_id: str) -> dict[str, Any]:
        """Handle anima.kill() - transitions agent to killed state.

        Returns dict with acknowledged: true per docs/INTERFACES.md Section C.
        Uses bridge to call anima.interrupt for alive agents, keeps kill fallback.
        """
        from anima.shell.meta import build_meta_from_instance_id

        result = find_agent_by_prefix(agent_id)
        if isinstance(result, Failure):
            return _with_error_envelope(
                {
                    "acknowledged": False,
                    "_meta": build_meta_from_instance_id(agent_id),
                },
                AgentNotFoundError(f"Agent not found: {agent_id}"),
            )

        agent_state = result.unwrap()
        control_service = build_shell_control_service()
        kill_result = control_service.kill(
            KillRequest(
                instance_id=agent_state.instance_id,
                reason="Killed by user (mcp)",
            )
        )
        if isinstance(kill_result, Failure):
            return _with_error_envelope(
                {
                    "acknowledged": False,
                    "_meta": build_meta(agent_state),
                },
                kill_result.failure(),
            )

        # Notify running process via MCP bridge if agent is still alive
        if is_agent_alive(agent_state.instance_id):
            try:
                bridge_result = await call_agent_mcp_tool_async(
                    agent_state.instance_id,
                    "anima.interrupt",
                )
                if isinstance(bridge_result, Failure):
                    # Log but don't fail - agent may already be dead
                    logger.debug(
                        "Failed to interrupt agent %s via bridge: %s",
                        agent_state.instance_id,
                        bridge_result.failure(),
                    )
            except Exception as e:
                # Source: medium-fixes.fix-kill-notify-handler-logger-debug
                # requires a generic interrupt-notify handler with debug logging.
                # Best-effort notification - agent may already be dead or socket closed
                logger.debug(
                    "Failed to notify agent %s via MCP interrupt: %s",
                    agent_state.instance_id,
                    e,
                    exc_info=e,
                )

        return {"acknowledged": True, "_meta": build_meta(agent_state)}

    async def _handle_logs(
        self, agent_id: str, tail: int | None = None
    ) -> dict[str, Any]:
        """Handle anima.logs() - returns agent log entries.

        Uses bridge for alive agents (MCP call) and falls back to file for dead.
        On error (agent not found), returns error envelope with AGENT_NOT_FOUND code.

        Args:
            agent_id: Agent identifier (supports prefix matching).
            tail: Optional number of most recent lines to return.

        Returns:
            Dict with instance_id, entries (list), count per docs/INTERFACES.md Section C.
        """
        from anima.shell.meta import build_meta_from_instance_id

        # Resolve agent_id to instance_id via prefix
        result = find_agent_by_prefix(agent_id)
        if isinstance(result, Failure):
            return _with_error_envelope(
                {
                    "instance_id": agent_id,
                    "entries": [],
                    "count": 0,
                    "_meta": build_meta_from_instance_id(agent_id),
                },
                AgentNotFoundError(
                    f"Agent not found: {agent_id}", details={"instance_id": agent_id}
                ),
            )

        agent_state = result.unwrap()
        instance_id = agent_state.instance_id

        # Try bridge first (MCP for alive agents)
        if is_agent_alive(instance_id):
            bridge_result = await call_agent_mcp_tool_async(
                instance_id,
                "anima.logs",
                arguments={"tail": tail} if tail is not None else {},
            )
            if isinstance(bridge_result, Success):
                # Bridge succeeded - add _meta and return
                data = bridge_result.unwrap()
                data["_meta"] = build_meta(agent_state)
                return data

        # Fallback: read from log.jsonl file directly
        return await self._read_logs_from_file(instance_id, tail, agent_state)

    async def _read_logs_from_file(
        self,
        instance_id: str,
        tail: int | None,
        agent_state: Any,
    ) -> dict[str, Any]:
        """Read logs from log.jsonl file as fallback for dead agents."""
        from anima.shell.state import get_agent_dir

        log_path = get_agent_dir(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 as e:
                            logger.debug(
                                "Skipping malformed log line for %s in %s",
                                instance_id,
                                log_path,
                                exc_info=e,
                            )
            except OSError as e:
                logger.debug(
                    "Failed to read log file for %s at %s",
                    instance_id,
                    log_path,
                    exc_info=e,
                )

        return {
            "instance_id": instance_id,
            "entries": entries,
            "count": len(entries),
            "_meta": build_meta(agent_state),
        }

    async def _handle_approve(
        self, agent_id: str, note: str | None = None
    ) -> dict[str, Any]:
        """Handle anima.approve() - approve a pending agent approval request.

        Uses bridge for alive agents (MCP call) and falls back to file write
        for dead agents when socket is not ready.

        Args:
            agent_id: Agent identifier (supports prefix matching).
            note: Optional note explaining the approval decision.

        Returns:
            Dict with instance_id and acknowledged fields.
            On error (agent not found), returns error envelope with AGENT_NOT_FOUND code.
            On error (invalid state), returns error envelope with INVALID_STATE code.
        """
        from anima.shell.meta import build_meta_from_instance_id

        # Resolve agent_id via prefix
        result = find_agent_by_prefix(agent_id)
        if isinstance(result, Failure):
            return _with_error_envelope(
                {
                    "instance_id": agent_id,
                    "acknowledged": False,
                    "_meta": build_meta_from_instance_id(agent_id),
                },
                AgentNotFoundError(
                    f"Agent not found: {agent_id}", details={"instance_id": agent_id}
                ),
            )

        agent_state = result.unwrap()
        instance_id = agent_state.instance_id

        if agent_state.state.value != "awaiting_approval":
            return {
                "error": {
                    "code": "INVALID_STATE",
                    "numeric_code": 400,
                    "message": f"Agent is not awaiting approval (state: {agent_state.state.value})",
                    "details": {
                        "instance_id": instance_id,
                        "state": agent_state.state.value,
                    },
                },
                "instance_id": instance_id,
                "acknowledged": False,
                "_meta": build_meta(agent_state),
            }

        control_service = build_shell_control_service()

        # Try bridge first for alive agents
        if is_agent_alive(instance_id):
            bridge_result = await call_agent_mcp_tool_async(
                instance_id,
                "anima.approve",
                arguments={"note": note} if note else {},
            )
            if isinstance(bridge_result, Success):
                data = bridge_result.unwrap()
                data["_meta"] = build_meta(agent_state)
                return data
            return _with_error_envelope(
                {
                    "instance_id": instance_id,
                    "acknowledged": False,
                    "_meta": build_meta(agent_state),
                },
                MCPConnectionError(
                    f"Failed to approve via bridge: {bridge_result.failure()}",
                    details={"instance_id": instance_id},
                ),
            )

        # File/state fallback through shared control-service path.
        approve_result = control_service.approve(
            ApprovalRequest(instance_id=instance_id, approved=True, note=note)
        )
        if isinstance(approve_result, Failure):
            return _with_error_envelope(
                {
                    "instance_id": instance_id,
                    "acknowledged": False,
                    "_meta": build_meta(agent_state),
                },
                approve_result.failure(),
            )

        return {
            "instance_id": instance_id,
            "acknowledged": True,
            "_meta": build_meta(agent_state),
        }

    async def _handle_reject(
        self, agent_id: str, reason: str | None = None
    ) -> dict[str, Any]:
        """Handle anima.reject() - reject a pending agent approval request.

        Uses bridge for alive agents (MCP call) and falls back to file write
        for dead agents when socket is not ready.

        Args:
            agent_id: Agent identifier (supports prefix matching).
            reason: Optional note explaining the rejection reason.

        Returns:
            Dict with instance_id and acknowledged fields.
            On error (agent not found), returns error envelope with AGENT_NOT_FOUND code.
            On error (invalid state), returns error envelope with INVALID_STATE code.
        """
        from anima.shell.meta import build_meta_from_instance_id

        # Resolve agent_id via prefix
        result = find_agent_by_prefix(agent_id)
        if isinstance(result, Failure):
            return _with_error_envelope(
                {
                    "instance_id": agent_id,
                    "acknowledged": False,
                    "_meta": build_meta_from_instance_id(agent_id),
                },
                AgentNotFoundError(
                    f"Agent not found: {agent_id}", details={"instance_id": agent_id}
                ),
            )

        agent_state = result.unwrap()
        instance_id = agent_state.instance_id

        if agent_state.state.value != "awaiting_approval":
            return {
                "error": {
                    "code": "INVALID_STATE",
                    "numeric_code": 400,
                    "message": f"Agent is not awaiting approval (state: {agent_state.state.value})",
                    "details": {
                        "instance_id": instance_id,
                        "state": agent_state.state.value,
                    },
                },
                "instance_id": instance_id,
                "acknowledged": False,
                "_meta": build_meta(agent_state),
            }

        control_service = build_shell_control_service()

        # Try bridge first for alive agents
        if is_agent_alive(instance_id):
            bridge_result = await call_agent_mcp_tool_async(
                instance_id,
                "anima.reject",
                arguments={"note": reason} if reason else {},
            )
            if isinstance(bridge_result, Success):
                data = bridge_result.unwrap()
                data["_meta"] = build_meta(agent_state)
                return data
            return _with_error_envelope(
                {
                    "instance_id": instance_id,
                    "acknowledged": False,
                    "_meta": build_meta(agent_state),
                },
                MCPConnectionError(
                    f"Failed to reject via bridge: {bridge_result.failure()}",
                    details={"instance_id": instance_id},
                ),
            )

        reject_result = control_service.reject(
            ApprovalRequest(instance_id=instance_id, approved=False, note=reason)
        )
        if isinstance(reject_result, Failure):
            return _with_error_envelope(
                {
                    "instance_id": instance_id,
                    "acknowledged": False,
                    "_meta": build_meta(agent_state),
                },
                reject_result.failure(),
            )

        return {
            "instance_id": instance_id,
            "acknowledged": True,
            "_meta": build_meta(agent_state),
        }

    async def _handle_resume(self, agent_id: str) -> dict[str, Any]:
        """Handle anima.resume() - resumes a suspended agent.

        Uses bridge for alive agents (MCP call) and falls back to shared
        control-service for dead agents when socket is not ready.

        Adapter note: Routes control through app.control_service rather than
        duplicate adapter-layer transition_state calls.

        Args:
            agent_id: Agent identifier (supports prefix matching).

        Returns:
            Dict with instance_id and acknowledged fields.
            On error (agent not found), returns error envelope with AGENT_NOT_FOUND code.
            On error (invalid state), returns error envelope with INVALID_STATE code.
        """
        from anima.shell.meta import build_meta, build_meta_from_instance_id

        # Resolve agent_id via prefix
        result = find_agent_by_prefix(agent_id)
        if isinstance(result, Failure):
            return _with_error_envelope(
                {
                    "instance_id": agent_id,
                    "acknowledged": False,
                    "_meta": build_meta_from_instance_id(agent_id),
                },
                AgentNotFoundError(
                    f"Agent not found: {agent_id}", details={"instance_id": agent_id}
                ),
            )

        agent_state = result.unwrap()
        instance_id = agent_state.instance_id

        if agent_state.state.value != "suspended":
            return {
                "error": {
                    "code": "INVALID_STATE",
                    "numeric_code": 400,
                    "message": f"Agent is not suspended (state: {agent_state.state.value})",
                    "details": {
                        "instance_id": instance_id,
                        "state": agent_state.state.value,
                    },
                },
                "instance_id": instance_id,
                "acknowledged": False,
                "_meta": build_meta(agent_state),
            }

        # Try bridge first for alive agents
        if is_agent_alive(instance_id):
            bridge_result = await call_agent_mcp_tool_async(
                instance_id,
                "anima.resume",
            )
            if isinstance(bridge_result, Success):
                data = bridge_result.unwrap()
                data["_meta"] = build_meta(agent_state)
                return data
            return _with_error_envelope(
                {
                    "instance_id": instance_id,
                    "acknowledged": False,
                    "_meta": build_meta(agent_state),
                },
                MCPConnectionError(
                    f"Failed to resume via bridge: {bridge_result.failure()}",
                    details={"instance_id": instance_id},
                ),
            )

        # Fallback: Use shared control-service for dead agents
        control_service = build_shell_control_service()
        resume_result = control_service.resume(ResumeRequest(instance_id=instance_id))
        if isinstance(resume_result, Failure):
            return _with_error_envelope(
                {
                    "instance_id": instance_id,
                    "acknowledged": False,
                    "_meta": build_meta(agent_state),
                },
                resume_result.failure(),
            )

        return {
            "instance_id": instance_id,
            "acknowledged": True,
            "_meta": build_meta(agent_state),
        }

    # =========================================================================
    # Server lifecycle
    # =========================================================================

    async def start(self) -> Result[None, ServeError]:
        """Start the MCP server with SSE transport on host:port."""
        if self._running:
            return Failure(
                ServeError(
                    code="SERVE_ALREADY_RUNNING",
                    message=f"ServeMCPServer already running on {self.host}:{self.port}",
                )
            )

        try:
            self._server_task = asyncio.create_task(
                self.mcp.run_http_async(
                    transport="sse", host=self.host, port=self.port, show_banner=False
                )
            )
            self._running = True
            await asyncio.sleep(0.1)  # Allow server to bind

            if self._server_task.done():
                exc = self._server_task.exception()
                if exc is not None:
                    self._running = False
                    self._server_task = None
                    return Failure(
                        ServeError(
                            code="SERVE_BIND_FAILED",
                            message=f"Failed to bind to {self.host}:{self.port}: {exc}",
                        )
                    )
            return Success(None)
        except OSError as e:
            return Failure(
                ServeError(code="SERVE_BIND_FAILED", message=f"Failed to bind: {e}")
            )
        except RuntimeError as e:
            return Failure(
                ServeError(code="SERVE_START_FAILED", message=f"Failed to start: {e}")
            )

    async def stop(self) -> Result[None, ServeError]:
        """Stop the MCP server gracefully."""
        if not self._running:
            return Success(None)
        try:
            if self._server_task and not self._server_task.done():
                self._server_task.cancel()
                try:
                    await self._server_task
                except asyncio.CancelledError:
                    pass
            self._running = False
            self._server_task = None
            return Success(None)
        except RuntimeError as e:
            return Failure(
                ServeError(code="SERVE_STOP_FAILED", message=f"Failed to stop: {e}")
            )

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