"""Starlette app factory + endpoint handlers for ratatoskr.web.

Per docs/contracts/issues/16.contract.md. INV-002: create_app accepts
a client_factory callable; the factory produces a configured
httpx.AsyncClient. Tests pass a respx-mocked factory; production
passes a factory that bakes in WORLDTREE_API_URL + WORLDTREE_API_KEY.
"""

from __future__ import annotations

import asyncio
import itertools
import json
from collections.abc import AsyncIterator, Callable, Mapping
from dataclasses import asdict, dataclass, is_dataclass
from importlib.metadata import version as _pkg_version

import httpx
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import (
    FileResponse,
    JSONResponse,
    Response,
    StreamingResponse,
)
from starlette.routing import Mount, Route
from starlette.staticfiles import StaticFiles
from worldtree_sdk import (
    CancelledEvent,
    ConnectFailed,
    DoneEvent,
    ErrorEvent,
    WorldtreeClient,
)

from ratatoskr import local_agents as _local_agents
from ratatoskr import wt
from ratatoskr.first_message import seed_preset_first_message
from ratatoskr.sessions import (
    AgentNotAvailable,
    AgentNotFound,
    AuthScopeDenied,
    BifrostBinding,
    BifrostConsumerKeyMissing,
    BifrostHandshakeFailed,
    PersonaNotConfigured,
    endpoint_for_plane,
)

# The turn path (create / stream / cancel / tools / messages), the agents /
# persona-state reads, AND the admin surface (bifrost inspection + admin-events stream)
# are all served by the worldtree-sdk adapter (`wt.*`), which raises ratatoskr's
# caller-semantic exceptions (DEC-2). `AdminEvent` is still ratatoskr's domain event
# type the adapter re-wraps into (imported from `sse_client` until slice-7 teardown).
from ratatoskr.sse_client import (
    AdminEvent,
    CancelAlreadyCompleted,
    CancelFailed,
    CancelTurnNotFound,
    MalformedSseData,
    MalformedSseId,
    SseConnectFailed,
    SseConnectionDropped,
    SseResponseTooLarge,
    TurnIdFlip,
)
from ratatoskr.tts import (
    ZONOS_TTS_URL,
    PadState,
    TtsUnavailable,
    pad_to_dials,
    tts_stream_long,
)


def _wt_client(
    client: httpx.AsyncClient, *, admin_key: str | None = None, max_reconnects: int = 5
) -> WorldtreeClient:
    """Wrap a client_factory transport as the adapter's WorldtreeClient (INV-CUT-1:
    the SDK never closes it). base_url + bearer are read off the transport (the
    factory bakes them in); the SDK re-applies auth per request, so the extracted
    key just mirrors the transport's default. A no-auth test transport falls back to
    a placeholder key (respx ignores auth).

    `admin_key` is the SERVER-HELD admin credential (slice-6): the SDK's `admin.*`
    routes authenticate with the client's `admin_auth`, NOT a per-call header, so an
    admin endpoint passes it here. Omitted for the default-tier reads."""
    base_url = str(client.base_url) or "http://localhost"
    header = client.headers.get("Authorization", "")
    # Case-insensitive scheme + tolerant of extra whitespace, so a valid bearer is
    # not silently dropped to the placeholder key (which would misauthenticate).
    parts = header.split(None, 1)
    api_key = parts[1].strip() if len(parts) == 2 and parts[0].lower() == "bearer" else ""
    return wt.build_client(
        base_url,
        api_key=api_key or "ratatoskr",
        admin_key=admin_key,
        transport=client,
        max_reconnects=max_reconnects,
    )


def _static_dir() -> str:
    """Locate the bundled static/ directory inside the installed package.

    Uses importlib.resources so the lookup works for editable installs,
    wheel installs, and uvicorn's worker reload. Per INV-009 packaging:
    static/index.html ships in the wheel.
    """
    from importlib.resources import files
    return str(files("ratatoskr.web") / "static")


def _root_endpoint(request: Request) -> FileResponse:
    """GET / → index.html. Per FN root_endpoint POST-001.

    no-store: this is an actively-iterated dev console — never let a browser serve a
    stale cached page (which silently hid the streaming-TTS / autoplay-unlock updates
    during debugging). The HTML is small; a re-fetch per load is free on the LAN."""
    from pathlib import Path
    return FileResponse(
        Path(_static_dir()) / "index.html",
        media_type="text/html",
        headers={"Cache-Control": "no-store"},
    )


def _version_endpoint(request: Request) -> JSONResponse:
    """GET /version → {"ratatoskr": "<version>"}.

    Per FN version_endpoint POST-001.
    """
    return JSONResponse({"ratatoskr": _pkg_version("ratatoskr")}, status_code=200)


def _as_dict(obj: object) -> dict:
    """Best-effort dataclass-to-dict for AgentInfo / LocalAgentEntry."""
    if is_dataclass(obj):
        return asdict(obj)
    return dict(obj)  # type: ignore[arg-type]


