"""worldtree-sdk adapter — ratatoskr's single chokepoint onto the Conversation-API SDK.

Slice-1 foundation of the SDK cutover (issue #20;
`docs/contracts/worldtree_sdk_cutover.contract.md`). A thin adapter that owns ONE
`WorldtreeClient`, built over ratatoskr's own injected `httpx.AsyncClient`
transport, and translates the SDK's error floor into ratatoskr's caller-semantic
exceptions. Two hard invariants anchor it:

  * **INV-CUT-1** — ratatoskr owns the injected transport's lifecycle; the SDK is
    given it as `transport=` (so `_owns_client=False`) and MUST NOT close it.
  * **INV-CUT-2** — the adapter raises ratatoskr's caller-semantic exceptions; the
    ROUTE is the discriminator (never the error body).

This slice ships only the construction chokepoint (`build_client`) and the error
adapter's DEFAULT rule (`translate_error`: `ApiError` → `SessionApiFailed`, every
discriminated `WorldtreeError` subclass passing through unchanged). Route-specific
error rows (`AgentNotFound`, `InvalidCursor`, `AuthoredHistoryUnavailable`, ...)
and the CLI / web / TUI surface wiring land in later slices.

Transient-migration note (DEC-4, incremental cutover): a same-named
`SessionApiFailed` still lives in the retiring `ratatoskr.sessions` wrapper and
serves the old hand-rolled path until slice-2 deletes it. No surface wires the
adapter in this slice, so the two never meet at runtime; slice-2 reconciles them
(deletes the `sessions.py` copy, points presenters at this one, which additionally
carries the SDK's parsed `error_code`).
"""

from __future__ import annotations

import json
import math
import re
from collections.abc import AsyncGenerator, Mapping, Sequence
from typing import Any

import httpx
import worldtree_sdk as wtsdk
from worldtree_sdk import ApiError, AuthProvider, CancelResult, PadState, WorldtreeClient

# Transitional (slice-2/3): the caller-semantic exceptions + the BifrostBinding input
# type still live in the retiring `sessions` / `sse_client` modules; they relocate
# into this adapter as their call-sites are rewired in later slices. wt →
# sessions / sse_client is one-way (neither imports wt), so there is no cycle.
from .sessions import (
    AgentNotAvailable as PersonaAgentNotAvailable,
)
from .sessions import (
    AgentNotFound,
    AuthoredHistoryUnavailable,
    AuthScopeDenied,
    BifrostBinding,
    BifrostConsumerKeyMissing,
    BifrostHandshakeFailed,
    InvalidCursor,
    PersonaNotConfigured,
    Tier3AgentNotFound,
    Tier3FieldNotMutable,
    Tier3LayerDeferred,
    Tier3QuotaExceeded,
    Tier3UserIdUnsupported,
)
from .sse_client import (
    AdminEvent,
    AgentNotAvailable,
    CancelAlreadyCompleted,
    CancelFailed,
    CancelTurnNotFound,
    MalformedSseData,
    MalformedSseId,
    SseConnectFailed,
    SseConnectionDropped,
    TurnIdFlip,
    TurnLaunchUnavailable,
)


class SessionApiFailed(Exception):
    """The adapter's DEFAULT caller-semantic error (INV-CUT-2 default row): any SDK
    `ApiError` not mapped to a more specific ratatoskr exception surfaces here,
    carrying the SDK's cleanly-parsed `status` / `error_code` / `body` verbatim.
    The `body` is already UTF-8-byte-bounded and bearer-scrubbed by the SDK."""

    def __init__(self, *, status: int, error_code: str, body: str | None = None) -> None:
        super().__init__(
            f"worldtree API failed: status={status}, error_code={error_code!r}"
        )
        self.status = status
        self.error_code = error_code
        self.body = body


def build_client(
    base_url: str,
    *,
    api_key: AuthProvider,
    admin_key: AuthProvider | None = None,
    transport: httpx.AsyncClient,
    max_reconnects: int = 5,
) -> WorldtreeClient:
    """Construct the adapter's `WorldtreeClient` over a ratatoskr-owned transport.

    `transport` is REQUIRED and ratatoskr-owned: injecting it sets the SDK's
    `_owns_client=False`, so `WorldtreeClient.aclose()` never closes it — ratatoskr
    owns the lifecycle exactly as today (INV-CUT-1). ratatoskr's `api_key` /
    `admin_key` map to the SDK's per-request `auth` / `admin_auth` providers; the
    injected transport carries ratatoskr's User-Agent / timeout, and (transitionally)
    the default bearer — the SDK adds auth per request, overriding it.

    `max_reconnects` is the resilient turn-stream's reconnect budget (SDK default 5);
    pass 0 to surface a transport drop immediately without auto-resume.
    """
    return WorldtreeClient(
        base_url,
        auth=api_key,
        admin_auth=admin_key,
        transport=transport,
        max_reconnects=max_reconnects,
    )


