"""LSP protocol client for communicating with language servers.

Implements the client side of the LSP protocol over a
:class:`StdioTransport`.  Handles:

- ``initialize`` / ``initialized`` handshake.
- ``shutdown`` / ``exit`` lifecycle.
- ``textDocument/publishDiagnostics`` notification handling.
- ``textDocument/completion`` requests.
- ``textDocument/didOpen`` / ``textDocument/didClose`` notifications.
- Request ID tracking and response correlation.

The client is **synchronous** — each request blocks until the response
arrives.  Notifications received while waiting for a response are
queued and processed later.

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

from __future__ import annotations

import contextlib
import threading
import time
from collections import deque
from typing import Any

import structlog

from cleveragents.lsp.errors import LspError
from cleveragents.lsp.transport import StdioTransport

logger = structlog.get_logger(__name__)

# Default timeout for a single JSON-RPC request/response cycle.
_REQUEST_TIMEOUT = 60.0


class LspClient:
    """Synchronous LSP protocol client.

    Wraps a :class:`StdioTransport` and provides typed methods for
    the LSP protocol operations needed by the runtime.

    Args:
        transport: The stdio transport connected to the server.
        server_name: Human-readable name for logging.
    """

    def __init__(
        self,
        transport: StdioTransport,
        server_name: str = "",
    ) -> None:
        self._transport = transport
        self._server_name = server_name
        self._request_id = 0
        self._id_lock = threading.Lock()
        self._initialized = False
        self._server_capabilities: dict[str, Any] = {}
        self._diagnostics: dict[str, list[dict[str, Any]]] = {}
        self._pending_notifications: deque[dict[str, Any]] = deque(maxlen=1000)

    @property
    def is_initialized(self) -> bool:
        """Whether the initialize handshake has completed."""
        return self._initialized

    @property
    def server_capabilities(self) -> dict[str, Any]:
        """Capabilities returned by the server during initialize."""
        return self._server_capabilities

    def _next_id(self) -> int:
        with self._id_lock:
            self._request_id += 1
            return self._request_id

    def _send_request(
        self,
        method: str,
        params: dict[str, Any] | None = None,
        *,
        timeout: float = _REQUEST_TIMEOUT,
    ) -> dict[str, Any]:
        """Send a JSON-RPC request and wait for the response.

        Args:
            method: LSP method name.
            params: Optional parameters.
            timeout: Maximum seconds to wait for the response.

        Returns:
            The ``result`` field from the response.

        Raises:
            LspError: On JSON-RPC error, transport failure, or timeout.
        """
        req_id = self._next_id()
        message: dict[str, Any] = {
            "jsonrpc": "2.0",
            "id": req_id,
            "method": method,
        }
        if params is not None:
            message["params"] = params

        self._transport.send_message(message)

        deadline = time.monotonic() + timeout

        # Read messages until we get our response or deadline expires
        while time.monotonic() < deadline:
            remaining = max(0.1, deadline - time.monotonic())
            msg = self._transport.read_message(timeout=remaining)
            if msg is None:
                # Distinguish EOF (server died) from select timeout:
                # if the transport process is still alive, select just
                # timed out — retry until the outer deadline expires.
                if self._transport.is_alive:
                    continue
                raise LspError(
                    f"LSP server closed connection while waiting for "
                    f"response to '{method}' (id={req_id})",
                    details={"method": method, "id": req_id},
                )

            # Check if this is a response to our request
            if "id" in msg and msg["id"] == req_id:
                if "error" in msg:
                    err = msg["error"]
                    raise LspError(
                        f"LSP error for '{method}': {err.get('message', 'unknown')}",
                        details={
                            "method": method,
                            "code": err.get("code"),
                            "data": err.get("data"),
                        },
                    )
                return msg.get("result", {})

            # Not our response — queue as notification
            self._handle_notification(msg)

        raise LspError(
            f"Timed out waiting for response to '{method}' "
            f"(id={req_id}, timeout={timeout}s)",
            details={"method": method, "id": req_id},
        )

    def _send_notification(
        self,
        method: str,
        params: dict[str, Any] | None = None,
    ) -> None:
        """Send a JSON-RPC notification (no response expected).

        Args:
            method: LSP method name.
            params: Optional parameters.
        """
        message: dict[str, Any] = {
            "jsonrpc": "2.0",
            "method": method,
        }
        if params is not None:
            message["params"] = params
        self._transport.send_message(message)

    def _handle_notification(self, msg: dict[str, Any]) -> None:
        """Process an incoming notification from the server."""
        method = msg.get("method", "")
        params = msg.get("params", {})

        if method == "textDocument/publishDiagnostics":
            uri = params.get("uri", "")
            diagnostics = params.get("diagnostics", [])
            self._diagnostics[uri] = diagnostics
            logger.debug(
                "lsp.client.diagnostics_received",
                server=self._server_name,
                uri=uri,
                count=len(diagnostics),
            )
        else:
            self._pending_notifications.append(msg)

    # -- LSP Lifecycle -------------------------------------------------------

    def initialize(
        self,
        workspace_path: str,
        initialization_options: dict[str, Any] | None = None,
    ) -> dict[str, Any]:
        """Perform the LSP ``initialize`` handshake.

        Args:
            workspace_path: Root URI for the workspace.
            initialization_options: Server-specific init options.

        Returns:
            The server's ``InitializeResult``.

        Raises:
            LspError: If already initialized or handshake fails.
        """
        if self._initialized:
            raise LspError(
                "LSP client already initialized",
                details={"server": self._server_name},
            )

        # Convert path to file URI
        workspace_uri = workspace_path
        if not workspace_uri.startswith("file://"):
            workspace_uri = f"file://{workspace_path}"

        params: dict[str, Any] = {
            "processId": None,
            "clientInfo": {
                "name": "cleveragents",
                "version": "0.1.0",
            },
            "rootUri": workspace_uri,
            "workspaceFolders": [
                {"uri": workspace_uri, "name": "workspace"},
            ],
            "capabilities": {
                "textDocument": {
                    "publishDiagnostics": {"relatedInformation": True},
                    "completion": {
                        "completionItem": {"snippetSupport": False},
                    },
                    "hover": {"contentFormat": ["plaintext", "markdown"]},
                    "definition": {"linkSupport": False},
                    "references": {},
                    "rename": {"prepareSupport": False},
                    "codeAction": {
                        "codeActionLiteralSupport": {
                            "codeActionKind": {
                                "valueSet": [
                                    "quickfix",
                                    "refactor",
                                    "source",
                                ],
                            },
                        },
                    },
                    "formatting": {},
                    "signatureHelp": {
                        "signatureInformation": {
                            "parameterInformation": {
                                "labelOffsetSupport": True,
                            },
                        },
                    },
                    "documentSymbol": {
                        "hierarchicalDocumentSymbolSupport": True,
                    },
                    "synchronization": {
                        "didSave": True,
                        "willSave": False,
                    },
                },
                "workspace": {
                    "workspaceFolders": True,
                    "symbol": {"symbolKind": {}},
                },
            },
        }
        if initialization_options:
            params["initializationOptions"] = initialization_options

        logger.info(
            "lsp.client.initializing",
            server=self._server_name,
            workspace=workspace_path,
        )

        result = self._send_request("initialize", params)
        self._server_capabilities = result.get("capabilities", {})
        self._initialized = True

        # Send initialized notification
        self._send_notification("initialized", {})

        logger.info(
            "lsp.client.initialized",
            server=self._server_name,
            capabilities=list(self._server_capabilities.keys()),
        )

        return result

    def shutdown(self) -> None:
        """Send ``shutdown`` request followed by ``exit`` notification.

        Raises:
            LspError: If not initialized.
        """
        if not self._initialized:
            logger.warning(
                "lsp.client.shutdown_not_initialized",
                server=self._server_name,
            )
            return

        logger.info("lsp.client.shutting_down", server=self._server_name)

        try:
            self._send_request("shutdown")
        except LspError:
            logger.warning(
                "lsp.client.shutdown_error",
                server=self._server_name,
                exc_info=True,
            )

        with contextlib.suppress(BrokenPipeError, RuntimeError):
            self._send_notification("exit")

        self._initialized = False

    # -- Text Document Operations --------------------------------------------

    def did_open(
        self,
        uri: str,
        language_id: str,
        version: int,
        text: str,
    ) -> None:
        """Notify the server that a document was opened.

        Args:
            uri: Document URI (``file://...``).
            language_id: Language identifier (e.g., ``"python"``).
            version: Document version number.
            text: Full document text.
        """
        self._send_notification(
            "textDocument/didOpen",
            {
                "textDocument": {
                    "uri": uri,
                    "languageId": language_id,
                    "version": version,
                    "text": text,
                },
            },
        )

    def did_close(self, uri: str) -> None:
        """Notify the server that a document was closed.

        Args:
            uri: Document URI.
        """
        self._send_notification(
            "textDocument/didClose",
            {"textDocument": {"uri": uri}},
        )
        # Clear cached diagnostics for this URI
        self._diagnostics.pop(uri, None)

    def get_diagnostics(self, uri: str) -> list[dict[str, Any]]:
        """Return cached diagnostics for *uri*.

        Diagnostics are pushed by the server via
        ``textDocument/publishDiagnostics`` notifications and cached
        by the client.  This method returns the latest cached set.

        Args:
            uri: Document URI.

        Returns:
            List of LSP Diagnostic objects.
        """
        # Drain pending messages (capped to prevent infinite loop if
        # server is flooding notifications).
        max_drain = 200
        for _ in range(max_drain):
            msg = self._transport.read_message(timeout=0.05)
            if msg is None:
                break
            self._handle_notification(msg)

        return list(self._diagnostics.get(uri, []))

    def get_completions(
        self,
        uri: str,
        line: int,
        character: int,
    ) -> list[dict[str, Any]]:
        """Request completions at a position.

        Args:
            uri: Document URI.
            line: 0-based line number.
            character: 0-based character offset.

        Returns:
            List of CompletionItem dicts.
        """
        result = self._send_request(
            "textDocument/completion",
            {
                "textDocument": {"uri": uri},
                "position": {"line": line, "character": character},
            },
        )

        # Result may be a CompletionList or a list of CompletionItems
        if isinstance(result, dict) and "items" in result:
            return result["items"]
        if isinstance(result, list):
            return result
        return []

    def get_hover(
        self,
        uri: str,
        line: int,
        character: int,
    ) -> dict[str, Any] | None:
        """Request hover information at a position.

        Args:
            uri: Document URI.
            line: 0-based line number.
            character: 0-based character offset.

        Returns:
            Hover result dict with ``contents`` and optional ``range``,
            or ``None`` if no hover info is available.
        """
        result = self._send_request(
            "textDocument/hover",
            {
                "textDocument": {"uri": uri},
                "position": {"line": line, "character": character},
            },
        )
        if result is None:
            return None
        if isinstance(result, dict):
            return result
        return None

    def get_definitions(
        self,
        uri: str,
        line: int,
        character: int,
    ) -> list[dict[str, Any]]:
        """Request go-to-definition at a position.

        Args:
            uri: Document URI.
            line: 0-based line number.
            character: 0-based character offset.

        Returns:
            List of Location dicts with ``uri`` and ``range`` keys.
        """
        result = self._send_request(
            "textDocument/definition",
            {
                "textDocument": {"uri": uri},
                "position": {"line": line, "character": character},
            },
        )
        if result is None:
            return []
        # Result may be a single Location, a list of Locations, or
        # a list of LocationLink objects.
        if isinstance(result, dict):
            return [result]
        if isinstance(result, list):
            return result
        return []


__all__ = [
    "LspClient",
]