async def _agents_endpoint(request: Request) -> JSONResponse:
    """GET /api/agents → upstream /agents + local Tier 3 index merge.

    Per FN agents_endpoint POST-001 + ERRORS table.
    """
    client_factory = request.app.state.client_factory
    try:
        async with client_factory() as client:
            upstream = await wt.list_agents(_wt_client(client))
    except wt.SessionApiFailed as exc:
        return JSONResponse(
            {"error_code": "session_api_failed", "status": exc.status},
            status_code=exc.status,
        )
    except (httpx.RequestError, ConnectFailed) as exc:
        # The SDK normalizes a transport failure to ConnectFailed(status=0), not a
        # raw httpx error; both surface the same network envelope (slice-3 foot-gun).
        return JSONResponse(
            {"error_code": "network_error", "message": str(exc)},
            status_code=502,
        )
    # Open-world upstream (parity: no AgentInfo normalization). Degrade, never crash:
    # a non-list envelope OR a malformed item (missing/non-str agent_id, non-mapping)
    # is dropped rather than KeyError/TypeError'ing the endpoint into a 500 before the
    # local fallback merges (heid-bug-hunt: the "open-world reads degrade" invariant).
    upstream_items = upstream if isinstance(upstream, list) else []
    well_formed = [
        a for a in upstream_items
        if isinstance(a, Mapping) and isinstance(a.get("agent_id"), str)
    ]
    upstream_ids = {a["agent_id"] for a in well_formed}
    local = _local_agents.load_local_agents()
    merged = [_as_dict(a) for a in well_formed] + [
        _as_dict(le) for le in local if le.agent_id not in upstream_ids
    ]
    return JSONResponse(merged, status_code=200)


async def _create_session_endpoint(request: Request) -> JSONResponse:
    """POST /api/sessions → upstream POST /sessions. Per FN create_session_endpoint.

    v0.16.0: end_user_id is SERVER-configured (app.state.end_user_id from
    RATATOSKR_END_USER_ID), never read from the browser body. A client
    cannot impersonate an arbitrary end-user partition.
    """
    body = await request.json()
    agent_id = body.get("agent_id") if isinstance(body, dict) else None
    if not agent_id:
        return JSONResponse({"error_code": "missing_agent_id"}, status_code=400)
    end_user_id = request.app.state.end_user_id
    client_factory = request.app.state.client_factory

    # Issue #17 (web bind split): the browser may select a PLANE; the server holds
    # the consumer key + visible host and constructs the binding. The consumer key
    # NEVER reaches the browser (INV-008/INV-009).
    bifrost: BifrostBinding | None = None
    bifrost_plane = body.get("bifrost_plane") if isinstance(body, dict) else None
    consumer_key = request.app.state.bifrost_consumer_key
    visible_host = request.app.state.bifrost_visible_host
    if bifrost_plane:
        if bifrost_plane not in ("memory", "affect", "combined"):
            return JSONResponse(
                {"error_code": "invalid_bifrost_plane"}, status_code=400
            )
        if not (consumer_key and visible_host):
            return JSONResponse(
                {"error_code": "bifrost_not_configured"}, status_code=400
            )
        bifrost = BifrostBinding(
            endpoint_url=endpoint_for_plane(bifrost_plane, visible_host)
        )

    try:
        async with client_factory() as client:
            wt_client = _wt_client(client)
            info = await wt.create_session(
                wt_client,
                agent_id,
                end_user_id=end_user_id,
                bifrost=bifrost,
                consumer_key=consumer_key if bifrost else None,
            )
            # #347 authored first-message: seed the agent's preset opening (best-effort;
            # never blocks create). Routed through the wt adapter (slice-3).
            await seed_preset_first_message(wt_client, info["session_id"], agent_id)
    except AgentNotFound:
        return JSONResponse({"error_code": "agent_not_found"}, status_code=404)
    except BifrostConsumerKeyMissing:
        # Server misconfiguration: a plane was requested but no consumer key.
        return JSONResponse(
            {"error_code": "bifrost_not_configured"}, status_code=400
        )
    except BifrostHandshakeFailed as exc:
        return JSONResponse(
            {
                "error_code": "bifrost_handshake_failed",
                "bifrost_error": exc.bifrost_error,
            },
            status_code=502,
        )
    except wt.SessionApiFailed as exc:
        return JSONResponse(
            {"error_code": "session_api_failed", "status": exc.status},
            status_code=exc.status,
        )
    # The adapter returns the SDK's open-world create dict; the browser reads it as-is.
    payload = dict(info)
    if bifrost is not None:
        # Bound-state for the UI indicator — plane + endpoint only, never the key.
        payload["bifrost"] = {
            "plane": bifrost_plane,
            "endpoint": bifrost.endpoint_url,
            "status": "bound",
        }
    return JSONResponse(payload, status_code=201)


@dataclass
class TurnHandle:
    """In-flight turn record stored in app.state.turn_registry.

    Per FN submit_turn_endpoint + INV-005/006/007.

    v0.16.0: `upstream_turn_id` captures Worldtree's server-assigned
    turn_id (from the first SSE event's sse_id.turn_id) once the stream
    opens. Cancel paths target THIS, not the browser-local `turn_id` —
    the local counter is only a registry key. None until the first
    upstream event arrives; cancel before then is a no-op (nothing to
    cancel upstream yet).
    """

    session_id: str
    turn_id: int
    content: str
    status: str = "queued"  # queued | streaming | done | error | cancelled
    upstream_turn_id: int | None = None