def translate_error(exc: BaseException) -> BaseException:
    """Map an SDK exception to ratatoskr's caller-semantic exception (INV-CUT-2).

    Foundation scope — the § Error map DEFAULT plus discriminated passthrough:
      * SDK `ApiError` (the undiscriminated REST floor) → `SessionApiFailed`
        carrying `status` / `error_code` / `body`.
      * Every other exception — the SDK's discriminated `WorldtreeError` subclasses
        (`AgentNotAvailable`, `SessionRetired`, `ResumeError`, `Cancel*`, ...) and
        any non-SDK error — passes through by IDENTITY, unchanged.

    Route-specific rows (a 404 on `sessions.create` → `AgentNotFound`, a 404 on
    `sessions.write_history` → `AuthoredHistoryUnavailable`, ...) are the ROUTE's
    to add at its call-site in later slices, never inferred from the body here.
    """
    if isinstance(exc, ApiError):
        return SessionApiFailed(
            status=exc.status, error_code=exc.error_code, body=exc.body
        )
    return exc


# ── slice-2: sessions/turn adapter routes ────────────────────────────────────
# Ratatoskr-semantic call surfaces over `WorldtreeClient.sessions.*`. Each builds
# the request from ratatoskr's domain params, delegates the HTTP to the SDK, and
# maps the SDK's `ApiError` floor by ROUTE (INV-CUT-2) — route-specific rows first,
# `translate_error`'s `SessionApiFailed` default otherwise. Open-world reads are
# returned verbatim (the parity-pass posture: presenters read them as mappings,
# tolerant of wire drift). The turn STREAM + cancel land alongside the presenter
# rewire in the next slice-2 commit.


def _bifrost_error_from_body(body: str | None) -> str | None:
    """Pull the spec-level `bifrost_error` from a bound-create 502 body string.

    Tolerates both the FastAPI-nested `{"detail": {"bifrost_error": …}}` shape (the
    real wire form) and a flat top-level `bifrost_error` — the same both-shape
    unwrap the hand-rolled path used, adapted to the SDK's already-parsed str body.
    """
    if not body:
        return None
    try:
        err = json.loads(body)
    except (json.JSONDecodeError, ValueError):
        return None
    if not isinstance(err, dict):
        return None
    bifrost_error = err.get("bifrost_error")
    if bifrost_error is None and isinstance(err.get("detail"), dict):
        bifrost_error = err["detail"].get("bifrost_error")
    return bifrost_error


async def create_session(
    client: WorldtreeClient,
    agent_id: str,
    *,
    end_user_id: str | None = None,
    bifrost: BifrostBinding | None = None,
    consumer_key: str | None = None,
    config: Mapping[str, Any] | None = None,
) -> Mapping[str, Any]:
    """Create a session (POST /sessions), returning the open-world create result.

    Body-building mirrors the hand-rolled path: `{agent_id}` plus `end_user_id` /
    `config` / `bifrost` when set. A bound create authenticates with `consumer_key`
    via the SDK's per-request auth (never a header, never the canary fallback —
    INV-001); the key is required pre-HTTP. Error map (INV-CUT-2): 404 →
    `AgentNotFound`; a bound 502 → `BifrostHandshakeFailed`; otherwise the
    `SessionApiFailed` default.
    """
    assert agent_id and isinstance(agent_id, str)
    assert end_user_id is None or (isinstance(end_user_id, str) and end_user_id)
    assert config is None or isinstance(config, Mapping)
    # Ephemeral config + Bifrost binding are mutually exclusive (server 422s).
    assert not (config is not None and bifrost is not None)
    # INV-001: a bound create REQUIRES a non-empty consumer key — enforced pre-HTTP
    # so it never falls back to the canary bearer.
    if bifrost is not None and not (isinstance(consumer_key, str) and consumer_key):
        raise BifrostConsumerKeyMissing()

    body: dict[str, Any] = {"agent_id": agent_id}
    if end_user_id is not None:
        body["end_user_id"] = end_user_id
    if config is not None:
        body["config"] = dict(config)
    if bifrost is not None:
        body["bifrost"] = {"endpoint_url": bifrost.endpoint_url, "scope": bifrost.scope}

    try:
        # consumer_key is a BOUND-create credential only — never forward it on an
        # unbound create, or the SDK's credential precedence (consumer_key > default)
        # would authenticate as the Bifrost consumer instead of the default bearer.
        # Centralized here so both surfaces are guarded (the web endpoint already is).
        return await client.sessions.create(
            body, consumer_key=consumer_key if bifrost is not None else None
        )
    except ApiError as exc:
        if exc.status == 404:
            raise AgentNotFound(agent_id=agent_id) from exc
        # NOT gated on error_code (unlike list's 422+cursor_invalid): INV-002 — a 502
        # on a BOUND create IS the synchronous Bifrost handshake failing, the sole
        # bound-502 cause; and the SDK does not surface a distinguishing top-level
        # error_code here (its envelope parser prefers the nested `detail`, which
        # carries `bifrost_error`, not `error_code`). The nested bifrost_error is
        # extracted for the exception; the route+status is the discriminator.
        if bifrost is not None and exc.status == 502:
            raise BifrostHandshakeFailed(
                bifrost_error=_bifrost_error_from_body(exc.body),
                body=(exc.body or "").encode(),
            ) from exc
        raise translate_error(exc) from exc


