"""Minimal LSP server stub with JSON-RPC over stdin/stdout transport.

Implements the Language Server Protocol base protocol with
Content-Length header framing.  Supports the ``initialize``,
``shutdown``, and ``exit`` lifecycle methods.  All other LSP
methods return a JSON-RPC ``MethodNotFound`` (-32601) error.

An :class:`A2aLocalFacade` instance is stored so that the
dispatch path can be wired through A2A when server mode lands.
In the current stub the facade is a placeholder — all methods
are handled by hardcoded stub handlers.

Usage::

    from cleveragents.lsp.server import LspServer
    server = LspServer()
    server.run()  # blocks reading stdin
"""

from __future__ import annotations

import json
import os
import sys
from typing import Any, BinaryIO

import structlog

from cleveragents.a2a.facade import A2aLocalFacade

logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__)

# JSON-RPC error codes (LSP specification)
_PARSE_ERROR = -32700
_INVALID_REQUEST = -32600
_METHOD_NOT_FOUND = -32601
SERVER_NOT_INITIALIZED = -32002

# LSP server metadata
_SERVER_NAME = "cleveragents-lsp-stub"
_SERVER_VERSION = "0.1.0"

# Maximum Content-Length accepted (10 MB).  Rejects messages that
# exceed this limit to prevent memory exhaustion from malicious or
# malformed headers.
MAX_CONTENT_LENGTH = 10 * 1024 * 1024

# Maximum number of header lines to read before giving up.  Prevents
# denial-of-service from an adversarial client sending infinite
# non-empty header lines without a blank terminator.
MAX_HEADER_LINES = 32

# Maximum bytes ``readline()`` will consume for a single header line.
# Without this cap a malicious client sending a multi-gigabyte line
# without a newline could cause unbounded memory allocation.  The
# MAX_HEADER_LINES guard fires *after* a complete line has been read,
# so it alone does not limit per-line memory.  With this cap the
# worst-case header memory is MAX_HEADER_LINES * MAX_HEADER_LINE_LENGTH
# (32 * 8192 = 256 KB).
MAX_HEADER_LINE_LENGTH = 8192


class _SkipMessage:
    """Sentinel returned by ``_read_message`` for recoverable transport errors.

    Distinguishes recoverable single-message failures (bad framing,
    oversized payload, malformed JSON) from a true EOF condition.  The
    run loop continues when ``_SKIP`` is returned instead of
    terminating the server.
    """

    __slots__ = ()


_SKIP = _SkipMessage()

# Stubbed capability set reported during initialize.  All
# capabilities from the specification's LSP Capability Exposure
# section are represented (disabled or null) so that clients see
# the full surface even while the server is a stub.
_STUBBED_CAPABILITIES: dict[str, Any] = {
    "textDocumentSync": 0,
    "completionProvider": None,
    "hoverProvider": False,
    "definitionProvider": False,
    "referencesProvider": False,
    "documentFormattingProvider": False,
    "renameProvider": False,
    "codeActionProvider": False,
    "diagnosticProvider": None,
    "signatureHelpProvider": None,
    "documentSymbolProvider": False,
    "workspaceSymbolProvider": False,
}


