"""Keiro client — low-level hosted gateway access for external Keiro users.

Usage::

    from keiro import models

    # Preferred prompt-first facade
    print(models("eb1-preview", "What is machine learning?"))

    # Lower-level HTTP client for raw chat/responses calls
    from keiro import Client
    client = Client()
    response = client.chat(
        messages=[
            {"role": "system", "content": "You are a helpful assistant."},
            {"role": "user", "content": "Explain quantum computing."},
        ],
        model="eb1-preview",
    )
    print(response["choices"][0]["message"]["content"])

    # Batch concurrent chat calls
    prompts = ["What is ML?", "What is AI?", "What is DL?"]
    results = client.batch(prompts, model="eb1-preview", max_workers=3)

The lightweight package prefers ``keiro.models`` for prompt-first testing and
keeps ``Client`` as the lower-level escape hatch. No Ember dependency
required. Credentials are read from ``~/.keiro/credentials`` (written by
``keiro setup``).
"""

from __future__ import annotations

import functools
import importlib.metadata
import json
import logging
import os
import random
import re
import time
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from typing import Any, Iterator, Mapping

import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

from keiro.credentials import load_credentials
from keiro.defaults import DEFAULT_MODEL, get_gateway_url

LOG = logging.getLogger(__name__)

# Client identification header sent with every gateway request so server-side
# logs can distinguish traffic by source (Python client vs Rust CLI vs Codex).
try:
    _KEIRO_VERSION = importlib.metadata.version("keiro")
except importlib.metadata.PackageNotFoundError:
    _KEIRO_VERSION = "dev"
_CLIENT_SOURCE = f"keiro-python/{_KEIRO_VERSION}"

# Patterns stripped from error messages before showing to users.
_ERROR_CODE_RE = re.compile(r"\[Error \d+\]\s*")
_RECOVERY_HINT_RE = re.compile(r"\s*\[Recovery:[^\]]*\]")
_TAKUMI_WORD_RE = re.compile(r"\bTakumi\b")
_TAKUMI_PREFIX_RE = re.compile(r"\btakumi/")

# Raw server error messages that carry no useful detail for users.
_GENERIC_SERVER_MESSAGES = frozenset({"server_error", "internal server error", ""})


def _sanitize_message(
    raw_message: str,
    status_code: int,
    error_type: str | None,
    retry_after: float | None,
) -> str:
    """Produce a user-facing error message from raw gateway details.

    Well-known status codes get fixed actionable messages. Everything
    else has internal patterns stripped (error codes, product names,
    recovery hints) while preserving the semantic content.

    Args:
        raw_message: The raw exception message string.
        status_code: HTTP status code.
        error_type: Error classification (e.g. ``"server_error"``).
        retry_after: Retry-After value in seconds, if any.

    Returns:
        Sanitized message string safe for end-user display.
    """
    if status_code == 401:
        return "Authentication failed. Run 'keiro setup' or set KEIRO_API_KEY."
    if status_code == 429:
        msg = "Rate limit exceeded."
        if retry_after is not None:
            msg += f" Retry in {retry_after:.0f}s."
        return msg
    # Strip internal patterns from the raw message.
    cleaned = _ERROR_CODE_RE.sub("", raw_message)
    cleaned = _RECOVERY_HINT_RE.sub("", cleaned)
    cleaned = _TAKUMI_WORD_RE.sub("Keiro", cleaned)
    cleaned = _TAKUMI_PREFIX_RE.sub("", cleaned)
    cleaned = cleaned.strip()

    if error_type == "server_error":
        base = f"Server error ({status_code})."
        # Preserve the raw message when it carries useful detail.
        if cleaned and cleaned.lower() not in _GENERIC_SERVER_MESSAGES:
            return f"{base} {cleaned}"
        return f"{base} The request could not be completed. Try again shortly."

    return cleaned or raw_message