# Process-local monotonic turn_id counter. Per FN submit_turn_endpoint
# STEPS 2: turn_id is opaque to the upstream Worldtree (whose own
# turn_ids come back via SSE); the registry's key uses our own counter
# so cancel/stream lookups don't need upstream-issued ids.
_TURN_COUNTER = itertools.count(1)


async def _submit_turn_endpoint(request: Request) -> JSONResponse:
    """POST /api/turns/{session_id} → allocate turn_id + register handle.

    Per FN submit_turn_endpoint. Does NOT open the upstream stream here;
    the subsequent GET /api/turns/{sid}/stream does that.
    """
    body = await request.json()
    content = body.get("content") if isinstance(body, dict) else None
    if not content or not isinstance(content, str):
        # Require a non-blank STRING; a truthy non-str (e.g. {"content": {...}}) gets a
        # deterministic 400 rather than reaching the SDK turn call as a bad type.
        return JSONResponse({"error_code": "missing_content"}, status_code=400)
    session_id = request.path_params["session_id"]
    turn_id = next(_TURN_COUNTER)
    request.app.state.turn_registry[(session_id, turn_id)] = TurnHandle(
        session_id=session_id, turn_id=turn_id, content=content,
    )
    return JSONResponse({"turn_id": turn_id}, status_code=200)


def _event_to_browser_payload(event: object) -> tuple[str, dict]:
    """Serialize an SDK `TurnEvent` to (browser_event_type, json_dict).

    Per INV-008 + FN stream_turn_endpoint STEP 3. The browser contract
    (tests/fixtures/presentation_contract.json) is preserved: the SDK's `raw` is
    the wire body — the same per-type field set the old dataclasses carried — so the
    payload is `raw` minus the redundant `type`, plus the composite `sse_id` string
    (already "T:S"). The browser event_type is the wire `type` ("text" / "done" /
    …), NOT the SDK class name. Open-world: additive server fields pass through.
    """
    browser_type = getattr(event, "type", "") or ""
    raw = getattr(event, "raw", None)
    # Open-world: degrade a non-mapping `raw` to an empty payload rather than letting
    # dict(raw) raise (which would abort the SSE stream mid-response).
    src = raw if isinstance(raw, Mapping) else {}
    data = {k: v for k, v in src.items() if k != "type"}
    data["sse_id"] = getattr(event, "sse_id", None)
    return browser_type, data


def _format_sse(event_type: str, data: dict) -> bytes:
    """Format a browser-facing SSE event with `event:` + `data:`.

    Two-newline terminator per the SSE spec.
    """
    return f"event: {event_type}\ndata: {json.dumps(data)}\n\n".encode()


def _turn_id_from_sse_id(sse_id: object) -> int | None:
    """The turn component of the SDK's composite sse_id (`"{turn}:{seq}"`) — the
    upstream cancel target, present on every frame (the SDK's top-level `turn_id` is
    the body field, absent on text/thinking events)."""
    if not isinstance(sse_id, str):
        return None
    head, _, _ = sse_id.partition(":")
    try:
        turn = int(head)
    except ValueError:
        return None
    return turn if turn > 0 else None


