"""LSP stdio transport for subprocess-based language servers.

Manages the lifecycle of an LSP server subprocess communicating via
JSON-RPC 2.0 over stdin/stdout.  The transport handles:

- Spawning the server process with the configured command and args.
- Writing JSON-RPC messages with ``Content-Length`` headers to stdin.
- Reading JSON-RPC responses from stdout.
- Graceful and forced process termination.

The transport is **not** thread-safe by itself; callers (e.g.,
:class:`LspClient`) must serialise access when sharing a transport
across threads.

Based on ``docs/specification.md`` LSP Server Lifecycle (lines 20744-20758)
and issue #826.
"""

from __future__ import annotations

import json
import os
import select
import subprocess
import threading
from typing import Any

import structlog

logger = structlog.get_logger(__name__)

# Maximum time (seconds) to wait for a graceful process termination
# before sending SIGKILL.
_GRACEFUL_SHUTDOWN_TIMEOUT = 5.0

# Default read timeout in seconds.  Prevents indefinite blocking on
# readline() when the server is slow or unresponsive.
_DEFAULT_READ_TIMEOUT = 30.0

# Maximum size of a single JSON-RPC message body (10 MiB, matching
# the existing ``server.py`` limit).
_MAX_CONTENT_LENGTH = 10 * 1024 * 1024