class KeirError(Exception):
    """Error returned by the Keiro gateway.

    Attributes:
        status_code: HTTP status code from the gateway.
        error_type: Error classification string (e.g. ``"server_error"``).
        code: Machine-readable error code.
        retry_after: Seconds to wait before retrying (429 responses).
        headers: Response headers snapshot.
        user_message: Sanitized message safe to display to end users.
            Strips internal error codes, product names, and recovery
            hints. ``str(exc)`` still returns the raw message for
            programmatic and debug use.
    """

    def __init__(
        self,
        message: str,
        *,
        status_code: int,
        error_type: str | None = None,
        code: str | None = None,
        retry_after: float | None = None,
        headers: Mapping[str, str] | None = None,
    ) -> None:
        self.status_code = status_code
        self.error_type = error_type
        self.code = code
        self.retry_after = retry_after
        self.headers = dict(headers) if headers else {}
        super().__init__(message)

    @functools.cached_property
    def user_message(self) -> str:
        """Return a sanitized error message suitable for end-user display.

        Maps well-known status codes to actionable messages. For other
        errors, strips internal patterns (error codes, product names,
        recovery hints) from the raw message.
        """
        return _sanitize_message(
            str(self),
            self.status_code,
            self.error_type,
            self.retry_after,
        )

    def __repr__(self) -> str:
        fields = [f"status_code={self.status_code!r}"]
        if self.error_type:
            fields.append(f"error_type={self.error_type!r}")
        if self.code:
            fields.append(f"code={self.code!r}")
        fields.append(f"message={str(self)!r}")
        return f"KeirError({', '.join(fields)})"


class KeirValidationError(KeirError):
    """Client-side input validation error.

    Raised when arguments fail validation before any HTTP request is
    made.  Caught by ``except KeirError`` so callers need only one
    handler.
    """

    def __init__(self, message: str, *, code: str | None = None) -> None:
        super().__init__(
            message,
            status_code=400,
            error_type="validation_error",
            code=code or "invalid_argument",
        )


@dataclass(frozen=True)
class BatchFailure:
    """Single failed batch request."""

    index: int
    prompt: str
    error: Exception


class BatchError(Exception):
    """Aggregate error for ``Client.batch()`` failures."""

    def __init__(
        self,
        failures: list[BatchFailure],
        partial_results: list[str | None],
    ) -> None:
        self.failures = failures
        self.partial_results = partial_results
        count = len(failures)
        total = len(partial_results)
        lines = [f"{count} of {total} batch requests failed:"]
        for failure in failures[:5]:
            lines.append(f"  [{failure.index}] {failure.error}")
        if count > 5:
            lines.append(f"  ... and {count - 5} more")
        super().__init__("\n".join(lines))


@dataclass(frozen=True)
class RateLimitInfo:
    """Rate limit state from the x-ratelimit-* response headers."""

    limit_requests: int | None = None
    remaining_requests: int | None = None
    reset_requests: str | None = None
    limit_tokens: int | None = None
    remaining_tokens: int | None = None
    reset_tokens: str | None = None

    @classmethod
    def from_headers(cls, headers: Mapping[str, str]) -> "RateLimitInfo":
        """Parse OpenAI-compatible rate limit headers into a RateLimitInfo."""

        def _int(key: str) -> int | None:
            v = headers.get(key)
            if v is None:
                return None
            try:
                return int(v)
            except ValueError:
                return None

        return cls(
            limit_requests=_int("x-ratelimit-limit-requests"),
            remaining_requests=_int("x-ratelimit-remaining-requests"),
            reset_requests=headers.get("x-ratelimit-reset-requests"),
            limit_tokens=_int("x-ratelimit-limit-tokens"),
            remaining_tokens=_int("x-ratelimit-remaining-tokens"),
            reset_tokens=headers.get("x-ratelimit-reset-tokens"),
        )


def _parse_retry_after(value: str | None) -> float | None:
    """Parse a Retry-After header value to a float, or None if absent/invalid."""
    if not value:
        return None
    try:
        return float(value)
    except ValueError:
        return None


def extract_error_detail(resp: requests.Response) -> str:
    """Extract a human-readable error message from an error response body.

    Handles both nested (``{"error": {"message": ...}}``) and flat
    (``{"message": ...}``) formats.  Returns an empty string if no
    message can be extracted.
    """
    try:
        body = resp.json()
        err = body.get("error", {})
        if isinstance(err, dict):
            return str(err.get("message", "") or body.get("message", ""))
        return str(body.get("message", "") or (str(err) if err else ""))
    except (ValueError, AttributeError, KeyError):
        return ""