async def _stream_turn_endpoint(request: Request) -> StreamingResponse:
    """GET /api/turns/{session_id}/stream?turn_id=N → proxy upstream SSE.

    Per FN stream_turn_endpoint. Handles browser-disconnect cleanup
    (INV-005) and synthesizes `event: error` for upstream typed
    exceptions.
    """
    session_id = request.path_params["session_id"]
    try:
        turn_id = int(request.query_params["turn_id"])
    except (KeyError, ValueError):
        return JSONResponse({"error_code": "missing_turn_id"}, status_code=400)
    registry = request.app.state.turn_registry
    handle = registry.get((session_id, turn_id))
    if handle is None:
        return JSONResponse({"error_code": "turn_not_found"}, status_code=404)

    client_factory = request.app.state.client_factory

    async def gen() -> AsyncIterator[bytes]:
        client = client_factory()
        wt_client = _wt_client(client)
        try:
            handle.status = "streaming"
            try:
                async for event in wt.stream_turn(wt_client, session_id, handle.content):
                    # v0.16.0: capture the upstream (Worldtree-assigned) turn_id from
                    # the first event so cancel paths target the real upstream turn,
                    # not our local counter — parsed from the composite sse_id.
                    if handle.upstream_turn_id is None:
                        handle.upstream_turn_id = _turn_id_from_sse_id(
                            getattr(event, "sse_id", None)
                        )
                    event_type, data = _event_to_browser_payload(event)
                    yield _format_sse(event_type, data)
                    if isinstance(event, (DoneEvent, ErrorEvent, CancelledEvent)):
                        handle.status = event.type or "done"
                        break
            except (wt.SessionApiFailed, SseConnectFailed, SseConnectionDropped,
                    SseResponseTooLarge, MalformedSseId, MalformedSseData, TurnIdFlip) as exc:
                # wt.SessionApiFailed covers the adapter's SessionRetired (410) mapping;
                # without it a retired-session stream would escape gen() after partial
                # frames as an uncaught 500, not a labeled `event: error`.
                yield _format_sse(
                    "error",
                    {"exception": type(exc).__name__, "message": str(exc)},
                )
                handle.status = "error"
            except asyncio.CancelledError:
                # Browser disconnect path (INV-005). Cancel the UPSTREAM
                # turn (if it started) — never the local turn_id.
                if handle.status == "streaming" and handle.upstream_turn_id is not None:
                    try:
                        await wt.cancel_turn(wt_client, session_id, handle.upstream_turn_id)
                    except (CancelAlreadyCompleted, CancelTurnNotFound):
                        pass  # cooperative race — turn already terminal upstream
                    except Exception as exc:
                        # v0.16.1: unexpected cancel failure during disconnect
                        # cleanup (e.g. CancelFailed, transport error) — log for
                        # diagnosability instead of silently swallowing. Never
                        # re-raise: we're already unwinding the cancelled
                        # generator and must not mask the CancelledError.
                        import sys as _sys
                        _sys.stderr.write(
                            f'{{"kind":"disconnect_cancel","event":"cancel_failed",'
                            f'"session_id":"{session_id}",'
                            f'"upstream_turn_id":{handle.upstream_turn_id},'
                            f'"exc":"{type(exc).__name__}"}}\n'
                        )
                raise
        finally:
            registry.pop((session_id, turn_id), None)
            await client.aclose()

    return StreamingResponse(gen(), media_type="text/event-stream")


async def _cancel_turn_endpoint(request: Request) -> JSONResponse:
    """POST /api/turns/{session_id}/cancel?turn_id=N. Per FN cancel_turn_endpoint."""
    session_id = request.path_params["session_id"]
    try:
        turn_id = int(request.query_params["turn_id"])
    except (KeyError, ValueError):
        return JSONResponse({"error_code": "missing_turn_id"}, status_code=400)
    registry = request.app.state.turn_registry
    handle = registry.get((session_id, turn_id))
    if handle is None:
        return JSONResponse({"error_code": "turn_not_found"}, status_code=404)
    # v0.16.0: cancel targets the UPSTREAM turn_id captured during
    # streaming, not the browser-local turn_id. If the upstream stream
    # never started (upstream_turn_id is None), there's nothing to
    # cancel — clean up and report a no-op.
    if handle.upstream_turn_id is None:
        registry.pop((session_id, turn_id), None)
        return JSONResponse(
            {"cancelled": False, "reason": "not_started"}, status_code=200
        )
    client_factory = request.app.state.client_factory
    try:
        async with client_factory() as client:
            result = await wt.cancel_turn(
                _wt_client(client), session_id, handle.upstream_turn_id
            )
        body = {"cancelled": bool(result.cancelled)}
    except (CancelAlreadyCompleted, CancelTurnNotFound):
        body = {"cancelled": False, "reason": "race_or_completed"}
    except CancelFailed:
        # The SDK abstracts the upstream cancel HTTP status; surface a generic 502.
        registry.pop((session_id, turn_id), None)
        return JSONResponse(
            {"error_code": "cancel_failed"},
            status_code=502,
        )
    registry.pop((session_id, turn_id), None)
    return JSONResponse(body, status_code=200)


async def _persona_state_endpoint(request: Request) -> JSONResponse:
    """GET /api/agents/{agent_id}/persona_state. Per FN persona_state_endpoint."""
    agent_id = request.path_params["agent_id"]
    client_factory = request.app.state.client_factory
    try:
        async with client_factory() as client:
            snap = await wt.get_persona_state(_wt_client(client), agent_id)
    except PersonaNotConfigured:
        return JSONResponse({"error_code": "persona_not_configured"}, status_code=404)
    except AgentNotAvailable:
        return JSONResponse({"error_code": "agent_not_available"}, status_code=404)
    except AuthScopeDenied:
        return JSONResponse({"error_code": "auth_scope_denied"}, status_code=403)
    except wt.SessionApiFailed as exc:
        # An unmatched upstream ApiError (a 500, or a coded-but-unmapped 4xx) →
        # controlled envelope, for parity with _agents_endpoint / create / admin
        # (heid-code-review slice-4: the persona endpoint was the lone sibling that
        # let it escape as a raw 500 — a latent pre-cutover gap, closed here).
        return JSONResponse(
            {"error_code": "session_api_failed", "status": exc.status},
            status_code=exc.status,
        )
    except (httpx.RequestError, ConnectFailed) as exc:
        # SDK normalizes a transport failure to ConnectFailed(status=0) (slice-3
        # foot-gun); surface the network envelope rather than a 500 crash.
        return JSONResponse(
            {"error_code": "network_error", "message": str(exc)}, status_code=502
        )
    return JSONResponse(snap, status_code=200)