class LspServer:
    """Minimal LSP server stub over JSON-RPC stdin/stdout transport.

    The server reads Content-Length framed JSON-RPC messages from
    *input_stream* and writes responses to *output_stream*.  LSP
    requests are routed through an :class:`A2aLocalFacade` instance
    in local mode.

    Attributes:
        _facade: A2A local-mode facade for request routing.
        _input: Binary stream to read from (default: ``sys.stdin.buffer``).
        _output: Binary stream to write to (default: ``sys.stdout.buffer``).
        _running: Whether the event loop is active.
        _initialized: Whether ``initialize`` has been received.
        _shutdown_requested: Whether ``shutdown`` has been received.
    """

    def __init__(
        self,
        input_stream: BinaryIO | None = None,
        output_stream: BinaryIO | None = None,
        facade: A2aLocalFacade | None = None,
    ) -> None:
        if input_stream is not None and not hasattr(input_stream, "read"):
            raise TypeError("input_stream must support read()")
        if output_stream is not None and not hasattr(output_stream, "write"):
            raise TypeError("output_stream must support write()")
        self._input: BinaryIO = input_stream or sys.stdin.buffer
        self._output: BinaryIO = output_stream or sys.stdout.buffer
        self._facade: A2aLocalFacade | None = facade
        self._running: bool = False
        self._initialized: bool = False
        self._shutdown_requested: bool = False

    @property
    def facade(self) -> A2aLocalFacade:
        """Return the A2A facade, creating it lazily on first access.

        The facade is stored for future server-mode wiring but is
        not called in the current stub.  Lazy creation avoids the
        overhead of ``A2aLocalFacade()`` construction when the
        facade is never needed (e.g., benchmarks).
        """
        if self._facade is None:
            self._facade = A2aLocalFacade()
        return self._facade

    @property
    def is_running(self) -> bool:
        """Whether the server event loop is currently active."""
        return self._running

    # ------------------------------------------------------------------
    # Transport layer
    # ------------------------------------------------------------------

    def _drain_headers(self) -> None:
        """Consume remaining header lines up to the blank terminator.

        Called after an early-exit error during header parsing
        (invalid Content-Length, too many header lines) so that the
        stream is left positioned at the start of the next message.
        Without this drain, unconsumed header bytes would be
        misinterpreted as headers of subsequent messages, causing a
        cascading transport desync.

        A secondary line-count cap (``MAX_HEADER_LINES``) prevents
        a malicious client from keeping the server in this drain
        loop indefinitely by streaming infinite non-blank lines
        after the primary guard fires.
        """
        for _ in range(MAX_HEADER_LINES):
            raw_line = self._input.readline(MAX_HEADER_LINE_LENGTH)
            if not raw_line:
                return
            line = raw_line.decode("utf-8", errors="replace").rstrip("\r\n")
            if line == "":
                return

    def _discard_body(self, content_length: int) -> None:
        """Read and discard body bytes from the input stream.

        Called when a message is rejected after headers are fully
        parsed (oversized payload) so that the body bytes do not
        corrupt the framing of subsequent messages.

        The number of bytes actually discarded is capped at
        :data:`MAX_CONTENT_LENGTH` to prevent a malicious client
        from blocking the server with an absurdly large declared
        ``Content-Length`` (e.g. 10 TB).  Beyond that cap the
        stream framing is unrecoverable anyway.
        """
        remaining = min(content_length, MAX_CONTENT_LENGTH)
        while remaining > 0:
            chunk = self._input.read(min(remaining, 65536))
            if not chunk:
                break
            remaining -= len(chunk)

    def _read_message(self) -> Any | None:
        """Read a single JSON-RPC message using Content-Length framing.

        Returns the parsed JSON value (typically a dict, but may be
        any JSON-decoded type such as a list or scalar), ``None`` on
        true EOF, or the :data:`_SKIP` sentinel for recoverable
        transport errors (bad framing, oversized payload, malformed
        JSON).  The sentinel allows the run loop to drop the current
        message and continue reading the next one rather than
        terminating the server.

        On recoverable errors the method drains unconsumed header
        and/or body bytes so that the stream is correctly positioned
        for the next message, preventing transport desync.
        """
        content_length: int | None = None
        header_lines_read = 0

        while True:
            raw_line = self._input.readline(MAX_HEADER_LINE_LENGTH)
            if not raw_line:
                return None
            header_lines_read += 1
            if header_lines_read > MAX_HEADER_LINES:
                logger.warning(
                    "lsp.transport.too_many_header_lines",
                    count=header_lines_read,
                    max_allowed=MAX_HEADER_LINES,
                )
                self._drain_headers()
                return _SKIP
            line = raw_line.decode("utf-8", errors="replace").rstrip("\r\n")
            if line == "":
                break
            if line.lower().startswith("content-length:"):
                try:
                    content_length = int(line.split(":", 1)[1].strip())
                except (ValueError, IndexError):
                    logger.warning("lsp.transport.invalid_content_length", raw=line)
                    self._drain_headers()
                    return _SKIP

        if content_length is None:
            logger.warning("lsp.transport.missing_content_length")
            return _SKIP

        if content_length < 0:
            logger.warning(
                "lsp.transport.negative_content_length",
                content_length=content_length,
            )
            return _SKIP

        if content_length > MAX_CONTENT_LENGTH:
            logger.warning(
                "lsp.transport.content_length_exceeded",
                content_length=content_length,
                max_allowed=MAX_CONTENT_LENGTH,
            )
            self._discard_body(content_length)
            return _SKIP

        # SEC: ``read(content_length)`` blocks until exactly
        # ``content_length`` bytes arrive or EOF.  A malicious client
        # that sends a valid Content-Length but stalls mid-body will
        # block the server indefinitely.  MAX_CONTENT_LENGTH limits
        # memory but not time.  For this stub the caller (IDE /
        # development harness) is trusted; when evolving to
        # production, wrap the read in ``select()`` / ``asyncio``
        # with a per-message timeout.
        data = self._input.read(content_length)
        if len(data) < content_length:
            logger.warning(
                "lsp.transport.incomplete_read",
                expected=content_length,
                got=len(data),
            )
            return _SKIP

        try:
            parsed: Any = json.loads(data)
        except json.JSONDecodeError as exc:
            logger.warning("lsp.transport.json_parse_error", error=str(exc))
            self._write_message(
                self._make_error_response(None, _PARSE_ERROR, f"Parse error: {exc}")
            )
            return _SKIP
        except RecursionError:
            logger.warning(
                "lsp.transport.json_recursion_error",
                size=len(data),
            )
            self._write_message(
                self._make_error_response(
                    None, _PARSE_ERROR, "JSON nesting depth exceeded"
                )
            )
            return _SKIP

        return parsed

    def _write_message(self, body: dict[str, Any]) -> bool:
        """Write a JSON-RPC response with Content-Length framing.

        Returns ``True`` on success, ``False`` if the output stream
        is broken (e.g. closed pipe).  The caller should stop the
        event loop when ``False`` is returned.
        """
        payload = json.dumps(body, separators=(",", ":")).encode("utf-8")
        header = f"Content-Length: {len(payload)}\r\n\r\n".encode()
        try:
            self._output.write(header + payload)
            self._output.flush()
        except OSError:
            logger.warning("lsp.transport.write_error")
            return False
        return True

    # ------------------------------------------------------------------
    # JSON-RPC helpers
    # ------------------------------------------------------------------

    def _make_response(
        self,
        request_id: int | str | None,
        result: Any,
    ) -> dict[str, Any]:
        """Build a JSON-RPC success response."""
        return {"jsonrpc": "2.0", "id": request_id, "result": result}

    def _make_error_response(
        self,
        request_id: int | str | None,
        code: int,
        message: str,
        data: Any = None,
    ) -> dict[str, Any]:
        """Build a JSON-RPC error response."""
        error: dict[str, Any] = {"code": code, "message": message}
        if data is not None:
            error["data"] = data
        return {"jsonrpc": "2.0", "id": request_id, "error": error}

    # ------------------------------------------------------------------
    # Method dispatch
    # ------------------------------------------------------------------

    def _handle_message(self, msg: Any) -> dict[str, Any] | None:
        """Dispatch a parsed JSON-RPC message to the appropriate handler.

        *msg* may be any JSON-decoded value (``json.loads`` can
        return lists, strings, etc.).  Non-dict values are rejected
        with ``InvalidRequest``.

        Returns a response dict, or ``None`` for notifications (no id).
        """
        if not isinstance(msg, dict):
            return self._make_error_response(
                None, _INVALID_REQUEST, "Invalid JSON-RPC message"
            )

        jsonrpc = msg.get("jsonrpc")
        if jsonrpc != "2.0":
            return self._make_error_response(
                msg.get("id"),
                _INVALID_REQUEST,
                "Invalid or missing jsonrpc version",
            )

        method = msg.get("method")
        if not isinstance(method, str):
            return self._make_error_response(
                msg.get("id"),
                _INVALID_REQUEST,
                "Missing or invalid method field",
            )

        request_id = msg.get("id")
        params = msg.get("params", {})
        if not isinstance(params, dict):
            params = {}

        result = self._dispatch_stub(method, params, request_id)
        return result

    def _dispatch_stub(
        self,
        method: str,
        params: dict[str, Any],
        request_id: int | str | None,
    ) -> dict[str, Any] | None:
        """Dispatch LSP methods to hardcoded stub handlers.

        The :attr:`_facade` is stored for future server-mode wiring
        but is **not called** in the current stub implementation.
        When server mode lands this method should route through the
        facade instead of the hardcoded ``if`` chain below.

        .. todo:: Wire dispatch through ``self._facade`` when server
           mode is implemented (see M7+ roadmap).
        """
        logger.debug("lsp.dispatch", method=method, request_id=request_id, mode="stub")

        # After shutdown only ``exit`` is accepted (LSP spec §3.16).
        if self._shutdown_requested and method != "exit":
            if request_id is None:
                return None
            return self._make_error_response(
                request_id,
                _INVALID_REQUEST,
                "Server is shutting down",
            )

        if method == "initialize":
            return self._handle_initialize(request_id, params)
        if method == "exit":
            self._handle_exit()
            return None

        # Before ``initialize`` is received, requests must receive
        # ``ServerNotInitialized`` (-32002) per LSP specification.
        # Only ``initialize`` and ``exit`` are exempt from this
        # guard — ``shutdown`` is a normal request and must be
        # rejected when the server has not been initialized.
        if not self._initialized and request_id is not None:
            return self._make_error_response(
                request_id,
                SERVER_NOT_INITIALIZED,
                f"Server not initialized: {method}",
            )

        if method == "shutdown":
            return self._handle_shutdown(request_id)

        # Notification (no id) — silently ignore
        if request_id is None:
            logger.info("lsp.notification.ignored", method=method)
            return None

        return self._make_error_response(
            request_id,
            _METHOD_NOT_FOUND,
            f"Not implemented: {method}",
        )

    # ------------------------------------------------------------------
    # LSP lifecycle handlers
    # ------------------------------------------------------------------

    def _handle_initialize(
        self,
        request_id: int | str | None,
        params: dict[str, Any],
    ) -> dict[str, Any]:
        """Handle the ``initialize`` request.

        Returns a stubbed ``InitializeResult`` with server info and
        capabilities.  A second ``initialize`` request is rejected
        with ``InvalidRequest`` per the LSP specification.
        """
        if self._initialized:
            logger.warning("lsp.initialize.duplicate", pid=os.getpid())
            return self._make_error_response(
                request_id,
                _INVALID_REQUEST,
                "Server already initialized",
            )
        logger.info(
            "lsp.initialize",
            client_info=params.get("clientInfo"),
            pid=os.getpid(),
        )
        # NOTE: ``_initialized`` is set upon receiving the ``initialize``
        # *request*, not the subsequent ``initialized`` *notification*.
        # Per a strict LSP reading the server should not fully activate
        # until the ``initialized`` notification arrives, but for a stub
        # this is acceptable.  Track this deviation when evolving into
        # the real server (see M7+ roadmap).
        self._initialized = True
        result: dict[str, Any] = {
            "capabilities": dict(_STUBBED_CAPABILITIES),
            "serverInfo": {
                "name": _SERVER_NAME,
                "version": _SERVER_VERSION,
            },
        }
        return self._make_response(request_id, result)

    def _handle_shutdown(
        self,
        request_id: int | str | None,
    ) -> dict[str, Any]:
        """Handle the ``shutdown`` request.

        Acknowledges and prepares for exit.
        """
        logger.info("lsp.shutdown", pid=os.getpid())
        self._shutdown_requested = True
        return self._make_response(request_id, None)

    def _handle_exit(self) -> None:
        """Handle the ``exit`` notification.

        Terminates the server process.  The final exit code is
        computed by :meth:`run` after the event loop ends (single
        source of truth based on ``_shutdown_requested``).
        """
        logger.info(
            "lsp.exit",
            code=0 if self._shutdown_requested else 1,
            pid=os.getpid(),
        )
        self._running = False

    # ------------------------------------------------------------------
    # Main event loop
    # ------------------------------------------------------------------

    def run(self) -> int:
        """Start the LSP server event loop.

        Reads JSON-RPC messages from stdin and writes responses to
        stdout until an ``exit`` notification is received or stdin
        is closed.

        Returns:
            Exit code: 0 if shutdown was requested before exit, 1
            otherwise.
        """
        pid = os.getpid()
        logger.info("lsp.server.starting", pid=pid, server=_SERVER_NAME)
        self._running = True

        while self._running:
            msg = self._read_message()
            if msg is None:
                logger.info("lsp.server.eof", pid=pid)
                self._running = False
                break
            if isinstance(msg, _SkipMessage):
                continue

            response = self._handle_message(msg)
            if response is not None and not self._write_message(response):
                self._running = False
                break

        exit_code = 0 if self._shutdown_requested else 1
        logger.info("lsp.server.stopped", pid=pid, exit_code=exit_code)
        return exit_code


__all__ = [
    "MAX_CONTENT_LENGTH",
    "MAX_HEADER_LINES",
    "MAX_HEADER_LINE_LENGTH",
    "SERVER_NOT_INITIALIZED",
    "LspServer",
]