def _raise_for_error(resp: requests.Response) -> None:
    """Raise KeirError with structured info from gateway error responses.

    Handles both nested (OpenAI-compatible) and flat error formats:
    - Nested: ``{"error": {"message": ..., "type": ..., "code": ...}}``
    - Flat:   ``{"error": "...", "message": "..."}``
    """
    if resp.status_code < 400:
        return
    try:
        body = resp.json()
        err = body.get("error", {})
        if isinstance(err, dict):
            # Nested OpenAI-compatible format
            message = err.get("message", resp.text)
            error_type = err.get("type")
            code = err.get("code")
        else:
            # Flat format: {"error": "type_string", "message": "..."}
            message = body.get("message", str(err) or resp.text)
            error_type = str(err) if err else None
            code = str(err) if err else None
    except (ValueError, KeyError):
        message = resp.text or f"HTTP {resp.status_code}"
        error_type = None
        code = None
    retry_after = _parse_retry_after(resp.headers.get("Retry-After"))
    raise KeirError(
        message,
        status_code=resp.status_code,
        error_type=error_type,
        code=code,
        retry_after=retry_after,
        headers=resp.headers,
    )


def _build_session(retries: int = 3, pool_maxsize: int = 100) -> requests.Session:
    """Build a requests session with retry on transient failures.

    Only retries on 429 (rate limit) and 503/504 (transient server issues).
    Does NOT retry on 500/502 because those indicate upstream provider
    failures that retrying won't resolve.
    """
    session = requests.Session()
    retry = Retry(
        total=retries,
        backoff_factor=1.0,
        status_forcelist=[429, 503, 504],
        allowed_methods=["POST", "GET"],
        respect_retry_after_header=True,
    )
    adapter = HTTPAdapter(
        max_retries=retry,
        pool_connections=pool_maxsize,
        pool_maxsize=pool_maxsize,
    )
    session.mount("http://", adapter)
    session.mount("https://", adapter)
    return session


# EB1 ensemble runs 3+ models in parallel + judge synthesis.
# 120s is too short for complex queries. Match defaults.yaml read_standard.
_DEFAULT_TIMEOUT: float = 600.0

# Transient HTTP statuses worth retrying on the streaming path.
# 429 is handled by urllib3's pre-body retry; 500 is a server bug
# (retrying won't help).  502/503/504 are gateway-level failures
# that are often transient.
_STREAMING_RETRYABLE_STATUSES: frozenset[int] = frozenset({502, 503, 504})

_MAX_RETRY_BACKOFF_S: float = 120.0

# Pre-compiled pattern for extracting HTTP status codes from
# ChunkedEncodingError messages like "got length b'HTTP/1.1 502 ...'"
_HTTP_STATUS_RE = re.compile(r"HTTP/\d\.\d (\d{3})")


def _compute_backoff(
    attempt: int,
    *,
    base_delay: float = 1.0,
    max_delay: float = 120.0,
    multiplier: float = 2.0,
    jitter_factor: float = 0.5,
) -> float:
    """Exponential backoff with proportional jitter.

    Mirrors ``ember._internal.backoff.compute_backoff`` but is inlined
    because the standalone keiro package has no dependency on ember.

    Args:
        attempt: 1-based attempt number (1 = first retry).
        base_delay: Initial delay in seconds.
        max_delay: Upper bound on the delay.
        multiplier: Exponential growth factor per attempt.
        jitter_factor: Fraction of delay used as jitter range.

    Returns:
        Delay in seconds before the next attempt.
    """
    delay = min(base_delay * (multiplier ** (attempt - 1)), max_delay)
    delay += random.uniform(0, delay * jitter_factor)
    return delay