async def _affect_state_endpoint(request: Request) -> JSONResponse:
    """GET /api/affect/{agent_id} → proxy the provider PAD read route. Supplies
    end_user_id SERVER-SIDE (never the browser, INV-002); proxies to the configured
    affect-read URL, re-encoding agent_id into the path (colon-id safe, INV-008).
    Per FN affect_state_endpoint (#18 Deliverable 2)."""
    from urllib.parse import quote

    agent_id = request.path_params["agent_id"]
    affect_read_url = request.app.state.affect_read_url
    end_user_id = request.app.state.end_user_id
    if not (affect_read_url and end_user_id):  # PRE-001: fail-visible, never silent
        return JSONResponse({"error_code": "affect_not_configured"}, status_code=400)
    url = f"{affect_read_url}/affect/state/{quote(agent_id, safe='')}"
    try:
        async with httpx.AsyncClient() as client:
            r = await client.get(url, params={"end_user_id": end_user_id})
    except httpx.RequestError:
        return JSONResponse(
            {"error_code": "affect_provider_unreachable"}, status_code=502
        )
    return JSONResponse(r.json(), status_code=r.status_code)


async def _memory_chunks_endpoint(request: Request) -> JSONResponse:
    """GET /api/memory/chunks?agent_id=… → proxy the provider memory DEBUG read route.
    Supplies end_user_id SERVER-SIDE (never the browser); proxies to the configured
    memory-read URL, forwarding the browser-named agent_id as a filter. The
    memory-plane analogue of the #18-D2 affect proxy — a live-polling view of what
    Worldtree has persisted into OUR store (content·scope·origin per chunk)."""
    memory_read_url = request.app.state.memory_read_url
    end_user_id = request.app.state.end_user_id
    if not (memory_read_url and end_user_id):  # PRE-001: fail-visible, never silent
        return JSONResponse({"error_code": "memory_not_configured"}, status_code=400)
    params = {"end_user_id": end_user_id}
    agent_id = request.query_params.get("agent_id")
    if agent_id:
        params["agent_id"] = agent_id
    url = f"{memory_read_url}/memory/chunks"
    try:
        async with httpx.AsyncClient() as client:
            r = await client.get(url, params=params)
    except httpx.RequestError:
        return JSONResponse(
            {"error_code": "memory_provider_unreachable"}, status_code=502
        )
    return JSONResponse(r.json(), status_code=r.status_code)


# Per-character voice map (DEC-8): interview characters resolve to their registered
# Zonos voice; everything else falls to the gateway default. Case-folded gateway-side.
_TTS_VOICE_MAP = {"ratatoskr:donut": "donut"}
_TTS_DEFAULT_VOICE = "Cora"
# The text rides the POST body (DEC-10), so URL length is no longer the bound — this is a
# safety ceiling on the shared-3090 hold: the server chunk-and-concatenates under the model's
# 71.2s/call cap, so ~8000 chars (~11 chunks, ~9 min) covers any real interview turn while a
# runaway is still bounded. A response past this is truncated at a word boundary (the full
# text still shows in the transcript).
_TTS_MAX_TEXT_CHARS = 8000


def _truncate_at_boundary(text: str, limit: int) -> str:
    """Trim to <= limit chars, preferring the last space so we don't cut mid-word."""
    if len(text) <= limit:
        return text
    head = text[:limit]
    cut = head.rfind(" ")
    return head[:cut] if cut > limit // 2 else head


