"""Prompt-first hosted model facade for lightweight external use.

This mirrors the ergonomic parts of ``ember.api.models`` without pulling in
the full Ember runtime. The facade uses the hosted Keiro gateway underneath.
"""

from __future__ import annotations

import copy
import json
import logging
import time
from collections.abc import Callable, Iterator, Mapping, Sequence
from dataclasses import dataclass, field
from typing import Any

from keiro.client import Client, KeirError, KeirValidationError

LOG = logging.getLogger(__name__)


def _stringify_json(value: object) -> str:
    if isinstance(value, str):
        return value
    if value is None:
        return ""
    try:
        return json.dumps(value, ensure_ascii=False)
    except TypeError:
        return str(value)


def _content_to_text(content: object) -> str:
    if isinstance(content, str):
        return content
    if isinstance(content, bytes):
        return content.decode("utf-8", errors="ignore")
    if isinstance(content, Mapping):
        if content.get("text") is not None:
            return _stringify_json(content.get("text"))
        if content.get("content") is not None:
            return _content_to_text(content.get("content"))
        if content.get("output") is not None:
            return _stringify_json(content.get("output"))
        return _stringify_json(content)
    if isinstance(content, Sequence) and not isinstance(content, (str, bytes, bytearray)):
        return "".join(_content_to_text(part) for part in content)
    if content is None:
        return ""
    return str(content)


def _part_to_text(part: Mapping[str, Any]) -> str:
    if part.get("text") is not None:
        return _stringify_json(part.get("text"))
    if part.get("output") is not None:
        return _stringify_json(part.get("output"))
    if part.get("content") is not None:
        return _content_to_text(part.get("content"))
    return ""


def _extract_response_text(raw_response: Mapping[str, Any]) -> str:
    output = raw_response.get("output")
    if isinstance(output, Sequence) and not isinstance(output, (str, bytes, bytearray)):
        text_parts: list[str] = []
        for item in output:
            if not isinstance(item, Mapping):
                continue
            item_type = str(item.get("type") or "")
            if item_type == "message":
                text_parts.append(_content_to_text(item.get("content")))
            elif item_type in {"output_text", "text"}:
                text_parts.append(_part_to_text(item))
        if text_parts:
            return "".join(text_parts)

    choices = raw_response.get("choices")
    if isinstance(choices, Sequence) and not isinstance(choices, (str, bytes, bytearray)):
        for choice in choices:
            if not isinstance(choice, Mapping):
                continue
            message = choice.get("message")
            if isinstance(message, Mapping):
                text = _content_to_text(message.get("content"))
                if text:
                    return text
    return ""


def _usage_dict(raw_response: Mapping[str, Any]) -> dict[str, Any]:
    usage = raw_response.get("usage")
    if not isinstance(usage, Mapping):
        return {
            "prompt_tokens": 0,
            "completion_tokens": 0,
            "total_tokens": 0,
            "cost": 0.0,
            "actual_cost_usd": None,
            "details": None,
        }

    normalized = dict(usage)
    normalized.setdefault("prompt_tokens", 0)
    normalized.setdefault("completion_tokens", 0)
    normalized.setdefault("total_tokens", 0)
    if "cost" not in normalized:
        normalized["cost"] = normalized.get("cost_usd", 0.0) or 0.0
    normalized.setdefault("actual_cost_usd", None)
    normalized.setdefault("details", None)
    return normalized


def _normalized_context(context: Sequence[Mapping[str, Any]] | None) -> list[dict[str, Any]]:
    if context is None:
        return []
    if isinstance(context, (str, bytes, bytearray)):
        raise KeirValidationError(
            "'context' must be a list of message dicts, not a string.",
            code="invalid_context_type",
        )

    normalized: list[dict[str, Any]] = []
    for index, item in enumerate(context):
        if not isinstance(item, Mapping):
            raise KeirValidationError(
                f"context[{index}] must be a dict, got {type(item).__name__}.",
                code="invalid_context_entry",
            )
        normalized.append(copy.deepcopy(dict(item)))
    return normalized


def _build_prompt_messages(
    prompt: str,
    *,
    system: str | None,
    context: Sequence[Mapping[str, Any]] | None,
) -> list[dict[str, Any]]:
    messages: list[dict[str, Any]] = []
    if system is not None:
        messages.append({"role": "system", "content": str(system)})

    messages.extend(_normalized_context(context))
    if prompt:
        messages.append({"role": "user", "content": prompt})
    return messages


def _merge_responses_system_alias(
    request_payload: dict[str, Any],
    *,
    system: object | None,
) -> None:
    if system is None:
        return
    system_text = system if isinstance(system, str) else str(system)
    existing = request_payload.get("instructions")
    if existing is None:
        request_payload["instructions"] = system_text
        return
    if existing != system_text:
        raise KeirValidationError(
            "Cannot supply both 'instructions' and 'system'. Use one or the other.",
            code="conflicting_arguments",
        )