class Client:
    """Keiro API client with retry, streaming, and batch support.

    Args:
        api_key: API key. Falls back to ``KEIRO_API_KEY`` env var,
            then ``~/.keiro/credentials``.
        base_url: Gateway URL. Falls back to ``KEIRO_BASE_URL`` env var,
            then ``~/.keiro/credentials``, then the default gateway URL.
        timeout: Request timeout in seconds. Default 600s (10 min) to
            accommodate EB1 ensemble latency on complex queries.
        retries: Number of automatic retries on transient errors (429, 5xx).
        pool_maxsize: Maximum size of the underlying HTTP connection pool.
    """

    def __init__(
        self,
        *,
        api_key: str | None = None,
        base_url: str | None = None,
        admin_token: str | None = None,
        timeout: float = _DEFAULT_TIMEOUT,
        retries: int = 3,
        pool_maxsize: int = 100,
    ) -> None:
        resolved_key, resolved_url = _resolve_credentials(api_key, base_url)
        self._api_key = resolved_key
        self._base_url = resolved_url
        self._admin_token = admin_token
        self._timeout = timeout
        self._retries = retries
        self._session = _build_session(retries, pool_maxsize)
        self._last_rate_limits: RateLimitInfo | None = None
        self._last_admin_status: bool | None = None

    @property
    def rate_limits(self) -> RateLimitInfo | None:
        """Rate limit info from the most recent request.

        Updated after each ``chat()``, ``chat_stream()``,
        ``responses()``, ``responses_stream()``, or ``get_models()``
        call. Not thread-safe across concurrent calls; use per-response
        headers for per-call accuracy in multi-threaded scenarios.
        """
        return self._last_rate_limits

    @property
    def admin_status(self) -> bool | None:
        """Admin access status from the most recent request.

        Returns True if the server granted admin access, False if
        unavailable, or None if the server did not send the header
        (older server version).
        """
        return self._last_admin_status

    def close(self) -> None:
        """Close the underlying HTTP session."""
        self._session.close()

    def __enter__(self) -> "Client":
        return self

    def __exit__(self, *_args: object) -> None:
        self.close()

    def _headers(self) -> dict[str, str]:
        headers = {
            "Authorization": f"Bearer {self._api_key}",
            "Content-Type": "application/json",
            "X-Client-Source": _CLIENT_SOURCE,
        }
        if self._admin_token:
            headers["X-Admin-Token"] = self._admin_token
        return headers

    def _url(self, path: str) -> str:
        return _build_url(self._base_url, path)

    def _record_rate_limits(self, resp: requests.Response) -> None:
        """Capture rate limit and admin status headers from the response."""
        self._last_rate_limits = RateLimitInfo.from_headers(resp.headers)
        self._record_admin_status(resp.headers)

    def _record_admin_status(self, headers: Mapping[str, str]) -> None:
        """Parse ``X-Keiro-Admin`` header into a boolean status."""
        value = headers.get("X-Keiro-Admin")
        if value is None:
            self._last_admin_status = None
        else:
            self._last_admin_status = value.strip().lower() == "granted"

    def _request(
        self,
        method: str,
        url: str,
        *,
        timeout: float,
        **kwargs: Any,
    ) -> requests.Response:
        """Issue an HTTP request, converting connection errors to KeirError.

        Wraps ``session.post()``/``session.get()`` so that transport-level
        failures (DNS, TCP, TLS, timeouts) surface as ``KeirError``
        instead of raw ``requests`` exceptions.
        """
        fn = self._session.post if method == "POST" else self._session.get
        try:
            return fn(url, timeout=timeout, **kwargs)
        except (
            requests.exceptions.ConnectionError,
            requests.exceptions.ChunkedEncodingError,
            requests.exceptions.Timeout,
        ) as exc:
            raise _connection_error_to_keir_error(exc, url=url) from exc

    def _stream_with_retry(
        self,
        make_stream: Callable[[], Iterator[dict[str, Any]]],
    ) -> Iterator[dict[str, Any]]:
        """Wrap a streaming request with application-level retry.

        urllib3's ``Retry`` only works before the response body is
        consumed. Once the server sends 200 OK with chunked encoding,
        a mid-stream failure is fatal at the transport layer. This
        method catches those failures and retries the entire request.

        Warning:
            If the caller consumed some yielded events before the
            failure, the retry re-yields from the beginning. Callers
            that accumulate partial output must handle deduplication.
        """
        last_error: KeirError | None = None
        for attempt in range(1 + self._retries):
            try:
                yield from make_stream()
                return
            except KeirError as exc:
                if exc.status_code not in _STREAMING_RETRYABLE_STATUSES:
                    raise
                last_error = exc
                if attempt < self._retries:
                    # Honor server's Retry-After when available —
                    # prevents thundering herd on overloaded gateways.
                    server_hint = exc.retry_after
                    if server_hint:
                        capped = min(server_hint, _MAX_RETRY_BACKOFF_S)
                        wait = capped + random.uniform(
                            0,
                            capped * 0.25,
                        )
                    else:
                        wait = _compute_backoff(
                            attempt + 1,
                            max_delay=_MAX_RETRY_BACKOFF_S,
                        )
                    LOG.warning(
                        "Streaming attempt %d/%d failed (HTTP %d), retrying in %.1fs%s",
                        attempt + 1,
                        1 + self._retries,
                        exc.status_code,
                        wait,
                        f" (server Retry-After: {server_hint:.0f}s)" if server_hint else "",
                    )
                    time.sleep(wait)
        if last_error is not None:
            raise last_error

    def chat(
        self,
        messages: list[dict[str, Any]],
        *,
        model: str = DEFAULT_MODEL,
        stream: bool = False,
        timeout: float | None = None,
        **kwargs: Any,
    ) -> dict[str, Any]:
        """Send a chat completion request.

        Args:
            messages: Conversation messages in OpenAI format
                (``[{"role": "user", "content": "..."}]``).
            model: Model name (default ``DEFAULT_MODEL``).
            stream: If True, returns after collecting the full streamed
                response. Use ``chat_stream()`` for incremental chunks.
            timeout: Override per-request timeout.
            **kwargs: Additional API parameters (``max_tokens``,
                ``temperature``, etc.).

        Returns:
            Chat completion response dict with ``choices``, ``usage``, etc.

        Raises:
            KeirError: On gateway errors (4xx/5xx).
            KeirError: 401 if no valid API key is configured.
        """
        payload: dict[str, Any] = {
            "model": model,
            "messages": messages,
            **kwargs,
        }
        if stream:
            return self._collect_stream(payload, timeout)

        resp = self._request(
            "POST",
            self._url("/v1/chat/completions"),
            headers=self._headers(),
            json=payload,
            timeout=timeout or self._timeout,
        )
        _raise_for_error(resp)
        self._record_rate_limits(resp)
        return resp.json()

    def chat_stream(
        self,
        messages: list[dict[str, Any]],
        *,
        model: str = DEFAULT_MODEL,
        timeout: float | None = None,
        **kwargs: Any,
    ) -> Iterator[dict[str, Any]]:
        """Stream a chat completion, yielding incremental chunks.

        Args:
            messages: Conversation messages.
            model: Model name.
            timeout: Override timeout.
            **kwargs: Additional API parameters.

        Yields:
            SSE chunk dicts. The final chunk has ``finish_reason`` set.

        Raises:
            KeirError: On gateway errors (retries transient failures).
        """
        payload: dict[str, Any] = {
            "model": model,
            "messages": messages,
            "stream": True,
            **kwargs,
        }

        def _attempt() -> Iterator[dict[str, Any]]:
            resp = self._request(
                "POST",
                self._url("/v1/chat/completions"),
                headers=self._headers(),
                json=payload,
                timeout=timeout or self._timeout,
                stream=True,
            )
            _raise_for_error(resp)
            self._record_rate_limits(resp)
            yield from _iter_parsed_sse(resp)

        yield from self._stream_with_retry(_attempt)

    def responses(
        self,
        payload: Mapping[str, Any],
        *,
        timeout: float | None = None,
    ) -> dict[str, Any]:
        """Send a Responses API request and return the JSON payload."""
        if not isinstance(payload, Mapping):
            raise KeirValidationError(
                f"'payload' must be a dict or mapping, got {type(payload).__name__}.",
                code="invalid_payload_type",
            )

        resp = self._request(
            "POST",
            self._url("/v1/responses"),
            headers=self._headers(),
            json=dict(payload),
            timeout=timeout or self._timeout,
        )
        _raise_for_error(resp)
        self._record_rate_limits(resp)
        return resp.json()

    def responses_stream(
        self,
        payload: Mapping[str, Any],
        *,
        timeout: float | None = None,
    ) -> Iterator[dict[str, Any]]:
        """Stream a Responses API request, yielding parsed SSE events."""
        if not isinstance(payload, Mapping):
            raise KeirValidationError(
                f"'payload' must be a dict or mapping, got {type(payload).__name__}.",
                code="invalid_payload_type",
            )

        body = {**dict(payload), "stream": True}

        def _attempt() -> Iterator[dict[str, Any]]:
            resp = self._request(
                "POST",
                self._url("/v1/responses"),
                headers=self._headers(),
                json=body,
                timeout=timeout or self._timeout,
                stream=True,
            )
            _raise_for_error(resp)
            self._record_rate_limits(resp)
            yield from _iter_parsed_sse(resp)

        yield from self._stream_with_retry(_attempt)

    def get_models(
        self,
        *,
        provider: str | None = None,
        status: str | None = None,
        legacy: bool = False,
        timeout: float | None = None,
    ) -> dict[str, Any]:
        """Fetch the gateway model catalog."""
        params: dict[str, str] = {}
        if provider:
            params["provider"] = provider
        if status:
            params["status"] = status
        if legacy:
            params["legacy"] = "true"

        resp = self._request(
            "GET",
            self._url("/v1/models"),
            headers=self._headers(),
            params=params or None,
            timeout=timeout or self._timeout,
        )
        _raise_for_error(resp)
        self._record_rate_limits(resp)
        return resp.json()

    def batch(
        self,
        prompts: list[str],
        *,
        model: str = DEFAULT_MODEL,
        system: str | None = None,
        max_workers: int = 5,
        timeout: float | None = None,
        raise_on_error: bool = True,
        **kwargs: Any,
    ) -> list[str | None]:
        """Run multiple prompts concurrently and return responses.

        Args:
            prompts: List of user prompts.
            model: Model name applied to all requests.
            system: Optional system prompt applied to all requests.
            max_workers: Maximum concurrent requests.
            timeout: Per-request timeout override.
            **kwargs: Additional API parameters.

        Returns:
            List of response texts, in the same order as prompts.
            When ``raise_on_error=False``, failed requests return ``None`` at
            their index. Otherwise a ``BatchError`` is raised with partial
            results and structured failure details.
        """
        results: list[str | None] = [None] * len(prompts)
        failures: list[BatchFailure] = []

        def _call(index: int, prompt: str) -> tuple[int, str | None, Exception | None]:
            messages = _build_messages(prompt, system=system)
            try:
                resp = self.chat(messages, model=model, timeout=timeout, **kwargs)
                text = resp["choices"][0]["message"]["content"]
                return (index, text, None)
            except Exception as exc:
                return (index, None, exc)

        workers = min(max_workers, len(prompts))
        with ThreadPoolExecutor(max_workers=workers) as pool:
            futures = {pool.submit(_call, i, p): i for i, p in enumerate(prompts)}
            for future in as_completed(futures):
                idx, text, error = future.result()
                results[idx] = text
                if error is not None:
                    failures.append(BatchFailure(index=idx, prompt=prompts[idx], error=error))

        if failures and raise_on_error:
            raise BatchError(
                sorted(failures, key=lambda failure: failure.index),
                results,
            )

        return results

    def _collect_stream(
        self,
        payload: dict[str, Any],
        timeout: float | None,
    ) -> dict[str, Any]:
        """Stream a request but collect and return the full response."""
        payload["stream"] = True
        chunks: list[str] = []
        stream_payload = dict(payload)
        model = str(stream_payload.pop("model", ""))
        messages = stream_payload.pop("messages", [])
        stream_payload.pop("stream", None)
        usage: dict[str, int] = {}

        for chunk in self.chat_stream(
            messages,
            model=model,
            timeout=timeout,
            **stream_payload,
        ):
            event_name = chunk.get("event")
            if event_name == "delta":
                raw = chunk.get("text")
                if isinstance(raw, str) and raw:
                    if raw.startswith("{"):
                        try:
                            inner = json.loads(raw)
                            if isinstance(inner, dict):
                                itype = inner.get("type", "")
                                if itype == "response.output_text.delta":
                                    raw = str(inner.get("delta", ""))
                                else:
                                    continue
                        except (json.JSONDecodeError, TypeError):
                            pass
                    chunks.append(raw)
                continue
            if event_name == "done":
                usage_payload = chunk.get("usage")
                if isinstance(usage_payload, dict):
                    usage = usage_payload
                done_text = chunk.get("text")
                if isinstance(done_text, str) and done_text and not chunks:
                    chunks.append(done_text)
                continue
            if event_name == "header":
                continue
            choices = chunk.get("choices", [])
            if choices:
                delta = choices[0].get("delta", {})
                content = delta.get("content", "")
                if content:
                    chunks.append(content)
            if "usage" in chunk:
                usage = chunk["usage"]

        text = "".join(chunks)
        return {
            "choices": [{"index": 0, "message": {"role": "assistant", "content": text}}],
            "model": model,
            "usage": usage,
        }