async def list_sessions(
    client: WorldtreeClient,
    *,
    include_archived: bool = False,
    limit: int = 50,
    cursor: str | None = None,
) -> Mapping[str, Any]:
    """List sessions (GET /sessions), returning the open-world page verbatim. A 422
    `cursor_invalid` → `InvalidCursor`; otherwise the `SessionApiFailed` default."""
    assert 1 <= limit <= 200
    assert cursor is None or (isinstance(cursor, str) and cursor)
    try:
        return await client.sessions.list(
            limit=limit, cursor=cursor, include_archived=include_archived or None
        )
    except ApiError as exc:
        if exc.status == 422 and exc.error_code == "cursor_invalid":
            raise InvalidCursor(raw=cursor) from exc
        raise translate_error(exc) from exc


async def get_session_messages(
    client: WorldtreeClient, session_id: str
) -> Mapping[str, Any]:
    """The session's message history (GET /sessions/{id}/messages), verbatim. Any
    error → the `SessionApiFailed` default (owner-scoped; 404 hide-existence stays
    generic here — messages is not a hide-existence-mapped route)."""
    assert session_id and isinstance(session_id, str)
    try:
        return await client.sessions.messages(session_id)
    except ApiError as exc:
        raise translate_error(exc) from exc


async def get_session_tools(
    client: WorldtreeClient, session_id: str
) -> Mapping[str, Any]:
    """The owner-scoped tool inventory (GET /sessions/{id}/tools), verbatim. Any
    error → the `SessionApiFailed` default."""
    assert session_id and isinstance(session_id, str)
    try:
        return await client.sessions.tools(session_id)
    except ApiError as exc:
        raise translate_error(exc) from exc


async def stream_turn(
    client: WorldtreeClient, session_id: str, content: str
) -> AsyncGenerator[wtsdk.TurnEvent, None]:
    """Drive the resilient turn stream (auto-resume; absorbs the old `reconnect_turn`)
    and yield the SDK's `TurnEvent`s, re-wrapping the stream's TERMINAL SDK errors
    into ratatoskr's caller-semantic exceptions (INV-CUT-2 / DEC-2 — the presenter
    keeps catching ratatoskr's types).

    The SDK's `stream_turn` retries only the transport-drop class internally; a
    resume failure / protocol violation / connect failure surfaces unchanged
    (B-RES-6), and a drop that exhausts the reconnect budget surfaces as
    `ConnectionDropped`. The eager launch failures (`AgentNotAvailable` 409,
    `TurnLaunchUnavailable` 503) and `SessionRetired` 410 are subclasses of the
    SDK's `ConnectFailed`, so they are caught before the generic `ConnectFailed`.
    """
    try:
        async for event in client.sessions.stream_turn(session_id, content):
            yield event
    except wtsdk.SessionRetired as exc:
        # Fresh-mode 410 → the session is gone server-side; a generic API failure.
        raise SessionApiFailed(
            status=exc.status, error_code=exc.error_code, body=exc.message
        ) from exc
    except wtsdk.AgentNotAvailable as exc:
        raise AgentNotAvailable(
            body=(exc.message or "").encode(),
            error_code=exc.error_code,
            message=exc.message or "",
        ) from exc
    except wtsdk.TurnLaunchUnavailable as exc:
        raise TurnLaunchUnavailable(
            body=(exc.message or "").encode(),
            error_code=exc.error_code,
            message=exc.message or "",
        ) from exc
    except wtsdk.ConnectFailed as exc:
        raise SseConnectFailed(status=exc.status, body=(exc.message or "").encode()) from exc
    except wtsdk.ResumeError as exc:
        # A terminal resume failure (the resilient stream absorbs the retryable ones).
        raise SseConnectFailed(status=exc.status, body=(exc.message or "").encode()) from exc
    except wtsdk.ConnectionDropped as exc:
        raise SseConnectionDropped(last_seen_sse_id=exc.last_seen_sse_id) from exc
    except wtsdk.MalformedSseId as exc:
        raise MalformedSseId(raw=exc.raw) from exc
    except wtsdk.MalformedSseData as exc:
        raise MalformedSseData(raw=exc.raw) from exc
    except wtsdk.TurnIdFlip as exc:
        raise TurnIdFlip(established=exc.established, got=exc.got) from exc
    except ApiError as exc:
        # INV-CUT-2 default: an undiscriminated ApiError surfacing from the stream →
        # SessionApiFailed (the discriminated stream errors are handled above).
        raise translate_error(exc) from exc