def _has_chat_completions_tool_format(messages: Sequence[Mapping[str, Any]]) -> bool:
    return any(
        message.get("role") == "tool"
        or message.get("tool_calls") is not None
        or message.get("tool_call_id") is not None
        for message in messages
    )


def _coerce_responses_part(
    part: Mapping[str, Any],
    *,
    default_type: str,
) -> dict[str, Any]:
    converted = copy.deepcopy(dict(part))
    part_type = str(converted.get("type") or default_type)
    if part_type == "text":
        converted["type"] = default_type
    return converted


def _coerce_responses_content(
    content: object,
    *,
    default_type: str,
) -> list[dict[str, Any]]:
    if isinstance(content, str):
        return [{"type": default_type, "text": content}]
    if isinstance(content, bytes):
        return [{"type": default_type, "text": content.decode("utf-8", errors="ignore")}]
    if isinstance(content, Mapping):
        return [_coerce_responses_part(content, default_type=default_type)]
    if isinstance(content, Sequence) and not isinstance(content, (str, bytes, bytearray)):
        parts: list[dict[str, Any]] = []
        for item in content:
            if isinstance(item, Mapping):
                parts.append(_coerce_responses_part(item, default_type=default_type))
            else:
                parts.append({"type": default_type, "text": _stringify_json(item)})
        return parts
    if content is None:
        return [{"type": default_type, "text": ""}]
    return [{"type": default_type, "text": str(content)}]


def _as_responses_turns(messages: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]:
    items: list[dict[str, Any]] = []

    for index, message in enumerate(messages):
        if not isinstance(message, Mapping):
            raise KeirValidationError(
                f"messages[{index}] must be a dict, got {type(message).__name__}.",
                code="invalid_message_entry",
            )

        role = str(message.get("role") or "")
        content = message.get("content", "")

        if role == "tool":
            items.append(
                {
                    "type": "function_call_output",
                    "call_id": str(message.get("tool_call_id", "")),
                    "output": _stringify_json(content),
                }
            )
            continue

        if role == "assistant":
            if content not in ("", None) and content != []:
                items.append(
                    {
                        "type": "message",
                        "role": "assistant",
                        "content": _coerce_responses_content(
                            content,
                            default_type="output_text",
                        ),
                    }
                )

            tool_calls = message.get("tool_calls")
            if isinstance(tool_calls, Sequence) and not isinstance(
                tool_calls, (str, bytes, bytearray)
            ):
                for tool_call in tool_calls:
                    if not isinstance(tool_call, Mapping):
                        continue
                    function = tool_call.get("function")
                    function_name = ""
                    function_arguments: object = "{}"
                    if isinstance(function, Mapping):
                        function_name = str(function.get("name", ""))
                        function_arguments = function.get("arguments", "{}")
                    items.append(
                        {
                            "type": "function_call",
                            "call_id": str(tool_call.get("id", "")),
                            "name": function_name,
                            "arguments": _stringify_json(function_arguments),
                        }
                    )
            continue

        items.append(
            {
                "type": "message",
                "role": role,
                "content": _coerce_responses_content(content, default_type="input_text"),
            }
        )

    return items


def _is_sse_sentinel(text: str) -> bool:
    """Return True if *text* is the SSE ``[DONE]`` termination marker.

    This is a defense-in-depth check: the SSE parser in ``client.py``
    should filter ``[DONE]`` before it reaches higher layers, but if a
    variant encoding (extra whitespace, mixed case) slips through, this
    guard prevents the sentinel from being yielded as user-visible text.
    """
    return text.strip().upper() == "[DONE]"