def _build_messages(
    prompt: str,
    *,
    system: str | None = None,
) -> list[dict[str, str]]:
    """Build a chat messages list from a prompt and optional system message."""
    messages: list[dict[str, str]] = []
    if system:
        messages.append({"role": "system", "content": system})
    messages.append({"role": "user", "content": prompt})
    return messages


def complete(
    prompt: str,
    **kwargs: Any,
) -> str:
    """Send a prompt to the Keiro gateway and return the response text.

    This is the simplest interface — one prompt in, one response out.

    Args:
        prompt: The user message to send.
        **kwargs: Passed to :class:`Client.chat`. Supported keyword
            arguments: ``model``, ``api_key``, ``base_url``, ``timeout``,
            ``system``, ``max_tokens``.

    Returns:
        The model's response text.

    Raises:
        KeirError: 401 if no valid API key is configured.
        KeirError: On gateway errors.
    """
    api_key = kwargs.pop("api_key", None)
    base_url = kwargs.pop("base_url", None)
    timeout = kwargs.pop("timeout", None)
    system = kwargs.pop("system", None)

    messages = _build_messages(prompt, system=system)

    with Client(api_key=api_key, base_url=base_url) as client:
        resp = client.chat(messages, timeout=timeout, **kwargs)
    return resp["choices"][0]["message"]["content"]