async def _tts_endpoint(request: Request) -> Response:
    """POST /api/tts {text, agent_id?, p?, a?} → audio/wav, STREAMED chunked from the Zonos
    gateway (FN tts_endpoint). POST (not GET) so an arbitrarily long turn rides the body,
    not a length-capped URL — the server chunk-and-concatenates it under the model's 71.2s
    per-call cap into ONE continuous stream (DEC-10, tts_stream_long). The gateway already
    streams each chunk (TTFB ~0.44s), so we pipe the bytes straight through — chunk 1's WAV
    header verbatim, chunks 2..N header-stripped, so the browser decodes one gapless stream.

    Server-side proxy (DEC-4 / INV-TTS-1: the gateway host never reaches the browser).
    Voice per-character (DEC-8); emotion dials from the browser-sent live PAD (DEC-7, p/a
    body floats). Serialized one-stream-at-a-time (DEC-5); a new turn aborts the prior fetch
    → the POST drops → the generator's finally releases the lock. A gateway open-failure on
    chunk 1 → 503 (INV-TTS-4: the client skips playback)."""
    try:
        body = await request.json()
    except (json.JSONDecodeError, ValueError):
        return JSONResponse({"error_code": "missing_text"}, status_code=400)
    text = body.get("text") if isinstance(body, dict) else None
    if not isinstance(text, str):
        return JSONResponse({"error_code": "missing_text"}, status_code=400)
    # Drop lone surrogates: they ride through JSON (\udXXX from a mid-surrogate-pair slice)
    # but raise UnicodeEncodeError when httpx serializes the gateway POST body (surrogates are
    # not utf-8-encodable) — an untrusted-input 500. Scrub so the text is always encodable
    # (INV-TTS-4 degrade). Also drops a whitespace-only body to a deterministic 400 below.
    text = text.encode("utf-8", "ignore").decode("utf-8")
    if not text.strip():
        return JSONResponse({"error_code": "missing_text"}, status_code=400)
    text = _truncate_at_boundary(text, _TTS_MAX_TEXT_CHARS)
    # agent_id / p / a are UNTRUSTED open-world body fields. A non-str agent_id (an
    # unhashable list/dict) would TypeError on the voice-map .get(); a huge JSON int p/a
    # would OverflowError on float(). Both degrade to a neutral read, never a 500
    # (INV-TTS-4). PadState.from_obj already hardens the numeric parse (catches
    # ArithmeticError/OverflowError) — route through it rather than re-implement a narrower net.
    agent_id = body.get("agent_id")
    voice = (
        _TTS_VOICE_MAP.get(agent_id, _TTS_DEFAULT_VOICE)
        if isinstance(agent_id, str) else _TTS_DEFAULT_VOICE
    )
    dials = pad_to_dials(PadState.from_obj({"pleasure": body.get("p"), "arousal": body.get("a")}))

    tts_url = request.app.state.tts_url
    lock = request.app.state.tts_lock

    # DEC-5: one stream at a time on the shared 3090. Held for the stream's duration
    # (all chunks) and released in the generator's finally — including the browser-abort
    # path. read=120s per infra-ops: a single near-cap chunk can render slowly under load.
    await lock.acquire()
    client = httpx.AsyncClient(
        timeout=httpx.Timeout(connect=10.0, read=120.0, write=10.0, pool=10.0)
    )
    gen = tts_stream_long(text, voice=voice, dials=dials, client=client, url=tts_url)

    async def _release() -> None:
        await gen.aclose()      # unwinds tts_stream's `async with` → closes the gateway resp
        await client.aclose()
        lock.release()

    try:
        # Peek the first chunk so a bad gateway OPEN (non-200 / transport) surfaces as a
        # 503 BEFORE we commit a 200 StreamingResponse. TTFB ~0.44s, so this is cheap.
        first = await gen.__anext__()
    except TtsUnavailable:
        await _release()
        return JSONResponse({"error_code": "tts_unavailable"}, status_code=503)
    except StopAsyncIteration:
        first = None
    except BaseException:
        # Any OTHER escape during the peek — a browser abort (CancelledError) before the
        # first byte, or an unmapped transport error (e.g. httpx.InvalidURL from a
        # misconfigured tts_url, which is NOT an httpx.RequestError) — must still release
        # the process-wide serialize lock + client, or every later /api/tts deadlocks at
        # lock.acquire() (DEC-5). The finally below only exists once piped() runs. Propagate.
        await _release()
        raise

    # Chunk 1 must be a WAV (RIFF header). A 200 non-WAV body — a misbehaving gateway or a
    # proxy error page — would be mislabeled audio/wav and the browser would decode markup as
    # PCM (or match a `data` byte-run in it). Reject → 503 (INV-TTS-4). Tolerant of a <4-byte
    # first read so a valid WAV split oddly is never false-rejected.
    if first is not None and len(first) >= 4 and not first.startswith(b"RIFF"):
        await _release()
        return JSONResponse({"error_code": "tts_unavailable"}, status_code=503)

    async def piped() -> AsyncIterator[bytes]:
        try:
            if first:
                yield first
            async for chunk in gen:
                yield chunk
        finally:
            await _release()

    return StreamingResponse(piped(), media_type="audio/wav")


async def _session_tools_endpoint(request: Request) -> JSONResponse:
    """GET /api/sessions/{session_id}/tools → owner-scoped tool inventory (spec #183).

    Proxies get_session_tools with the client's CONSUMER bearer (no admin scope):
    the merged {agent_id, builtin_tools, bifrost_tools} the LLM saw at turn-fire.
    Any non-200 upstream → surfaced as a status-preserving error envelope."""
    session_id = request.path_params["session_id"]
    client_factory = request.app.state.client_factory
    try:
        async with client_factory() as client:
            info = await wt.get_session_tools(_wt_client(client), session_id)
    except wt.SessionApiFailed as exc:
        return JSONResponse(
            {"error_code": "session_tools_unavailable", "status": exc.status},
            status_code=exc.status,
        )
    return JSONResponse(dict(info), status_code=200)


async def _session_messages_endpoint(request: Request) -> JSONResponse:
    """GET /api/sessions/{session_id}/messages → the session's message history.

    Proxies get_session_messages so the SPA can render a session's EXISTING turns
    on open — notably a #347 authored first-message seeded at create-time (which
    lives in the ledger, not the live turn stream). Any non-200 upstream → a
    status-preserving error envelope."""
    session_id = request.path_params["session_id"]
    client_factory = request.app.state.client_factory
    try:
        async with client_factory() as client:
            data = await wt.get_session_messages(_wt_client(client), session_id)
    except wt.SessionApiFailed as exc:
        return JSONResponse(
            {"error_code": "session_messages_unavailable", "status": exc.status},
            status_code=exc.status,
        )
    return JSONResponse(dict(data), status_code=200)