async def cancel_turn(
    client: WorldtreeClient, session_id: str, turn_id: int, *, persist_partial: bool = False
) -> CancelResult:
    """Cancel a running turn (POST /sessions/{id}/turns/{turn_id}/cancel). Returns the
    SDK `CancelResult` (a 200 with `cancelled=False` is the benign late-cancel race,
    not an error). The typed cancel races map onto ratatoskr's same-named exceptions
    (DEC-2): 404 `turn_not_found` → `CancelTurnNotFound`, 409 `turn_finished` →
    `CancelAlreadyCompleted`, any other cancel failure → `CancelFailed`."""
    assert session_id and isinstance(session_id, str)
    assert isinstance(turn_id, int) and turn_id > 0
    try:
        return await client.sessions.cancel_turn(
            session_id, turn_id, persist_partial=persist_partial
        )
    except wtsdk.CancelTurnNotFound as exc:
        raise CancelTurnNotFound(turn_id=turn_id) from exc
    except wtsdk.CancelAlreadyCompleted as exc:
        raise CancelAlreadyCompleted(turn_id=turn_id) from exc
    except wtsdk.CancelError as exc:
        raise CancelFailed(
            status=0, body=(getattr(exc, "message", "") or str(exc)).encode()
        ) from exc
    except ApiError as exc:
        # INV-CUT-2 default: an undiscriminated ApiError on this route → SessionApiFailed.
        raise translate_error(exc) from exc


# ── slice-3: persona + authored-history adapter routes ───────────────────────
# The session-scoped affect write (set_persona_state) and the #347 authored-history
# write (write_authored_history). The SDK owns the wire shapes — the canonical
# `{"pad": {...}}` persona body via `PadState`, and the authored-write entry — so
# ratatoskr no longer hand-builds either. Error map (INV-CUT-2): persona has no row
# beyond the default; authored-history's 404 is the sole hide-existence route
# (AuthoredHistoryUnavailable), everything else the SessionApiFailed default.


async def set_persona_state(
    client: WorldtreeClient,
    session_id: str,
    *,
    pleasure: float,
    arousal: float,
    dominance: float,
) -> None:
    """Set a session's PAD persona state (POST /sessions/{id}/persona_state, W-7).

    The adapter builds the canonical `PadState`; the SDK owns the wire wrapper
    (`{"pad": {pleasure, arousal, dominance}}`, prose-pinned #317) — ratatoskr no
    longer hand-assembles it. Resolves on 204 (→ None). Error map (INV-CUT-2): no
    route-specific row → the `SessionApiFailed` default.

    Finiteness is enforced HERE at the chokepoint (not only at the CLI): a
    non-finite axis would serialize to `null` and corrupt the injection, and the SDK
    raises `ConfigurationError` pre-HTTP — the precondition asserts it so any caller
    gets a clean ratatoskr-side rejection, never a leaked SDK error. (The CLI surface
    additionally pre-validates for a friendly usage error.)
    """
    assert session_id and isinstance(session_id, str)
    assert all(math.isfinite(v) for v in (pleasure, arousal, dominance))
    try:
        await client.sessions.set_persona_state(
            session_id, PadState(pleasure=pleasure, arousal=arousal, dominance=dominance)
        )
    except ApiError as exc:
        raise translate_error(exc) from exc