def _iter_sse_events(resp: requests.Response) -> Iterator[tuple[str | None, str]]:
    """Parse an SSE response into ``(event, data)`` tuples."""
    # Force UTF-8 decoding.  The requests library defaults text/* types
    # to ISO-8859-1 per RFC 2616 when no charset is specified, which
    # corrupts multi-byte UTF-8 characters (e.g. em dash → mojibake).
    resp.encoding = "utf-8"

    event_name: str | None = None
    data_lines: list[str] = []

    for raw_line in resp.iter_lines(decode_unicode=True):
        if raw_line is None:
            continue
        if isinstance(raw_line, str):
            line = raw_line
        else:
            try:
                line = raw_line.decode("utf-8")
            except (UnicodeDecodeError, AttributeError):
                continue
        if line == "":
            if data_lines:
                yield event_name, "\n".join(data_lines)
            event_name = None
            data_lines = []
            continue
        if line.startswith(":"):
            continue
        if line.startswith("event:"):
            event_name = line[6:].strip() or None
            continue
        if line.startswith("data:"):
            data_lines.append(line[5:].lstrip())

    if data_lines:
        yield event_name, "\n".join(data_lines)


def _raise_stream_error(payload: dict[str, Any], *, status_code: int) -> None:
    """Raise ``KeirError`` from a streamed error payload."""
    message = str(
        payload.get("message") or "Streaming response failed unexpectedly.",
    )
    error_type = None
    code = payload.get("code")

    error_obj = payload.get("error")
    if isinstance(error_obj, dict):
        message = str(error_obj.get("message") or message)
        if error_obj.get("type") is not None:
            error_type = str(error_obj.get("type"))
        if code is None and error_obj.get("code") is not None:
            code = str(error_obj.get("code"))
    elif isinstance(error_obj, str):
        error_type = error_obj
        if code is None:
            code = error_obj

    raise KeirError(
        message,
        status_code=status_code,
        error_type=error_type,
        code=str(code) if code is not None else None,
    )


