"""Thin, dependency-free HTTP client for the lean-ctx ``/v1`` contract.

Uses only the Python standard library (``urllib``) so it installs and runs
anywhere with no transitive dependencies. It speaks the wire protocol only — it
never links the engine or re-implements compression — so it stays stable as
lean-ctx evolves. Mirrors the TypeScript (`lean-ctx-client`) and Rust
(`lean-ctx-client`) SDKs.
"""

from __future__ import annotations

import json
import urllib.error
import urllib.parse
import urllib.request
from typing import Any, Dict, Iterator, Optional

from .errors import LeanCtxConfigError, LeanCtxHTTPError, LeanCtxTransportError
from .tool_text import tool_result_to_text


class LeanCtxClient:
    """A blocking client for a running lean-ctx HTTP server."""

    def __init__(
        self,
        base_url: str,
        *,
        bearer_token: Optional[str] = None,
        workspace_id: Optional[str] = None,
        channel_id: Optional[str] = None,
        timeout: float = 30.0,
    ) -> None:
        trimmed = (base_url or "").strip()
        if not trimmed:
            raise LeanCtxConfigError("base_url is required")
        self.base_url = trimmed[:-1] if trimmed.endswith("/") else trimmed
        self._bearer_token = (bearer_token or "").strip() or None
        self._workspace_id = (workspace_id or "").strip() or None
        self._channel_id = (channel_id or "").strip() or None
        self._timeout = timeout

    # -- discovery ---------------------------------------------------------

    def health(self) -> str:
        return self._request_text("GET", "/health")

    def manifest(self) -> Any:
        return self._get_json("/v1/manifest")

    def capabilities(self) -> Dict[str, Any]:
        return self._get_json("/v1/capabilities")

    def openapi(self) -> Dict[str, Any]:
        return self._get_json("/v1/openapi.json")

    # -- tools -------------------------------------------------------------

    def list_tools(
        self, *, offset: Optional[int] = None, limit: Optional[int] = None
    ) -> Dict[str, Any]:
        query: Dict[str, str] = {}
        if offset is not None:
            query["offset"] = str(offset)
        if limit is not None:
            query["limit"] = str(limit)
        return self._get_json("/v1/tools", query)

    def call_tool(
        self,
        name: str,
        arguments: Optional[Dict[str, Any]] = None,
        *,
        workspace_id: Optional[str] = None,
        channel_id: Optional[str] = None,
    ) -> Any:
        """Call a tool and return its raw ``result``."""
        if not name:
            raise LeanCtxConfigError("tool name is required")
        if arguments is not None and not isinstance(arguments, dict):
            raise LeanCtxConfigError("arguments must be a dict (JSON object)")

        body: Dict[str, Any] = {"name": name}
        if arguments is not None:
            body["arguments"] = arguments
        ws = (workspace_id or "").strip() or self._workspace_id
        ch = (channel_id or "").strip() or self._channel_id
        if ws:
            body["workspaceId"] = ws
        if ch:
            body["channelId"] = ch

        payload = self._request_json(
            "POST", "/v1/tools/call", body=body, workspace_id=ws
        )
        if not isinstance(payload, dict):
            raise LeanCtxConfigError("call_tool: unexpected response shape")
        return payload.get("result")

    def call_tool_text(
        self,
        name: str,
        arguments: Optional[Dict[str, Any]] = None,
        **ctx: Any,
    ) -> str:
        """Call a tool and flatten its result into text."""
        return tool_result_to_text(self.call_tool(name, arguments, **ctx))

    # -- context views -------------------------------------------------------

    def context_summary(
        self,
        *,
        workspace_id: Optional[str] = None,
        channel_id: Optional[str] = None,
        limit: Optional[int] = None,
    ) -> Dict[str, Any]:
        """Materialized workspace/channel summary (``GET /v1/context/summary``)."""
        query: Dict[str, str] = {}
        ws = (workspace_id or "").strip() or self._workspace_id
        ch = (channel_id or "").strip() or self._channel_id
        if ws:
            query["workspaceId"] = ws
        if ch:
            query["channelId"] = ch
        if limit is not None:
            query["limit"] = str(limit)
        return self._get_json("/v1/context/summary", query)

    def search_events(
        self,
        q: str,
        *,
        workspace_id: Optional[str] = None,
        channel_id: Optional[str] = None,
        limit: Optional[int] = None,
    ) -> Dict[str, Any]:
        """Full-text search over event payloads (``GET /v1/events/search``)."""
        if not q:
            raise LeanCtxConfigError("search query is required")
        query: Dict[str, str] = {"q": q}
        ws = (workspace_id or "").strip() or self._workspace_id
        ch = (channel_id or "").strip() or self._channel_id
        if ws:
            query["workspaceId"] = ws
        if ch:
            query["channelId"] = ch
        if limit is not None:
            query["limit"] = str(limit)
        return self._get_json("/v1/events/search", query)

    def event_lineage(
        self,
        event_id: int,
        *,
        depth: Optional[int] = None,
        workspace_id: Optional[str] = None,
    ) -> Dict[str, Any]:
        """Causal lineage chain for an event (``GET /v1/events/lineage``)."""
        query: Dict[str, str] = {"id": str(event_id)}
        if depth is not None:
            query["depth"] = str(depth)
        ws = (workspace_id or "").strip() or self._workspace_id
        if ws:
            query["workspaceId"] = ws
        return self._get_json("/v1/events/lineage", query)

    def metrics(self) -> Dict[str, Any]:
        """JSON metrics snapshot (``GET /v1/metrics``)."""
        return self._get_json("/v1/metrics")

    # -- events (SSE) ------------------------------------------------------

    def subscribe_events(
        self,
        *,
        workspace_id: Optional[str] = None,
        channel_id: Optional[str] = None,
        since: Optional[int] = None,
        limit: Optional[int] = None,
    ) -> Iterator[Dict[str, Any]]:
        """Yield ``ContextEventV1`` dicts from the SSE stream until the server closes it."""
        ws = (workspace_id or "").strip() or self._workspace_id
        ch = (channel_id or "").strip() or self._channel_id
        query: Dict[str, str] = {}
        if ws:
            query["workspaceId"] = ws
        if ch:
            query["channelId"] = ch
        if since is not None:
            query["since"] = str(since)
        if limit is not None:
            query["limit"] = str(limit)

        req = self._build_request(
            "GET", "/v1/events", query, accept="text/event-stream", workspace_id=ws
        )
        try:
            resp = urllib.request.urlopen(req, timeout=self._timeout)
        except urllib.error.HTTPError as exc:
            raise self._http_error_from(exc, "GET", "/v1/events") from exc
        except urllib.error.URLError as exc:
            raise LeanCtxTransportError(str(exc.reason)) from exc

        with resp:
            buf = ""
            for raw in resp:
                buf += raw.decode("utf-8", "replace")
                while "\n\n" in buf:
                    chunk, buf = buf.split("\n\n", 1)
                    data = _parse_sse_data(chunk)
                    if data is None:
                        continue
                    try:
                        event = json.loads(data)
                    except json.JSONDecodeError:
                        continue
                    if isinstance(event, dict):
                        yield event

    def events_probe(self) -> str:
        """Open ``GET /v1/events`` and return its ``Content-Type`` header.

        Returns as soon as response headers arrive (the body is never read),
        so it cannot block on an idle stream. Used by the conformance kit to
        prove the SSE endpoint exists and speaks ``text/event-stream``.
        """
        req = self._build_request(
            "GET", "/v1/events", {"limit": "1"}, accept="text/event-stream"
        )
        try:
            resp = urllib.request.urlopen(req, timeout=self._timeout)
        except urllib.error.HTTPError as exc:
            raise self._http_error_from(exc, "GET", "/v1/events") from exc
        except urllib.error.URLError as exc:
            raise LeanCtxTransportError(str(exc.reason)) from exc
        with resp:
            return resp.headers.get("content-type", "") or ""

    # -- internals ---------------------------------------------------------

    def _get_json(self, path: str, query: Optional[Dict[str, str]] = None) -> Any:
        return self._request_json("GET", path, query=query)

    def _request_text(self, method: str, path: str) -> str:
        req = self._build_request(method, path, accept="text/plain")
        return self._send(req, method, path).decode("utf-8", "replace")

    def _request_json(
        self,
        method: str,
        path: str,
        *,
        query: Optional[Dict[str, str]] = None,
        body: Optional[Dict[str, Any]] = None,
        workspace_id: Optional[str] = None,
    ) -> Any:
        req = self._build_request(
            method,
            path,
            query,
            accept="application/json",
            body=body,
            workspace_id=workspace_id,
        )
        raw = self._send(req, method, path)
        if not raw:
            return None
        return json.loads(raw.decode("utf-8", "replace"))

    def _build_request(
        self,
        method: str,
        path: str,
        query: Optional[Dict[str, str]] = None,
        *,
        accept: str,
        body: Optional[Dict[str, Any]] = None,
        workspace_id: Optional[str] = None,
    ) -> urllib.request.Request:
        url = self.base_url + path
        if query:
            url += "?" + urllib.parse.urlencode(query)
        data = None
        headers = {"Accept": accept}
        if body is not None:
            data = json.dumps(body).encode("utf-8")
            headers["Content-Type"] = "application/json"
        if self._bearer_token:
            headers["Authorization"] = f"Bearer {self._bearer_token}"
        if workspace_id:
            headers["x-leanctx-workspace"] = workspace_id
        return urllib.request.Request(url, data=data, headers=headers, method=method)

    def _send(self, req: urllib.request.Request, method: str, path: str) -> bytes:
        try:
            with urllib.request.urlopen(req, timeout=self._timeout) as resp:
                return resp.read()
        except urllib.error.HTTPError as exc:
            raise self._http_error_from(exc, method, path) from exc
        except urllib.error.URLError as exc:
            raise LeanCtxTransportError(str(exc.reason)) from exc

    def _http_error_from(
        self, exc: urllib.error.HTTPError, method: str, path: str
    ) -> LeanCtxHTTPError:
        url = self.base_url + path
        message = f"HTTP {exc.code} {method} {url}"
        error_code = None
        body: Any = None
        try:
            raw = exc.read()
        except Exception:  # pragma: no cover - defensive
            raw = b""
        content_type = exc.headers.get("content-type", "") if exc.headers else ""
        if raw:
            if "application/json" in content_type:
                try:
                    body = json.loads(raw.decode("utf-8", "replace"))
                    if isinstance(body, dict):
                        if isinstance(body.get("error"), str) and body["error"].strip():
                            message = body["error"]
                        if isinstance(body.get("error_code"), str):
                            error_code = body["error_code"].strip() or None
                except json.JSONDecodeError:
                    body = raw.decode("utf-8", "replace")
            else:
                text = raw.decode("utf-8", "replace")
                body = text
                if text.strip():
                    message = text.strip()
        return LeanCtxHTTPError(
            status=exc.code,
            method=method,
            url=url,
            message=message,
            error_code=error_code,
            body=body,
        )


def _parse_sse_data(chunk: str) -> Optional[str]:
    """Join the ``data:`` lines of one SSE frame; ignore id/event/comments."""
    data_lines = []
    for line in chunk.split("\n"):
        line = line.rstrip("\r")
        if not line or line.startswith(":"):
            continue
        if line.startswith("data:"):
            data_lines.append(line[5:].lstrip())
    return "\n".join(data_lines) if data_lines else None