def _extract_stream_text(
    event: Mapping[str, Any],
    *,
    include_reasoning: bool = False,
) -> str:
    """Extract plain text from a streaming event.

    Handles three formats:
    - Responses API text deltas (``response.output_text.delta``)
    - Custom ``delta`` events with a ``text`` field
    - Chat Completions deltas (``choices[].delta.content``)

    All other Responses API lifecycle events (``response.created``,
    ``response.completed``, etc.) are explicitly skipped.

    Args:
        event: A single streaming event dict.
        include_reasoning: When True, emit reasoning summary deltas as
            dim ANSI text (for admin mode). When False, suppress them.
    """
    event_type = event.get("event") or event.get("type")

    # Responses API text delta — the only event carrying user-visible text.
    if event_type == "response.output_text.delta":
        text = _content_to_text(event.get("delta"))
        if _is_sse_sentinel(text):
            return ""
        return text

    # Reasoning summary deltas — show dimmed in admin mode, hide otherwise.
    if event_type == "response.reasoning_summary_text.delta":
        if include_reasoning:
            text = str(event.get("delta", ""))
            if text:
                return f"\033[2m{text}\033[0m"
        return ""

    # Custom "delta" event with a "text" field (gateway SSE format).
    # The gateway wraps Responses API events inside delta.text as JSON
    # strings — unwrap and only extract text from output_text.delta.
    if event_type == "delta":
        raw_text = event.get("text")
        if isinstance(raw_text, str) and _is_sse_sentinel(raw_text):
            return ""
        if isinstance(raw_text, str) and raw_text.startswith("{"):
            try:
                inner = json.loads(raw_text)
                if isinstance(inner, dict):
                    inner_type = inner.get("type", "")
                    if inner_type == "response.output_text.delta":
                        return str(inner.get("delta", ""))
                    if inner_type == "response.reasoning_summary_text.delta":
                        if include_reasoning:
                            text = str(inner.get("delta", ""))
                            if text:
                                return f"\033[2m{text}\033[0m"
                        return ""
                    # All other parsed JSON events carry no
                    # user-visible text.
                    return ""
            except (json.JSONDecodeError, TypeError):
                pass
        return _content_to_text(raw_text)

    # Defensive fallback: extract text from response.completed events
    # that embed the full response payload with output text.  This
    # handles gateways that return the text only in the completed event
    # (e.g., sync-to-stream conversion without proper delta injection).
    if event_type == "response.completed":
        resp_obj = event.get("response")
        if isinstance(resp_obj, Mapping):
            ot = resp_obj.get("output_text")
            if isinstance(ot, str) and ot:
                return ot
            output = resp_obj.get("output")
            if isinstance(output, Sequence) and not isinstance(output, (str, bytes)):
                for item in output:
                    if not isinstance(item, Mapping):
                        continue
                    content = item.get("content")
                    if not isinstance(content, Sequence):
                        continue
                    for part in content:
                        if isinstance(part, Mapping) and part.get("text"):
                            return str(part["text"])
        return ""

    # Surface error events instead of silently dropping them.
    # Layer 1 (_iter_parsed_sse in client.py) should catch these first,
    # but this is a defensive fallback in case the event slips through.
    # The gateway may use either "response.error" or bare "error".
    if event_type in ("response.error", "error"):
        error_obj = event.get("error")
        msg = "Streaming error (no details provided by server)."
        error_type: str | None = None
        code: str | None = None
        if isinstance(error_obj, Mapping):
            raw_msg = error_obj.get("message")
            if isinstance(raw_msg, str) and raw_msg:
                msg = raw_msg
            error_type = error_obj.get("type")
            raw_code = error_obj.get("code")
            if raw_code is not None:
                code = str(raw_code)
        raise KeirError(
            msg,
            status_code=502,
            error_type=error_type,
            code=code,
        )

    # Skip all other Responses API lifecycle events. These carry metadata
    # (response objects, item status, usage) but no user-visible text.
    if isinstance(event_type, str) and event_type.startswith("response."):
        return ""

    # Chat Completions format: choices[].delta.content (always a string).
    choices = event.get("choices")
    if isinstance(choices, Sequence) and not isinstance(choices, (str, bytes, bytearray)):
        for choice in choices:
            if not isinstance(choice, Mapping):
                continue
            delta = choice.get("delta")
            if isinstance(delta, Mapping):
                content = delta.get("content")
                if isinstance(content, str) and content:
                    if _is_sse_sentinel(content):
                        return ""
                    return content
    return ""


_MAX_STREAM_BYTES: int = 512_000  # 512KB hard limit
_REPEAT_WINDOW: int = 20  # Track last N chunks for pattern detection
_REPEAT_THRESHOLD: int = 3  # Pattern must repeat this many times