async def write_authored_history(
    client: WorldtreeClient,
    session_id: str,
    *,
    content: str,
    idempotency_key: str,
) -> Mapping[str, Any]:
    """Write one authored assistant turn into the session ledger (POST
    /sessions/{id}/history, #347) — the durable first-message primitive.

    v1 accepts only `author="assistant"` (INV-347-4), so the adapter fixes it; the
    caller supplies `content` + the per-content `idempotency_key` (REQUIRED, never
    SDK-generated — a replay with the same (session, key) is an idempotent 200).
    Returns the open-world `AuthoredTurn` ack verbatim. Error map (INV-CUT-2): a 404
    → `AuthoredHistoryUnavailable` (the ROUTE is the discriminator — hide-existence,
    never body-sniffed: feature-absent / ungranted / session-absent are one 404 by
    design, server INV-347-1); every other `ApiError` (notably 409 generation_active,
    422 validation) → the `SessionApiFailed` default.
    """
    assert session_id and isinstance(session_id, str)
    assert content and isinstance(content, str)
    assert idempotency_key and isinstance(idempotency_key, str)
    try:
        return await client.sessions.write_history(
            session_id,
            {"author": "assistant", "content": content, "idempotency_key": idempotency_key},
        )
    except ApiError as exc:
        if exc.status == 404:
            raise AuthoredHistoryUnavailable(session_id=session_id) from exc
        raise translate_error(exc) from exc


# ── slice-4: agents (Tier-3) adapter routes ──────────────────────────────────
# Ratatoskr-semantic surfaces over `WorldtreeClient.agents.*` (list/persona_state/
# define/patch/delete). The SDK returns open-world dicts (parity posture — read as
# mappings, never normalized into a frozen dataclass) and surfaces the tier3/persona
# failure modes on its undiscriminated `ApiError` floor; the adapter maps them by the
# ROUTE + (status, error_code) per the § Error map (INV-CUT-2). The `model`→`role`
# cutover folds in here: the define/patch responses echo `role` (spec 1.2 / b128), so
# callers read `info["role"]` off the open-world dict — no `Tier3AgentInfo` survives.

# Consumer-agent slug (spec §2627): agent_name is `[a-z][a-z0-9-]{2,63}`.
_AGENT_SLUG_RE = re.compile(r"^[a-z][a-z0-9-]{2,63}$")


def _error_field_from_body(body: str | None) -> str | None:
    """Pull the envelope's `field` from an SDK `ApiError` body string.

    The SDK's `ApiError` carries the parsed `error_code` but NOT the envelope's
    `field`, so the field-bearing tier3 rejections (`layer_deferred`,
    `field_not_mutable`) body-parse it here — same both-shape unwrap as
    `_bifrost_error_from_body`, tolerant of `{"detail": {"field": …}}` and a flat
    top-level `field`. Returns None on any parse failure (the exception still carries
    a None field, exactly as the hand-rolled path did on shape mismatch).
    """
    if not body:
        return None
    try:
        err = json.loads(body)
    except (json.JSONDecodeError, ValueError):
        return None
    if not isinstance(err, dict):
        return None
    field = err.get("field")
    if field is None and isinstance(err.get("detail"), dict):
        field = err["detail"].get("field")
    # The exception surface declares `field: str | None` (and the CLI prints it), so a
    # non-string envelope value (`{"field": {…}}` / `{"field": 1}`) collapses to None —
    # same guard the retired hand-rolled `_extract_error_field` applied (heid-bug-hunt).
    return field if isinstance(field, str) else None


async def list_agents(client: WorldtreeClient) -> Sequence[Mapping[str, Any]]:
    """List the caller's agents (GET /agents) as the SDK's open-world array, verbatim
    (parity: each item read as a mapping, tolerant of wire drift — no `AgentInfo`
    normalization). Any error → the `SessionApiFailed` default."""
    try:
        return await client.agents.list()
    except ApiError as exc:
        raise translate_error(exc) from exc