async def _session_bifrost_endpoint(request: Request) -> JSONResponse:
    """GET /api/sessions/{session_id}/bifrost → admin-scoped Bifrost dispatch state (#176).

    The admin key is SERVER-HELD (app.state.admin_key) and never reaches the
    browser (INV-003 precedent — upstream credentials stay server-side); it rides on
    the wt client's `admin_auth` (`_wt_client(admin_key=…)`), which the SDK uses for
    the `admin.*` routes (NOT a per-call header — slice-6). Fail-visible when the admin
    key isn't configured (never a silent empty pane)."""
    session_id = request.path_params["session_id"]
    admin_key = request.app.state.admin_key
    if not admin_key:  # PRE-001: fail-visible, never silent
        return JSONResponse({"error_code": "admin_key_not_configured"}, status_code=400)
    client_factory = request.app.state.client_factory
    try:
        async with client_factory() as transport:
            # slice-6: the SDK's admin.* routes use the client's admin_auth (built with
            # admin_key), not a per-call header — so it rides on the wt client here.
            client = _wt_client(transport, admin_key=admin_key)
            bstate = await wt.get_session_bifrost(client, session_id)
    except wt.SessionApiFailed as exc:
        return JSONResponse(
            {"error_code": "bifrost_state_unavailable", "status": exc.status},
            status_code=exc.status,
        )
    except (httpx.RequestError, ConnectFailed) as exc:
        # SDK normalizes a transport failure to ConnectFailed(status=0), not a raw
        # httpx error; both surface the same network envelope (cutover foot-gun).
        return JSONResponse(
            {"error_code": "network_error", "message": str(exc)},
            status_code=502,
        )
    # Open-world read: degrade a non-mapping 200 body to {} rather than 500ing on
    # `dict(non-mapping)` (heid bug-hunt slice-6).
    return JSONResponse(dict(bstate) if isinstance(bstate, Mapping) else {}, status_code=200)


def _admin_event_matches_web(ev: AdminEvent, session_id: str | None) -> bool:
    """AdminEvents filter (design-brief §6, mirrors the TUI): forward non-heartbeat
    system.* (stream-integrity signals) + events for the active session; drop the
    rest so the browser sees only session-relevant lifecycle, never the full
    cross-session admin firehose."""
    if ev.type == "system.heartbeat":
        return False
    if ev.type.startswith("system."):
        return True
    return session_id is not None and ev.data.get("session_id") == session_id


async def _admin_events_endpoint(request: Request) -> Response:
    """GET /api/admin/events?session_id=... → SSE proxy of GET /admin/events (#11).

    The admin key is SERVER-HELD; the browser only ever receives the session-filtered
    stream (never the key, never the cross-session firehose). Long-lived + best-effort:
    a connect failure or mid-stream drop emits a labeled `stream_error` event and ends."""
    admin_key = request.app.state.admin_key
    if not admin_key:  # PRE-001: fail-visible, never silent
        return JSONResponse({"error_code": "admin_key_not_configured"}, status_code=400)
    session_id = request.query_params.get("session_id")
    client_factory = request.app.state.client_factory

    async def gen() -> AsyncIterator[bytes]:
        transport = client_factory()
        try:
            # slice-6: admin_auth rides on the wt client (built with admin_key); the
            # adapter re-wraps the SDK's AdminEvent → ratatoskr's (id/type/data degraded)
            # and the stream's terminal SDK errors (incl. ConnectFailed) → the Sse* types
            # below. Built INSIDE the try so a construction failure still hits the finally
            # that closes the transport — no leak (heid bug-hunt slice-6).
            client = _wt_client(transport, admin_key=admin_key)
            async for ev in wt.stream_admin_events(client):
                if not _admin_event_matches_web(ev, session_id):
                    continue
                # Fixed SSE event name so the browser renders EVERY admin type
                # with one listener (no per-type enumeration → nothing silently
                # dropped); the real dotted type rides in the payload.
                yield _format_sse(
                    "admin_event",
                    {"id": ev.id, "type": ev.type, "timestamp": ev.timestamp,
                     "data": ev.data},
                )
        except (SseConnectFailed, SseConnectionDropped, SseResponseTooLarge,
                MalformedSseId, MalformedSseData) as exc:
            yield _format_sse(
                "stream_error",
                {"exception": type(exc).__name__, "message": str(exc)},
            )
        except asyncio.CancelledError:
            raise  # browser disconnect — let the generator unwind
        finally:
            # ratatoskr owns the transport lifecycle (INV-CUT-1); close the injected
            # httpx client, never the wt client (which would no-op the transport anyway).
            await transport.aclose()

    return StreamingResponse(gen(), media_type="text/event-stream")