def _iter_parsed_sse(resp: requests.Response) -> Iterator[dict[str, Any]]:
    """Parse an SSE response into typed JSON payloads.

    Handles ``[DONE]`` sentinel, malformed lines, streamed error events,
    and transport-level failures (e.g. chunked encoding errors from a
    502 Bad Gateway injected mid-stream by the gateway or reverse proxy).

    Closes the response when iteration completes or is abandoned.
    """
    try:
        for event_name, data in _iter_sse_events(resp):
            if data.strip() == "[DONE]":
                return
            try:
                payload_obj = json.loads(data)
            except json.JSONDecodeError:
                continue
            if not isinstance(payload_obj, dict):
                continue
            if event_name:
                payload_obj.setdefault("event", event_name)
            if event_name == "error":
                _raise_stream_error(payload_obj, status_code=resp.status_code)
            # Also detect error events embedded in the data payload
            # (sent by the gateway/GNN server without an SSE event:
            # field).  The gateway may use either "response.error" or
            # bare "error" as the type value.
            event_type = payload_obj.get("type")
            if event_type in ("response.error", "error"):
                _raise_stream_error(payload_obj, status_code=resp.status_code)
            yield payload_obj
    except (
        requests.exceptions.ConnectionError,
        requests.exceptions.ChunkedEncodingError,
    ) as exc:
        raise _connection_error_to_keir_error(
            exc,
            url=getattr(resp, "url", None),
        ) from exc
    finally:
        resp.close()


