"""ACP transport layer — subprocess lifecycle and JSON-RPC over stdio.

Thin wrapper around the ``agent-client-protocol`` SDK's
:func:`acp.spawn_agent_process`.  Manages the agent subprocess,
maps OS/transport errors to Otto exception types, and provides
a clean start/stop interface for the higher-level client layer.
"""

from __future__ import annotations

import asyncio
from asyncio.subprocess import Process

from acp import spawn_agent_process
from acp.client import ClientSideConnection
from acp.interfaces import Client

from otto.log import get_logger

_log = get_logger("otto.acp.transport")

# Seconds to wait for graceful shutdown before killing the agent.
DEFAULT_SHUTDOWN_TIMEOUT = 4.0


class ACPTransport:
    """Manages the lifecycle of a single ACP agent subprocess.

    Usage::

        transport = ACPTransport(client, command=["python3", "agent.py"])
        conn, proc = await transport.start()
        try:
            ...  # use conn (ClientSideConnection)
        finally:
            await transport.stop()
    """

    def __init__(
        self,
        client: Client,
        command: list[str],
        *,
        env: dict[str, str] | None = None,
        cwd: str | None = None,
        shutdown_timeout: float = DEFAULT_SHUTDOWN_TIMEOUT,
    ):
        self._client = client
        self._command = command
        self._env = env
        self._cwd = cwd
        self._shutdown_timeout = shutdown_timeout

        self._ctx_manager = None
        self._connection: ClientSideConnection | None = None
        self._process: Process | None = None
        self._stderr_task: asyncio.Task | None = None

    @property
    def connection(self) -> ClientSideConnection | None:
        return self._connection

    @property
    def process(self) -> Process | None:
        return self._process

    @property
    def running(self) -> bool:
        return self._process is not None and self._process.returncode is None

    async def start(self) -> tuple[ClientSideConnection, Process]:
        """Spawn the agent subprocess and return the connection and process.

        Raises:
            ACPAgentNotFoundError: if the command binary does not exist.
            ACPAgentCrashedError: if the process exits during startup.
        """
        from otto.acp import ACPAgentCrashedError, ACPAgentNotFoundError

        if self._connection is not None:
            raise RuntimeError("transport already started; call stop() first")

        cmd, *args = self._command
        transport_kwargs: dict[str, object] = {
            "shutdown_timeout": self._shutdown_timeout,
        }

        try:
            self._ctx_manager = spawn_agent_process(
                self._client,
                cmd,
                *args,
                env=self._env,
                cwd=self._cwd,
                transport_kwargs=transport_kwargs,
            )
            conn, proc = await self._ctx_manager.__aenter__()
        except FileNotFoundError as exc:
            self._ctx_manager = None
            raise ACPAgentNotFoundError(f"agent binary not found: {self._command[0]}") from exc
        except OSError as exc:
            self._ctx_manager = None
            raise ACPAgentCrashedError(f"failed to start agent process: {exc}") from exc

        # If the process already exited, treat it as a crash.
        if proc.returncode is not None:
            await self._cleanup_ctx()
            raise ACPAgentCrashedError(
                f"agent process exited immediately with code {proc.returncode}"
            )

        self._connection = conn
        self._process = proc
        self._stderr_task = asyncio.create_task(self._drain_stderr(proc))

        _log.info(
            "acp agent started",
            command=self._command,
            pid=proc.pid,
        )

        return conn, proc

    @staticmethod
    async def _drain_stderr(proc: Process) -> None:
        """Read and log the agent subprocess's stderr in the background."""
        if proc.stderr is None:
            return
        try:
            while True:
                line = await proc.stderr.readline()
                if not line:
                    break
                text = line.decode(errors="replace").rstrip()
                if text:
                    _log.debug("acp agent stderr", line=text)
        except (asyncio.CancelledError, OSError):
            pass

    async def stop(self) -> int | None:
        """Shut down the agent subprocess gracefully.

        Returns the process exit code, or ``None`` if the transport was
        not started.
        """
        if self._ctx_manager is None:
            return None

        if self._stderr_task is not None:
            self._stderr_task.cancel()
            self._stderr_task = None

        exit_code = await self._cleanup_ctx()

        pid = self._process.pid if self._process else None
        _log.info("acp agent stopped", pid=pid, exit_code=exit_code)

        self._connection = None
        self._process = None
        self._ctx_manager = None

        return exit_code

    async def _cleanup_ctx(self) -> int | None:
        """Exit the spawn context manager, handling errors."""
        if self._ctx_manager is None:
            return None

        exit_code = None
        try:
            await self._ctx_manager.__aexit__(None, None, None)
        except Exception:
            _log.warning("error during transport cleanup", exc_info=True)

        if self._process is not None:
            exit_code = self._process.returncode

        return exit_code

    async def __aenter__(self) -> tuple[ClientSideConnection, Process]:
        return await self.start()

    async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
        await self.stop()