async def get_persona_state(client: WorldtreeClient, agent_id: str) -> Mapping[str, Any]:
    """Fetch an agent's persona snapshot (GET /agents/{id}/persona_state, WT #204),
    open-world dict verbatim. Error map (INV-CUT-2 — dual-key status+error_code): 404
    `persona_not_configured` → `PersonaNotConfigured`; 404 `agent_not_available` →
    `AgentNotAvailable` (the persona-surface variant); 403 `auth_scope_denied` →
    `AuthScopeDenied`; every other error → the `SessionApiFailed` default. A 404 with
    an unrecognized code stays generic — the error_code is the discriminator, never a
    bare 404→hidden (this route is NOT hide-existence)."""
    assert agent_id and isinstance(agent_id, str)
    try:
        return await client.agents.persona_state(agent_id)
    except ApiError as exc:
        if exc.status == 404 and exc.error_code == "persona_not_configured":
            raise PersonaNotConfigured(agent_id=agent_id) from exc
        if exc.status == 404 and exc.error_code == "agent_not_available":
            raise PersonaAgentNotAvailable(agent_id=agent_id) from exc
        if exc.status == 403 and exc.error_code == "auth_scope_denied":
            raise AuthScopeDenied(scope="persona.read") from exc
        raise translate_error(exc) from exc


async def define_agent(
    client: WorldtreeClient, *, agent_name: str, system_prompt: str, role: str
) -> Mapping[str, Any]:
    """Define a Tier-3 consumer agent (POST /agents/define). Sends the
    `AgentDefineInput` body `{agent_name, role, system_prompt}` and returns the SDK's
    open-world `DefinedAgent` dict verbatim (echoes `role` post-b128 — read
    `info["role"]`, no `Tier3AgentInfo`). The slug is validated client-side pre-HTTP
    (server-side is the safety net). Error map (INV-CUT-2): 429 →
    `Tier3QuotaExceeded(retry_after=0)` — the SDK's `ApiError` floor drops the
    `Retry-After` header, and §2675 pins Phase-2.0 quota to 0; 403
    `tier3_user_id_unsupported` → `Tier3UserIdUnsupported`; 422 `layer_deferred` →
    `Tier3LayerDeferred(field)`; else the `SessionApiFailed` default."""
    assert _AGENT_SLUG_RE.match(agent_name), (
        f"agent_name must match [a-z][a-z0-9-]{{2,63}}: {agent_name!r}"
    )
    assert system_prompt and isinstance(system_prompt, str)
    assert role and isinstance(role, str)
    try:
        # Inline literal so it type-checks structurally against the SDK's
        # AgentDefineInput TypedDict (no import of the SDK's private `_types`).
        return await client.agents.define(
            {"agent_name": agent_name, "role": role, "system_prompt": system_prompt}
        )
    except ApiError as exc:
        if exc.status == 429:
            raise Tier3QuotaExceeded(retry_after=0) from exc
        if exc.status == 403 and exc.error_code == "tier3_user_id_unsupported":
            raise Tier3UserIdUnsupported() from exc
        if exc.status == 422 and exc.error_code == "layer_deferred":
            raise Tier3LayerDeferred(field=_error_field_from_body(exc.body)) from exc
        raise translate_error(exc) from exc


async def patch_agent(
    client: WorldtreeClient,
    agent_id: str,
    *,
    system_prompt: str | None = None,
    role: str | None = None,
) -> Mapping[str, Any]:
    """Mutate a Tier-3 agent (PATCH /agents/{id}). At least one of `system_prompt` /
    `role` is required; the None-valued field is omitted from the body. Returns the
    open-world `PatchedAgent` dict verbatim. Error map (INV-CUT-2): 404 →
    `Tier3AgentNotFound` (agents CRUD is NOT hide-existence); 422 `field_not_mutable`
    → `Tier3FieldNotMutable(field)`; else the `SessionApiFailed` default."""
    assert ":" in agent_id, f"tier 3 agent_id must contain ':': {agent_id!r}"
    assert system_prompt is not None or role is not None, (
        "patch requires at least one of system_prompt or role"
    )
    changes: dict[str, Any] = {}
    if system_prompt is not None:
        changes["system_prompt"] = system_prompt
    if role is not None:
        changes["role"] = role
    try:
        return await client.agents.patch(agent_id, changes)
    except ApiError as exc:
        if exc.status == 404:
            raise Tier3AgentNotFound(agent_id=agent_id) from exc
        if exc.status == 422 and exc.error_code == "field_not_mutable":
            raise Tier3FieldNotMutable(field=_error_field_from_body(exc.body)) from exc
        raise translate_error(exc) from exc


async def delete_agent(client: WorldtreeClient, agent_id: str) -> None:
    """Hard-delete a Tier-3 agent (DELETE /agents/{id}); resolves on 204 → None. Error
    map (INV-CUT-2): 404 → `Tier3AgentNotFound` (route-discriminated; NOT
    hide-existence); else the `SessionApiFailed` default."""
    assert ":" in agent_id, f"tier 3 agent_id must contain ':': {agent_id!r}"
    try:
        await client.agents.delete(agent_id)
    except ApiError as exc:
        if exc.status == 404:
            raise Tier3AgentNotFound(agent_id=agent_id) from exc
        raise translate_error(exc) from exc