def create_app(
    client_factory: Callable[[], httpx.AsyncClient],
    *,
    end_user_id: str | None = None,
    bifrost_consumer_key: str | None = None,
    bifrost_visible_host: str | None = None,
    affect_read_url: str | None = None,
    memory_read_url: str | None = None,
    admin_key: str | None = None,
    tts_url: str | None = None,
) -> Starlette:
    """Construct the Starlette app — wire routes + state per FN create_app.

    INV-002: app MUST NOT construct httpx.AsyncClient at module top;
    everything HTTP-bound goes through client_factory.
    INV-006: lifespan shutdown drains the turn registry within a 5s
    budget — every in-flight turn gets a best-effort upstream cancel.

    v0.16.0: `end_user_id` is the server-configured Worldtree end-user
    partition (from RATATOSKR_END_USER_ID). Threaded into POST /sessions
    server-side; never accepted from the browser.
    """
    assert callable(client_factory)

    from contextlib import asynccontextmanager

    @asynccontextmanager
    async def lifespan(app: Starlette):
        yield
        # Shutdown path — drain in-flight turns per INV-006. Cancel the
        # UPSTREAM turn_id (v0.16.0); skip handles whose upstream stream
        # never started (upstream_turn_id is None — nothing to cancel).
        import sys as _sys

        registry: dict[tuple[str, int], TurnHandle] = app.state.turn_registry
        in_flight = [
            h for h in registry.values()
            if h.status == "streaming" and h.upstream_turn_id is not None
        ]
        if in_flight:
            client = client_factory()
            wt_client = _wt_client(client)
            try:
                task_to_handle = {
                    asyncio.create_task(
                        wt.cancel_turn(wt_client, h.session_id, h.upstream_turn_id)
                    ): h
                    for h in in_flight
                }
                _done, pending = await asyncio.wait(task_to_handle, timeout=5.0)
                # Per-pending session/turn detail (INV-006 logging fidelity).
                for task in pending:
                    h = task_to_handle[task]
                    task.cancel()
                    _sys.stderr.write(
                        f'{{"kind":"shutdown","event":"cleanup_timeout",'
                        f'"session_id":"{h.session_id}",'
                        f'"upstream_turn_id":{h.upstream_turn_id}}}\n'
                    )
            finally:
                await client.aclose()
        registry.clear()

    routes = [
        Route("/", _root_endpoint),
        Mount("/static", app=StaticFiles(directory=_static_dir()), name="static"),
        Route("/version", _version_endpoint),
        Route("/api/agents", _agents_endpoint),
        Route("/api/sessions", _create_session_endpoint, methods=["POST"]),
        Route("/api/agents/{agent_id}/persona_state", _persona_state_endpoint),
        Route("/api/affect/{agent_id}", _affect_state_endpoint),
        Route("/api/memory/chunks", _memory_chunks_endpoint),
        Route("/api/sessions/{session_id}/tools", _session_tools_endpoint),
        Route("/api/sessions/{session_id}/messages", _session_messages_endpoint),
        Route("/api/sessions/{session_id}/bifrost", _session_bifrost_endpoint),
        Route("/api/admin/events", _admin_events_endpoint),
        Route("/api/tts", _tts_endpoint, methods=["POST"]),
        Route("/api/turns/{session_id}", _submit_turn_endpoint, methods=["POST"]),
        Route("/api/turns/{session_id}/stream", _stream_turn_endpoint),
        Route("/api/turns/{session_id}/cancel", _cancel_turn_endpoint, methods=["POST"]),
    ]
    app = Starlette(routes=routes, lifespan=lifespan)
    app.state.client_factory = client_factory
    app.state.end_user_id = end_user_id
    # Issue #17 (web bind split): the consumer key + Worldtree-visible provider
    # host are SERVER-HELD config (env), never sent from the browser. The browser
    # selects only the PLANE; the server constructs the bound session (INV-008).
    app.state.bifrost_consumer_key = bifrost_consumer_key
    app.state.bifrost_visible_host = bifrost_visible_host
    # Issue #18 (Deliverable 2): the provider affect-read base URL (server→provider hop,
    # same dev box) — distinct from the WT-visible host used for binding.
    app.state.affect_read_url = affect_read_url
    # Memory viewer: the provider memory-read base URL (server→provider hop, same dev
    # box) — the combined :8392 provider serves BOTH read routes, so in practice this
    # points at the same host as affect_read_url; kept as its own config for isolation.
    app.state.memory_read_url = memory_read_url
    # Admin observability panes (BifrostState + AdminEvents): the admin key is
    # SERVER-HELD (RATATOSKR_ADMIN_API_KEY) and never reaches the browser — the
    # server proxies admin-scoped reads and forwards only the session-filtered result.
    app.state.admin_key = admin_key
    # Auto-TTS (slice 2): the Zonos gateway URL is SERVER-HELD config — the host
    # never reaches the browser (DEC-4 / INV-TTS-1). Defaults to the direct gateway
    # (DEC-1); overridable via RATATOSKR_TTS_URL (the swap seam). The lock serializes
    # one synth at a time so concurrent turns don't contend the shared 3090 (DEC-5).
    app.state.tts_url = tts_url or ZONOS_TTS_URL
    app.state.tts_lock = asyncio.Lock()
    # INV-002: turn registry is in-process memory, keyed (session_id, turn_id)
    app.state.turn_registry = {}
    return app