class StdioTransport:
    """Stdio transport for an LSP server subprocess.

    Manages the full lifecycle of a language server process:
    spawn, message I/O, and termination.

    Note:
        Uses ``select.select()`` for non-blocking reads, which requires
        Unix-like pipe file descriptors.  Not supported on Windows where
        ``select`` only works with sockets.

    Args:
        command: Executable command (e.g., ``"pyright-langserver"``).
        args: Additional CLI arguments (e.g., ``["--stdio"]``).
        env: Extra environment variables merged with the current env.
        cwd: Working directory for the subprocess.
    """

    def __init__(
        self,
        command: str,
        args: list[str] | None = None,
        env: dict[str, str] | None = None,
        cwd: str | None = None,
    ) -> None:
        if not command:
            raise ValueError("command must be a non-empty string")
        self._command = command
        self._args = args or []
        self._env = env or {}
        self._cwd = cwd
        self._process: subprocess.Popen[bytes] | None = None
        self._read_lock = threading.Lock()
        self._write_lock = threading.Lock()

    @property
    def is_alive(self) -> bool:
        """Return ``True`` if the subprocess is still running."""
        return self._process is not None and self._process.poll() is None

    def start(self) -> None:
        """Spawn the LSP server subprocess.

        Raises:
            LspError: If the process cannot be started.
            RuntimeError: If already started.
        """
        if self._process is not None and self.is_alive:
            raise RuntimeError("Transport already started")

        merged_env = {**os.environ, **self._env}
        cmd = [self._command, *self._args]

        logger.info(
            "lsp.transport.starting",
            command=self._command,
            args=self._args,
            cwd=self._cwd,
        )

        try:
            self._process = subprocess.Popen(
                cmd,
                stdin=subprocess.PIPE,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                env=merged_env,
                cwd=self._cwd,
            )
        except FileNotFoundError as exc:
            from cleveragents.lsp.errors import LspError

            raise LspError(
                f"LSP server command not found: {self._command}",
                details={"command": self._command, "args": self._args},
            ) from exc
        except OSError as exc:
            from cleveragents.lsp.errors import LspError

            raise LspError(
                f"Failed to start LSP server: {exc}",
                details={"command": self._command, "error": str(exc)},
            ) from exc

        logger.info(
            "lsp.transport.started",
            pid=self._process.pid,
            command=self._command,
        )

    def stop(self, timeout: float = _GRACEFUL_SHUTDOWN_TIMEOUT) -> int | None:
        """Terminate the subprocess gracefully, then force-kill if needed.

        Args:
            timeout: Seconds to wait for graceful exit before SIGKILL.

        Returns:
            The process exit code, or ``None`` if not started.
        """
        if self._process is None:
            return None

        if self._process.poll() is not None:
            code = self._process.returncode
            self._process = None
            return code

        logger.info(
            "lsp.transport.stopping",
            pid=self._process.pid,
        )

        self._process.terminate()
        try:
            self._process.wait(timeout=timeout)
        except subprocess.TimeoutExpired:
            logger.warning(
                "lsp.transport.force_killing",
                pid=self._process.pid,
            )
            self._process.kill()
            self._process.wait(timeout=2.0)

        code = self._process.returncode
        self._process = None
        logger.info("lsp.transport.stopped", exit_code=code)
        return code

    def send_message(self, body: dict[str, Any]) -> None:
        """Write a JSON-RPC message to the server's stdin.

        Args:
            body: JSON-serialisable message body.

        Raises:
            RuntimeError: If the transport is not started.
            BrokenPipeError: If the server process has exited.
        """
        if self._process is None or self._process.stdin is None:
            raise RuntimeError("Transport not started")

        payload = json.dumps(body, separators=(",", ":")).encode("utf-8")
        header = f"Content-Length: {len(payload)}\r\n\r\n".encode("ascii")

        with self._write_lock:
            try:
                self._process.stdin.write(header + payload)
                self._process.stdin.flush()
            except BrokenPipeError:
                logger.error("lsp.transport.write_broken_pipe")
                raise

    def read_message(self, timeout: float | None = None) -> dict[str, Any] | None:
        """Read the next JSON-RPC message from the server's stdout.

        Uses ``select`` to wait for data with a timeout.  Returns
        ``None`` if no complete message arrives before *timeout*
        seconds or the stream ends.

        Args:
            timeout: Maximum seconds to wait for data.  ``None``
                (default) waits up to ``_DEFAULT_READ_TIMEOUT``.

        Returns:
            Parsed JSON body, or ``None`` if the stream ended or
            timed out.

        Raises:
            RuntimeError: If the transport is not started.
            ValueError: If the message exceeds ``_MAX_CONTENT_LENGTH``.
        """
        if self._process is None or self._process.stdout is None:
            raise RuntimeError("Transport not started")

        effective_timeout = timeout if timeout is not None else _DEFAULT_READ_TIMEOUT

        with self._read_lock:
            return self._read_one_message(effective_timeout)

    def _read_one_message(self, timeout: float) -> dict[str, Any] | None:
        """Parse a single ``Content-Length`` framed JSON-RPC message."""
        assert self._process is not None
        assert self._process.stdout is not None

        stdout = self._process.stdout
        content_length: int | None = None

        # Read headers (with select-based timeout)
        while True:
            ready, _, _ = select.select([stdout], [], [], timeout)
            if not ready:
                return None  # Timed out waiting for data
            line = stdout.readline()
            if not line:
                return None  # EOF — server exited
            decoded = line.decode("ascii", errors="replace").strip()
            if not decoded:
                break  # Empty line = end of headers
            if decoded.lower().startswith("content-length:"):
                try:
                    content_length = int(decoded.split(":", 1)[1].strip())
                except ValueError:
                    logger.warning(
                        "lsp.transport.invalid_content_length",
                        header=decoded,
                    )
                    return None

        if content_length is None:
            return None

        if content_length > _MAX_CONTENT_LENGTH:
            raise ValueError(
                f"LSP message too large: {content_length} > {_MAX_CONTENT_LENGTH}"
            )

        # Read body (wait for remaining data)
        ready, _, _ = select.select([stdout], [], [], timeout)
        if not ready:
            return None
        body_bytes = stdout.read(content_length)
        if len(body_bytes) < content_length:
            return None  # Truncated — server exited mid-message

        try:
            return json.loads(body_bytes)
        except json.JSONDecodeError:
            logger.warning(
                "lsp.transport.invalid_json",
                body_length=len(body_bytes),
            )
            return None


__all__ = [
    "StdioTransport",
]