# ── slice-5: characters + me/capabilities/models adapter routes ───────────────
# The remaining consumer READS + transient-character CRUD over `client.me` /
# `client.capabilities` / `client.models` / `client.characters.*`. All six are
# open-world reads/acks (B-OPEN-2) returned verbatim; NONE carries a discriminated
# error on the SDK floor (no `map_error`), so each maps any `ApiError` → the
# `SessionApiFailed` default (INV-CUT-2) — exact parity with the retiring hand-rolled
# path, which never discriminated a status/code on these routes. No new § Error map
# rows. CLI-only: `--whoami` (me + capabilities) and `--characters` (models + CRUD);
# no web-server caller this slice.


async def get_me(client: WorldtreeClient) -> Mapping[str, Any]:
    """The caller's identity + key metadata (GET /me), open-world dict verbatim.

    The boot whoami — verifies the key with no agent-config side effects. Any error →
    the `SessionApiFailed` default (notably 401 on a bad/absent key when auth is on).
    """
    try:
        return await client.me.get()
    except ApiError as exc:
        raise translate_error(exc) from exc


async def get_capabilities(client: WorldtreeClient) -> Mapping[str, Any]:
    """The server capability advertisement (GET /capabilities), open-world verbatim.

    Any authenticated caller may read it (no scope). Any error → the `SessionApiFailed`
    default. The `--whoami` renderer degrades on a malformed advertisement rather than
    crashing (`_format_whoami`, already hardened).
    """
    try:
        return await client.capabilities.get()
    except ApiError as exc:
        raise translate_error(exc) from exc


async def list_character_models(client: WorldtreeClient) -> Mapping[str, Any]:
    """The character-capable model catalog (GET /models/available-for-characters, #161),
    open-world dict verbatim. Requires `character.read`. Any error → the
    `SessionApiFailed` default."""
    try:
        return await client.models.available_for_characters()
    except ApiError as exc:
        raise translate_error(exc) from exc


async def create_character(
    client: WorldtreeClient,
    character: Mapping[str, Any],
    *,
    state: Mapping[str, Any] | None = None,
) -> Mapping[str, Any]:
    """Create a transient character (POST /characters, #161). Requires `character.write`.

    The body is `{character}` plus `state` ONLY when supplied — the SDK forwards the
    dict as-is, so ratatoskr omits the hand-rolled path's redundant explicit
    `state: null` (server-equivalent — Worldtree's `CreateCharacterRequest.state`
    defaults None whether omitted or explicit-null; SDK-idiomatic). Returns the
    open-world create ACK verbatim (`{character_id, ttl_expires_at, ...}`). Any error →
    the `SessionApiFailed` default (notably 403 when the key lacks `character.write`).
    """
    assert isinstance(character, Mapping) and character
    try:
        # Inline literals (per branch) so each type-checks structurally against the
        # SDK's `CreateCharacterInput` TypedDict (`character` required, `state`
        # NotRequired) without importing the SDK's private `_types` — same posture as
        # `define_agent`. `state` is present ONLY when supplied (no redundant null).
        if state is not None:
            return await client.characters.create(
                {"character": dict(character), "state": dict(state)}
            )
        return await client.characters.create({"character": dict(character)})
    except ApiError as exc:
        raise translate_error(exc) from exc


async def get_character_state(
    client: WorldtreeClient, character_id: str
) -> Mapping[str, Any]:
    """The character's live runtime state (GET /characters/{id}/state, #161), open-world
    dict verbatim; the read refreshes the character's TTL. Requires `character.read`.
    Any error → the `SessionApiFailed` default."""
    assert character_id and isinstance(character_id, str)
    try:
        return await client.characters.state(character_id)
    except ApiError as exc:
        raise translate_error(exc) from exc


async def delete_character(
    client: WorldtreeClient, character_id: str
) -> Mapping[str, Any] | None:
    """Delete a transient character (DELETE /characters/{id}, #161). Requires
    `character.write`; bound sessions detach (next turn → 410 `character_not_found`).

    Returns the SDK's open-world delete ACK verbatim (`CharacterDeleteResult` —
    Worldtree returns a body here, NOT 204) rather than normalizing to the hand-rolled
    `None` (parity: no None-normalization of an open-world read). A 204 no-content
    yields `None`, hence the `Mapping | None` return; the sole call-site ignores it.
    Any error → the `SessionApiFailed` default.
    """
    assert character_id and isinstance(character_id, str)
    try:
        return await client.characters.delete(character_id)
    except ApiError as exc:
        raise translate_error(exc) from exc