def _is_degenerate_loop(recent: list[str]) -> bool:
    """Detect repeating patterns in a rolling window of chunks.

    For each candidate pattern length L (1..WINDOW), check whether the
    most recent ``L * THRESHOLD`` chunks consist of the same L-chunk
    sequence repeated THRESHOLD times.
    """
    n = len(recent)
    for length in range(1, n // _REPEAT_THRESHOLD + 1):
        span = length * _REPEAT_THRESHOLD
        if span > n:
            continue
        tail = recent[-span:]
        pattern = tail[:length]
        if all(tail[i] == pattern[i % length] for i in range(length, span)):
            return True
    return False


def _deduplicate_stream_text(
    events: Iterator[Mapping[str, Any]],
    *,
    include_reasoning: bool = False,
) -> Iterator[str]:
    """Yield text from streaming events, suppressing duplicates.

    When both ``response.output_text.delta`` events and a terminal
    ``response.completed`` event carry the same text, only the delta
    text is yielded. If no deltas arrived (sync-to-stream conversion),
    the completed event's text is used as a fallback.
    """
    yield from _iter_deduplicated_text(
        events,
        include_reasoning=include_reasoning,
    )


def _iter_deduplicated_text(
    events: Iterator[Mapping[str, Any]],
    on_event: Callable[[Mapping[str, Any]], None] | None = None,
    on_first_text: Callable[[], None] | None = None,
    *,
    include_reasoning: bool = False,
) -> Iterator[str]:
    """Core deduplication loop shared by plain streaming and AdminStream."""
    saw_delta = False
    total_bytes = 0
    recent_chunks: list[str] = []

    for event in events:
        if on_event is not None:
            on_event(event)
        event_type = event.get("type") or event.get("event") or ""
        text = _extract_stream_text(
            event,
            include_reasoning=include_reasoning,
        )
        if text:
            if event_type == "response.completed" and saw_delta:
                continue
            saw_delta = True
            if on_first_text is not None:
                on_first_text()
                on_first_text = None  # Fire once.

            total_bytes += len(text.encode("utf-8"))
            if total_bytes > _MAX_STREAM_BYTES:
                LOG.warning(
                    "Stream exceeded %d byte limit, terminating",
                    _MAX_STREAM_BYTES,
                )
                break

            recent_chunks.append(text)
            if len(recent_chunks) > _REPEAT_WINDOW:
                recent_chunks = recent_chunks[-_REPEAT_WINDOW:]
            if len(recent_chunks) >= _REPEAT_THRESHOLD and _is_degenerate_loop(recent_chunks):
                LOG.warning(
                    "Degenerate loop detected, terminating stream",
                )
                break

            yield text


@dataclass
class AdminMetrics:
    """Metrics captured during an admin-mode streaming response.

    Client-side fields (``ttfb_ms``, ``total_ms``) are always populated.
    Server-side fields require a valid ``KEIRO_ADMIN_TOKEN``.
    """

    request_id: str | None = None
    provider: str | None = None
    provider_model: str | None = None
    model: str | None = None

    candidates: list[str] = field(default_factory=list)
    candidates_attempted: int | None = None
    candidates_succeeded: int | None = None
    judge_model: str | None = None
    primary_source_model: str | None = None

    gnn_distribution: dict[str, float] = field(default_factory=dict)
    gnn_tier: str | None = None
    gnn_confidence: float | None = None
    gnn_logit_margin: float | None = None
    gnn_route_time_ms: float | None = None

    prompt_tokens: int = 0
    completion_tokens: int = 0
    total_tokens: int = 0
    cost_usd: float | None = None
    timings_ms: dict[str, float] = field(default_factory=dict)

    ttfb_ms: float | None = None
    total_ms: float | None = None

    admin_granted: bool | None = None


class AdminStream:
    """Streaming iterator that yields text while capturing admin metrics.

    Wraps the raw SSE event stream from ``Client.responses_stream()``.
    Uses ``_iter_deduplicated_text`` for text extraction so deduplication
    logic is shared with ``_deduplicate_stream_text``.
    """

    def __init__(
        self,
        raw_events: Iterator[Mapping[str, Any]],
        *,
        include_reasoning: bool = False,
        admin_status_fn: Callable[[], bool | None] | None = None,
        on_header: Callable[["AdminMetrics"], None] | None = None,
    ) -> None:
        self._raw = raw_events
        self._start = time.monotonic()
        self._include_reasoning = include_reasoning
        self._admin_status_fn = admin_status_fn
        self._on_header = on_header
        self.metrics = AdminMetrics()

    def __iter__(self) -> Iterator[str]:
        def mark_ttfb() -> None:
            self.metrics.ttfb_ms = (time.monotonic() - self._start) * 1000
            # Resolve lazily: the streaming generator parses
            # response headers before yielding the first event.
            if self._admin_status_fn is not None:
                self.metrics.admin_granted = self._admin_status_fn()

        yield from _iter_deduplicated_text(
            self._raw,
            on_event=self._ingest,
            on_first_text=mark_ttfb,
            include_reasoning=self._include_reasoning,
        )
        self.metrics.total_ms = (time.monotonic() - self._start) * 1000

    def _ingest(self, event: Mapping[str, Any]) -> None:
        event_type = event.get("event") or event.get("type") or ""

        if event_type == "telemetry":
            self._ingest_telemetry(event)
            return

        if event_type == "header":
            self.metrics.request_id = _str_or_none(event.get("id"))
            self.metrics.model = _str_or_none(event.get("model"))
            self.metrics.provider = _str_or_none(event.get("provider"))
            self.metrics.provider_model = _str_or_none(
                event.get("provider_model"),
            )
            self._ingest_plan(event)
            self._ingest_provider_metadata(event)
            if self._on_header is not None:
                try:
                    self._on_header(self.metrics)
                except Exception:
                    pass  # Don't crash the stream on display errors.
                self._on_header = None  # Fire once.

        if event_type in ("done", "response.completed"):
            self._ingest_usage(event)
            self._ingest_provider_metadata(event)
            cost = event.get("cost_usd")
            if cost is not None:
                self.metrics.cost_usd = float(cost)
            resp = event.get("response")
            if isinstance(resp, Mapping):
                self._ingest_usage(resp)
                self._ingest_provider_metadata(resp)
                meta = resp.get("metadata")
                if isinstance(meta, Mapping):
                    self._ingest_debug_metadata(meta)

        if event_type not in ("delta", "response.output_text.delta"):
            meta = event.get("metadata")
            if isinstance(meta, Mapping):
                self._ingest_debug_metadata(meta)

    def _ingest_usage(self, source: Mapping[str, Any]) -> None:
        usage = source.get("usage")
        if not isinstance(usage, Mapping):
            return
        # Support both OpenAI (prompt_tokens/completion_tokens) and
        # Anthropic (input_tokens/output_tokens) naming conventions.
        pt = usage.get("prompt_tokens")
        if pt is None:
            pt = usage.get("input_tokens")
        ct = usage.get("completion_tokens")
        if ct is None:
            ct = usage.get("output_tokens")
        tt = usage.get("total_tokens")
        if pt is not None:
            self.metrics.prompt_tokens = int(pt)
        if ct is not None:
            self.metrics.completion_tokens = int(ct)
        if tt is not None:
            self.metrics.total_tokens = int(tt)

    def _ingest_plan(self, event: Mapping[str, Any]) -> None:
        """Extract ensemble plan details (candidate list, judge)."""
        plan = event.get("plan")
        if isinstance(plan, Mapping):
            cands = plan.get("candidates")
            if isinstance(cands, (list, tuple)) and cands:
                self.metrics.candidates = [str(c) for c in cands]
            judge = plan.get("judge")
            if isinstance(judge, str) and not self.metrics.judge_model:
                self.metrics.judge_model = judge
        outline = event.get("plan_outline")
        if isinstance(outline, Mapping) and not self.metrics.candidates:
            cands = outline.get("candidates")
            if isinstance(cands, (list, tuple)) and cands:
                self.metrics.candidates = [str(c) for c in cands]

    def _ingest_provider_metadata(self, event: Mapping[str, Any]) -> None:
        """Extract GNN routing signals from provider_metadata.

        Server emits these as ``_``-prefixed internal fields; we
        normalize to public field names on ``AdminMetrics``.
        """
        pm = event.get("provider_metadata")
        if not isinstance(pm, Mapping):
            return
        dist = pm.get("_distribution")
        if isinstance(dist, Mapping) and dist:
            self.metrics.gnn_distribution = {str(k): float(v) for k, v in dist.items()}
        tier = pm.get("_tier")
        if isinstance(tier, str):
            self.metrics.gnn_tier = tier
        conf = pm.get("_confidence")
        if conf is not None:
            self.metrics.gnn_confidence = float(conf)
        margin = pm.get("_logit_margin")
        if margin is not None:
            self.metrics.gnn_logit_margin = float(margin)
        rt = pm.get("_route_time_ms")
        if rt is not None:
            self.metrics.gnn_route_time_ms = float(rt)

    def _ingest_debug_metadata(self, metadata: Mapping[str, Any]) -> None:
        """Extract admin-only debug fields from response metadata."""
        for attr in ("judge_model", "primary_source_model"):
            value = metadata.get(attr)
            if value is not None:
                setattr(self.metrics, attr, str(value))
        for attr in ("candidates_attempted", "candidates_succeeded"):
            value = metadata.get(attr)
            if value is not None:
                setattr(self.metrics, attr, int(value))
        cands = metadata.get("candidates")
        if isinstance(cands, (list, tuple)) and cands and not self.metrics.candidates:
            self.metrics.candidates = [str(c) for c in cands]
        timings = metadata.get("timings_ms")
        if isinstance(timings, Mapping):
            self.metrics.timings_ms = {str(k): float(v) for k, v in timings.items()}

    def _ingest_telemetry(self, event: Mapping[str, Any]) -> None:
        """Parse a structured ``event: telemetry`` payload into metrics.

        The telemetry event is a versioned envelope emitted once at
        end-of-stream for admin users. It consolidates routing, usage,
        timings, and provider metadata into a single JSON object,
        replacing the need to scrape scattered header/done/metadata events.

        Existing sub-parsers remain for backward compat with older servers.
        """
        self.metrics.request_id = _str_or_none(event.get("request_id"))

        routing = event.get("routing")
        if isinstance(routing, Mapping):
            self.metrics.provider = _str_or_none(routing.get("provider"))
            self.metrics.provider_model = _str_or_none(
                routing.get("provider_model"),
            )
            self.metrics.model = _str_or_none(routing.get("canonical_model"))
            plan = routing.get("plan")
            if isinstance(plan, Mapping):
                cands = plan.get("candidates")
                if isinstance(cands, (list, tuple)) and cands:
                    self.metrics.candidates = [str(c) for c in cands]
                judge = plan.get("judge")
                if isinstance(judge, str):
                    self.metrics.judge_model = judge

        usage = event.get("usage")
        if isinstance(usage, Mapping):
            pt = usage.get("prompt_tokens")
            ct = usage.get("completion_tokens")
            tt = usage.get("total_tokens")
            if pt is not None:
                self.metrics.prompt_tokens = int(pt)
            if ct is not None:
                self.metrics.completion_tokens = int(ct)
            if tt is not None:
                self.metrics.total_tokens = int(tt)
            cost = usage.get("cost_usd")
            if cost is not None:
                self.metrics.cost_usd = float(cost)

        timings = event.get("timings_ms")
        if isinstance(timings, Mapping):
            self.metrics.timings_ms = {str(k): float(v) for k, v in timings.items()}

        pm = event.get("provider_metadata")
        if isinstance(pm, Mapping):
            dist = pm.get("distribution")
            if isinstance(dist, Mapping) and dist:
                self.metrics.gnn_distribution = {str(k): float(v) for k, v in dist.items()}
            tier = pm.get("tier")
            if isinstance(tier, str):
                self.metrics.gnn_tier = tier
            conf = pm.get("confidence")
            if conf is not None:
                self.metrics.gnn_confidence = float(conf)
            margin = pm.get("logit_margin")
            if margin is not None:
                self.metrics.gnn_logit_margin = float(margin)
            rt = pm.get("route_time_ms")
            if rt is not None:
                self.metrics.gnn_route_time_ms = float(rt)


def _str_or_none(value: object) -> str | None:
    if isinstance(value, str) and value:
        return value
    return None


class Response:
    """Structured wrapper around hosted gateway responses."""

    def __init__(self, raw_response: Mapping[str, Any]):
        self._raw = dict(raw_response)

    @property
    def text(self) -> str:
        return _extract_response_text(self._raw)

    @property
    def usage(self) -> dict[str, Any]:
        return _usage_dict(self._raw)

    @property
    def model_id(self) -> str | None:
        model_id = self._raw.get("model")
        return str(model_id) if isinstance(model_id, str) and model_id else None

    @property
    def metadata(self) -> dict[str, Any]:
        metadata: dict[str, Any] = {}
        for key in ("id", "object", "created"):
            value = self._raw.get(key)
            if value is not None:
                metadata[key] = value
        if self.model_id is not None:
            metadata["model_id"] = self.model_id
        return metadata

    @property
    def output(self) -> list[dict[str, Any]]:
        output = self._raw.get("output")
        if isinstance(output, list):
            return [dict(item) for item in output if isinstance(item, Mapping)]
        return []

    @property
    def raw(self) -> dict[str, Any]:
        return dict(self._raw)

    def __str__(self) -> str:
        return self.text

    def __repr__(self) -> str:
        preview = self.text[:50]
        return f"Response(text={preview!r}, tokens={self.usage['total_tokens']})"


@dataclass(frozen=True)
class ModelInvocation:
    """Composite result exposing text alongside the structured response."""

    text: str
    response: Response

    @property
    def usage(self) -> dict[str, Any]:
        return self.response.usage

    @property
    def model_id(self) -> str | None:
        return self.response.model_id

    @property
    def metadata(self) -> dict[str, Any]:
        return self.response.metadata


class ModelResult(str):
    """String-like result that retains the structured response object."""

    __slots__ = ("_response",)
    _response: Response

    def __new__(cls, response: Response) -> "ModelResult":
        instance = super().__new__(cls, response.text)
        instance._response = response
        return instance

    @property
    def text(self) -> str:
        return str(self)

    @property
    def response(self) -> Response:
        return self._response

    @property
    def usage(self) -> dict[str, Any]:
        return self._response.usage

    @property
    def model_id(self) -> str | None:
        return self._response.model_id

    @property
    def metadata(self) -> dict[str, Any]:
        return self._response.metadata

    @property
    def output(self) -> list[dict[str, Any]]:
        return self._response.output

    @property
    def raw(self) -> dict[str, Any]:
        return self._response.raw

    def __repr__(self) -> str:
        return f"ModelResult(text={str(self)!r})"


class ModelBinding:
    """Reusable callable with a fixed model id and default parameters."""

    def __init__(self, model_id: str, api: ModelsAPI, **params: Any) -> None:
        self.model_id = model_id
        self._api = api
        self._params = dict(params)

    def __call__(self, prompt: str, **override_params: Any) -> str:
        return str(self._api(self.model_id, prompt, **{**self._params, **override_params}))

    def response(self, prompt: str, **override_params: Any) -> Response:
        return self._api.response(self.model_id, prompt, **{**self._params, **override_params})

    def with_metadata(self, prompt: str, **override_params: Any) -> ModelInvocation:
        return self._api.with_metadata(
            self.model_id,
            prompt,
            **{**self._params, **override_params},
        )

    def stream(self, prompt: str, **override_params: Any) -> Iterator[str]:
        return self._api.stream(self.model_id, prompt, **{**self._params, **override_params})

    def __repr__(self) -> str:
        return f"ModelBinding(model_id={self.model_id!r}, params={self._params!r})"


class ModelsAPI:
    """Hosted facade shaped like ``ember.api.models`` for quick testing.

    Caches a single :class:`Client` instance for HTTP connection reuse.
    Call :meth:`close` (or use as a context manager) to release the
    underlying connection pool explicitly.
    """

    def __init__(
        self,
        *,
        api_key: str | None = None,
        base_url: str | None = None,
        admin_token: str | None = None,
        timeout: float | None = None,
        retries: int = 3,
        pool_maxsize: int = 100,
    ) -> None:
        self._api_key = api_key
        self._base_url = base_url
        self._admin_token = admin_token
        self._timeout = timeout
        self._retries = retries
        self._pool_maxsize = pool_maxsize
        self._cached_client: Client | None = None

    def _client(self) -> Client:
        if self._cached_client is not None:
            return self._cached_client
        client_kwargs: dict[str, Any] = {
            "api_key": self._api_key,
            "base_url": self._base_url,
            "admin_token": self._admin_token,
            "retries": self._retries,
            "pool_maxsize": self._pool_maxsize,
        }
        if self._timeout is not None:
            client_kwargs["timeout"] = self._timeout
        self._cached_client = Client(**client_kwargs)
        return self._cached_client

    def close(self) -> None:
        """Close the underlying HTTP client and release connections."""
        if self._cached_client is not None:
            self._cached_client.close()
            self._cached_client = None

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

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

    def _prepare_messages(
        self,
        prompt: str,
        params: dict[str, Any],
    ) -> list[dict[str, Any]]:
        """Pop system/context from *params* and build chat messages."""
        system = params.pop("system", None)
        if system is not None and not isinstance(system, str):
            system = str(system)
        context = params.pop("context", None)
        if context is not None and (
            isinstance(context, (str, bytes, bytearray)) or not isinstance(context, Sequence)
        ):
            raise KeirValidationError(
                "'context' must be a list of message dicts when supplied.",
                code="invalid_context_type",
            )
        return _build_prompt_messages(prompt, system=system, context=context)

    def _chat_response(self, model: str, prompt: str, **params: Any) -> Response:
        stream_requested = bool(params.pop("stream", False))
        if stream_requested:
            raise KeirValidationError(
                "Pass stream=True to ModelsAPI.stream(), not to __call__().",
                code="use_stream_method",
            )

        if "messages" in params:
            raise KeirValidationError(
                "Pass raw messages via Client.chat() or "
                "ModelsAPI.responses(messages=...), not via __call__().",
                code="use_chat_for_messages",
            )

        messages = self._prepare_messages(prompt, params)
        return Response(self._client().chat(messages, model=model, **params))

    def _build_responses_payload(
        self,
        payload: Mapping[str, Any] | None,
        *,
        input: object | None,
        instructions: object | None,
        messages: Sequence[Mapping[str, Any]] | None,
        params: dict[str, Any],
    ) -> dict[str, Any]:
        request_payload: dict[str, Any] = {}
        if payload is not None:
            request_payload.update(copy.deepcopy(dict(payload)))

        system = params.pop("system", None)

        if input is not None:
            request_payload["input"] = copy.deepcopy(input)

        if instructions is not None:
            request_payload["instructions"] = copy.deepcopy(instructions)

        _merge_responses_system_alias(request_payload, system=system)

        if messages is not None:
            for index, message in enumerate(messages):
                if not isinstance(message, Mapping):
                    raise KeirValidationError(
                        f"messages[{index}] must be a dict, got {type(message).__name__}.",
                        code="invalid_message_entry",
                    )

            if _has_chat_completions_tool_format(messages):
                # _as_responses_turns builds fresh dicts; no pre-copy needed.
                request_payload["input"] = _as_responses_turns(messages)
            else:
                request_payload["messages"] = [copy.deepcopy(dict(m)) for m in messages]

        prompt = params.pop("prompt", None)
        if prompt is not None:
            if "input" in request_payload and request_payload["input"] is not None:
                raise KeirValidationError(
                    "Cannot supply both 'prompt' and 'input'. Use one or the other.",
                    code="conflicting_arguments",
                )
            request_payload["input"] = prompt

        context = params.pop("context", None)
        if context is not None and "messages" not in request_payload:
            if isinstance(context, (str, bytes, bytearray)) or not isinstance(context, Sequence):
                raise KeirValidationError(
                    "'context' must be a list of message dicts when supplied.",
                    code="invalid_context_type",
                )
            request_payload["messages"] = _normalized_context(context)

        max_tokens = params.pop("max_tokens", None)
        if max_tokens is not None and "max_output_tokens" not in request_payload:
            request_payload["max_output_tokens"] = max_tokens

        params.pop("stream", None)

        for key, value in list(params.items()):
            if value is None:
                continue
            request_payload[key] = copy.deepcopy(value)

        return {key: value for key, value in request_payload.items() if value is not None}

    def __call__(self, model: str, prompt: str, **params: Any) -> ModelResult:
        return ModelResult(self._chat_response(model, prompt, **params))

    def response(self, model: str, prompt: str, **params: Any) -> Response:
        return self._chat_response(model, prompt, **params)

    def with_metadata(self, model: str, prompt: str, **params: Any) -> ModelInvocation:
        response = self._chat_response(model, prompt, **params)
        return ModelInvocation(text=response.text, response=response)

    def instance(self, model: str, **params: Any) -> ModelBinding:
        return ModelBinding(model, self, **params)

    def stream(self, model: str, prompt: str, **params: Any) -> Iterator[str]:
        params.pop("stream", None)
        messages = self._prepare_messages(prompt, params)
        return _deduplicate_stream_text(
            self._client().chat_stream(messages, model=model, **params),
        )

    def responses(
        self,
        model: str,
        *,
        payload: Mapping[str, Any] | None = None,
        input: object | None = None,
        instructions: object | None = None,
        messages: Sequence[Mapping[str, Any]] | None = None,
        **params: Any,
    ) -> Response:
        stream_requested = bool(params.pop("stream", False))
        if stream_requested:
            raise KeirValidationError(
                "Pass stream=True to ModelsAPI.responses_stream(), not to responses().",
                code="use_stream_method",
            )
        timeout = params.pop("timeout", None)

        request_payload = self._build_responses_payload(
            payload,
            input=input,
            instructions=instructions,
            messages=messages,
            params=dict(params),
        )
        request_payload["model"] = model
        return Response(self._client().responses(request_payload, timeout=timeout))

    def responses_stream(
        self,
        model: str,
        *,
        payload: Mapping[str, Any] | None = None,
        input: object | None = None,
        instructions: object | None = None,
        messages: Sequence[Mapping[str, Any]] | None = None,
        **params: Any,
    ) -> Iterator[str]:
        params.pop("stream", None)
        timeout = params.pop("timeout", None)
        request_payload = self._build_responses_payload(
            payload,
            input=input,
            instructions=instructions,
            messages=messages,
            params=dict(params),
        )
        request_payload["model"] = model
        return _deduplicate_stream_text(
            self._client().responses_stream(request_payload, timeout=timeout),
        )

    def responses_stream_admin(
        self,
        model: str,
        *,
        payload: Mapping[str, Any] | None = None,
        input: object | None = None,
        instructions: object | None = None,
        messages: Sequence[Mapping[str, Any]] | None = None,
        on_header: Callable[["AdminMetrics"], None] | None = None,
        **params: Any,
    ) -> AdminStream:
        """Stream a Responses API request with admin metrics capture.

        Returns an ``AdminStream`` that yields text chunks (like
        ``responses_stream``) but also populates ``stream.metrics``
        with server-side and client-side telemetry as events arrive.

        Args:
            on_header: Optional callback invoked once when the header
                event arrives (before any text deltas). Receives the
                partially populated ``AdminMetrics`` with routing info.

        Requires ``admin_token`` on the ``ModelsAPI`` for server-side
        fields. Client-side timing (TTFB, total latency) is always
        captured regardless.
        """
        params.pop("stream", None)
        timeout = params.pop("timeout", None)
        request_payload = self._build_responses_payload(
            payload,
            input=input,
            instructions=instructions,
            messages=messages,
            params=dict(params),
        )
        request_payload["model"] = model
        client = self._client()
        raw_events = client.responses_stream(
            request_payload,
            timeout=timeout,
        )
        return AdminStream(
            raw_events,
            include_reasoning=True,
            admin_status_fn=lambda: client.admin_status,
            on_header=on_header,
        )

    def list(self, provider: str | None = None, *, status: str | None = None) -> list[str]:
        return sorted(self.discover(provider=provider, status=status))

    def discover(
        self,
        provider: str | None = None,
        *,
        status: str | None = None,
    ) -> dict[str, dict[str, Any]]:
        payload = self._client().get_models(provider=provider, status=status)

        data = payload.get("data")
        if not isinstance(data, list):
            raise ValueError("Gateway /v1/models response did not contain a 'data' list")

        discovered: dict[str, dict[str, Any]] = {}
        for entry in data:
            if not isinstance(entry, Mapping):
                continue
            model_id = entry.get("id")
            if not isinstance(model_id, str) or not model_id:
                continue
            discovered[model_id] = dict(entry)
        return discovered


__all__ = [
    "AdminMetrics",
    "AdminStream",
    "ModelBinding",
    "ModelInvocation",
    "ModelResult",
    "ModelsAPI",
    "Response",
]