def _connection_error_to_keir_error(
    exc: requests.exceptions.RequestException,
    *,
    url: str | None = None,
) -> KeirError:
    """Convert a transport-level error into a structured KeirError.

    For ``ChunkedEncodingError`` (mid-stream failure), extracts the HTTP
    status code from the exception message when possible (e.g.
    ``InvalidChunkLength(got length b'HTTP/1.1 502 ...')``).

    For ``ConnectionError`` (connection never established), uses 503
    (Service Unavailable) since no HTTP response was received.

    Args:
        exc: The transport-level exception.
        url: The URL that was being requested, if known.
    """
    raw = str(exc)
    is_mid_stream = isinstance(exc, requests.exceptions.ChunkedEncodingError)

    if is_mid_stream:
        status_code = 502
        match = _HTTP_STATUS_RE.search(raw)
        if match:
            status_code = int(match.group(1))
        detail = f"Gateway returned HTTP {status_code} mid-stream. Retry the request."
    else:
        status_code = 503
        if url:
            detail = f"Gateway is unreachable at {url}. Check the URL and network connection."
        else:
            detail = "Gateway is unreachable. Check the server URL and network connection."

    return KeirError(
        detail,
        status_code=status_code,
        error_type="connection_error",
        code="stream_interrupted" if is_mid_stream else "connection_failed",
    )


def _resolve_credentials(
    explicit_key: str | None,
    explicit_url: str | None,
) -> tuple[str, str]:
    """Resolve API key and base URL from args > env > credentials > gateway URL.

    When no URL is set on the client, in env, or in ``~/.keiro/credentials``,
    :func:`keiro.defaults.get_gateway_url` supplies the default (honoring
    ``EB1_GATEWAY_URL`` / ``EB1_GATEWAY_HOST`` before bundled defaults).

    Raises:
        KeirError: 401 if no API key is found in any source.
    """
    key = explicit_key or os.environ.get("KEIRO_API_KEY")
    url = explicit_url or os.environ.get("KEIRO_BASE_URL")

    if not key or not url:
        creds = load_credentials()
        key = key or creds.get("api_key")
        url = url or creds.get("base_url")

    if not key:
        raise KeirError(
            "No API key configured. Run 'keiro setup' or set KEIRO_API_KEY.",
            status_code=401,
            error_type="authentication_error",
            code="missing_api_key",
        )

    return key, (url or get_gateway_url()).rstrip("/")


def _build_url(base: str, path: str) -> str:
    """Build a full endpoint URL, avoiding duplicate ``/v1`` prefixes."""
    normalized_base = base.rstrip("/")
    if path.startswith("/v1/") and normalized_base.endswith("/v1"):
        path = path[3:]
    return f"{normalized_base}{path}"