# ── slice-6: admin (bifrost inspection + admin-events stream) adapter routes ──
# The admin surface over `client.admin.*` — admin_auth-scoped (set via
# `build_client(admin_key=...)`, NOT a per-call `Authorization` header). Both are
# web-only. `get_session_bifrost` reads the open-world `BifrostInspection` verbatim
# (any error → the `SessionApiFailed` default); `stream_admin_events` drives the
# long-lived D2 admin-events SSE, re-wrapping the SDK's `AdminEvent` → ratatoskr's
# (degrading the SDK's `admin_id`-nan / None `type`/`data` at the boundary so the web
# filter never crashes) and re-wrapping the stream's terminal errors → ratatoskr's
# `Sse*` types (INV-CUT-2 stream rows).


async def get_session_bifrost(
    client: WorldtreeClient, session_id: str
) -> Mapping[str, Any]:
    """The admin-scoped Bifrost dispatch state for a session (GET
    /admin/sessions/{id}/bifrost, #176), open-world dict verbatim.

    Admin-tier — the client MUST carry `admin_auth` (built with `admin_key`); the SDK
    uses that provider, not a per-call header. Any error → the `SessionApiFailed`
    default (notably 403 `auth_scope_denied`, 404 `session_not_bifrost_bound`) — the
    retired hand-rolled path likewise mapped every non-200 generically.
    """
    assert session_id and isinstance(session_id, str)
    try:
        return await client.admin.sessions.bifrost(session_id)
    except ApiError as exc:
        raise translate_error(exc) from exc


async def stream_admin_events(
    client: WorldtreeClient, *, last_event_id: int | None = None
) -> AsyncGenerator[AdminEvent, None]:
    """Drive the long-lived admin-events SSE (GET /admin/events, #11 / INV-046) and yield
    ratatoskr `AdminEvent`s, re-wrapping the SDK's typed `AdminEvent` at the boundary.

    Admin-tier (the client MUST carry `admin_auth`). The SDK's `AdminEvent` is open-world
    where ratatoskr's is stable: `admin_id` is `nan` for an id-less envelope (→ `id=0`),
    and `type`/`data` may be None (→ `""` / `{}`) — normalized HERE so the web filter +
    formatter (`ev.id` / `ev.type` / `ev.data`) never crash on a partial wire (chosen over
    yielding SDK events through + rewiring the web filter). Error map (INV-CUT-2, stream
    rows): SDK `ConnectFailed` (a connect-time transport / auth-resolution failure — the
    SDK's general transport floor) → `SseConnectFailed`; SDK `ApiError` (a non-200 open,
    `admin_stream_failed`) → `SseConnectFailed`; SDK `ConnectionDropped` (a connect-time
    transport failure → cursor None, OR a mid-stream drop / the long-lived stream's
    resumable EOF → cursor) → `SseConnectionDropped`. The SDK stream is best-effort (skips
    malformed frames — no `Malformed*`).
    """
    try:
        async for ev in client.admin.stream_events(last_event_id=last_event_id):
            yield AdminEvent(
                id=ev.admin_id if isinstance(ev.admin_id, int) else 0,
                # isinstance-guard `type` (not `or ""`): a truthy NON-str type (123, a
                # list from a partial wire) would otherwise reach `.startswith` in the
                # web filter → AttributeError (heid bug-hunt slice-6; match admin_id/data).
                type=ev.type if isinstance(ev.type, str) else "",
                timestamp=ev.timestamp,
                data=dict(ev.data) if isinstance(ev.data, Mapping) else {},
            )
    except wtsdk.ConnectionDropped as exc:
        raise SseConnectionDropped(last_seen_sse_id=exc.last_seen_sse_id) from exc
    except wtsdk.ConnectFailed as exc:
        # A connect-time transport / auth-resolution failure surfaces as ConnectFailed
        # (the SDK's general transport floor) — map it → SseConnectFailed, mirroring
        # stream_turn. The web gen catches the Sse* types, so an unmapped ConnectFailed
        # would escape and abort the SSE with no labeled stream_error (heid bug-hunt).
        raise SseConnectFailed(status=exc.status, body=(exc.message or "").encode()) from exc
    except ApiError as exc:
        # A non-200 open raises ApiError("admin_stream_failed", status=…) → SseConnectFailed.
        raise SseConnectFailed(status=exc.status, body=(exc.body or "").encode()) from exc
