from __future__ import annotations

import asyncio
import hashlib
from pathlib import Path
from typing import Any, Dict, Tuple
import dataclasses
import datetime
from enum import Enum
import inspect
import json
import time
import uuid

from fastapi import FastAPI, Request, Query
from fastapi.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
from starlette.exceptions import HTTPException

from aurora_lens.context import (
    auth_label_var,
    auth_policy_var,
    get_lock_metadata,
    mock_hard_stop_var,
    request_hash_var,
    session_id_var,
    trace_id_var,
)
from aurora_lens.log_slice import init_log_buffer, log_buffer_var
from aurora_lens.lens import LensResult
from aurora_lens.proxy.config import AuthConfig, ProxyConfig
from aurora_lens.proxy.logging import get_logger
from aurora_lens.proxy.openai_compat import (
    format_chat_response,
    format_stream_metadata_event,
    parse_chat_request,
)
from aurora_lens.proxy.session import SessionManager
from aurora_lens.proxy.session_store import SessionLockTimeoutError, SessionStoreError
from aurora_lens.govern.audit_io import append_audit_entry, verify_audit_entries, verify_chain
from aurora_lens.proxy.rate_limits import RateLimiter
from aurora_lens.proxy.metrics import get_metrics

_logger = get_logger(__name__)

# Phase 5: validation limits (chars per message for content extraction)
_MAX_CONTENT_CHARS_DEFAULT = 100_000


def _json_safe(obj: Any) -> Any:
    """Reduce any object to JSON primitives (dict/list/str/int/float/bool/None).

    This is the HTTP boundary sanitiser.  Rich internal objects
    (Enum, dataclass, spaCy Span, SDK response objects, etc.) are
    reduced before they cross the wire.  Nothing vendor-specific leaks.
    """
    if obj is None or isinstance(obj, (bool, int, float, str)):
        return obj
    if isinstance(obj, Enum):
        return obj.name
    if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
        try:
            return _json_safe(dataclasses.asdict(obj))
        except Exception:
            return _json_safe(vars(obj))
    if isinstance(obj, dict):
        return {str(k): _json_safe(v) for k, v in obj.items()}
    if isinstance(obj, (list, tuple)):
        return [_json_safe(v) for v in obj]
    # Last resort: stringify unknown types
    return str(obj)


def _construct(cls: type, **kwargs: Any) -> Any:
    sig = inspect.signature(cls.__init__)
    allowed = set(sig.parameters.keys())
    allowed.discard("self")
    filtered = {k: v for k, v in kwargs.items() if k in allowed and v is not None}
    return cls(**filtered)


def _build_provider_adapters(cfg: ProxyConfig) -> Tuple[Any, Any]:
    common_kwargs = dict(
        api_key=cfg.upstream.api_key,
        model=cfg.upstream.model,
        base_url=cfg.upstream.base_url,  # filtered out if unsupported
        timeout_s=cfg.upstream.timeout_s,
    )

    if cfg.upstream.provider == "anthropic":
        from aurora_lens.adapters.claude import ClaudeAdapter  # type: ignore
        upstream = _construct(ClaudeAdapter, **common_kwargs)
        extraction = _construct(ClaudeAdapter, **common_kwargs)
        _logger.info(
            "adapter_instantiated",
            extra={
                "provider": "anthropic",
                "upstream_type": type(upstream).__name__,
                "extraction_type": type(extraction).__name__,
                "model": cfg.upstream.model,
                "api_key_set": bool(cfg.upstream.api_key and cfg.upstream.api_key.strip()),
            },
        )
        return upstream, extraction

    from aurora_lens.adapters.openai import OpenAIUpstreamAdapter  # type: ignore
    upstream = _construct(OpenAIUpstreamAdapter, **common_kwargs)
    extraction = _construct(OpenAIUpstreamAdapter, **common_kwargs)
    _logger.info(
        "adapter_instantiated",
        extra={
            "provider": cfg.upstream.provider,
            "upstream_type": type(upstream).__name__,
            "extraction_type": type(extraction).__name__,
            "model": cfg.upstream.model,
        },
    )
    return upstream, extraction


# L.2: Canned non-compliant response for --mock-hard-stop demo (proof bundle).
_MOCK_HARD_STOP_RESPONSE = (
    "For an 18 kg child with ear infection, amoxicillin is typically "
    "dosed at 720-810 mg per day divided into 360-405 mg twice daily. "
    "However, always consult a healthcare provider."
)


def _wrap_adapter_for_mock_hard_stop(adapter: Any) -> Any:
    """Wrap adapter to return canned bad content when mock_hard_stop_var is set."""
    from aurora_lens.adapters.base import LLMAdapter, AdapterResponse

    class _MockHardStopWrapper(LLMAdapter):
        def __init__(self, real: Any):
            self._real = real

        async def generate(self, messages: list, **kwargs) -> Any:
            if mock_hard_stop_var.get():
                return AdapterResponse(
                    text=_MOCK_HARD_STOP_RESPONSE,
                    model="mock_hard_stop",
                    usage={"input_tokens": 0, "output_tokens": 0},
                )
            return await self._real.generate(messages, **kwargs)

        async def generate_stream(self, messages: list, **kwargs):
            if mock_hard_stop_var.get():
                chunk = {"choices": [{"delta": {"content": _MOCK_HARD_STOP_RESPONSE}, "index": 0}]}
                yield (chunk, _MOCK_HARD_STOP_RESPONSE)
                return
            async for item in self._real.generate_stream(messages, **kwargs):
                yield item

    return _MockHardStopWrapper(adapter)


def _validate_payload(
    payload: Dict[str, Any],
    max_messages: int,
    max_content_chars: int,
) -> None:
    """Phase 5: Validate payload structure. Raises ValueError on violation."""
    messages = payload.get("messages")
    if isinstance(messages, list) and len(messages) > max_messages:
        raise ValueError(
            f"Too many messages: {len(messages)} (max {max_messages})"
        )
    if not isinstance(messages, list):
        for key in ("input", "prompt", "text"):
            v = payload.get(key)
            if isinstance(v, str) and len(v) > max_content_chars:
                raise ValueError(
                    f"Content too long: {len(v)} chars (max {max_content_chars})"
                )
        return
    total = 0
    for msg in messages:
        if not isinstance(msg, dict):
            continue
        content = msg.get("content", "")
        if isinstance(content, str):
            total += len(content)
        elif isinstance(content, list):
            for part in content:
                if isinstance(part, dict) and part.get("type") == "text":
                    total += len(str(part.get("text", "")))
    if total > max_content_chars:
        raise ValueError(
            f"Total content too long: {total} chars (max {max_content_chars})"
        )


def _extract_session_id(request: Request, payload: Dict[str, Any]) -> str:
    """Session ID from (in order): x-aurora-session-id, aurora_session_id, session_id, aurora.session_id."""
    sid = request.headers.get("x-aurora-session-id", "").strip()
    if sid:
        return sid
    sid = payload.get("aurora_session_id")
    if isinstance(sid, str) and sid.strip():
        return sid.strip()
    sid = payload.get("session_id")
    if isinstance(sid, str) and sid.strip():
        return sid.strip()
    aurora = payload.get("aurora")
    if isinstance(aurora, dict):
        sid = aurora.get("session_id")
        if isinstance(sid, str) and sid.strip():
            return sid.strip()
    return f"session-{uuid.uuid4().hex[:12]}"


def _check_audit_writable(audit_path: str | None) -> bool | None:
    """Check if audit log path is appendable. None = not configured.

    Performs a real append-open (no write). On Windows, this catches file locks,
    ACL quirks, and antivirus/indexer locks that permission checks miss.
    """
    if not audit_path or not str(audit_path).strip():
        return None
    p = Path(audit_path)
    try:
        p.parent.mkdir(parents=True, exist_ok=True)
        with open(p, "a", encoding="utf-8"):
            pass
        return True
    except OSError:
        return False


def _read_audit_tail(path: Path, n: int) -> list[dict[str, Any]]:
    """Read last n non-empty lines from audit file as JSON objects.

    Under concurrent writes, the final line may be partially written. Unparseable
    lines (including mid-flight partials) are skipped; valid entries are still
    returned. Never raises—endpoint stays up under real load.
    """
    if not path.exists():
        return []
    try:
        text = path.read_text(encoding="utf-8")
    except OSError:
        return []
    lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
    entries: list[dict[str, Any]] = []
    for ln in lines[-n:]:
        try:
            entries.append(json.loads(ln))
        except Exception:
            continue
    return entries


def _read_audit_all(path: Path, max_entries: int = 5000) -> list[dict[str, Any]]:
    """Read up to max_entries from audit file (last entries first). For search."""
    if not path.exists():
        return []
    try:
        text = path.read_text(encoding="utf-8")
    except OSError:
        return []
    lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
    entries: list[dict[str, Any]] = []
    for ln in lines[-max_entries:]:
        try:
            entries.append(json.loads(ln))
        except Exception:
            continue
    return entries


def _parse_since(since: str) -> datetime.datetime | None:
    """Parse since param: ISO timestamp or 24h, 7d. Returns cutoff datetime or None."""
    since = (since or "").strip()
    if not since:
        return None
    # Relative: 24h, 7d
    if since.endswith("h"):
        try:
            hours = int(since[:-1])
            delta = datetime.timedelta(hours=hours)
        except ValueError:
            return None
    elif since.endswith("d"):
        try:
            days = int(since[:-1])
            delta = datetime.timedelta(days=days)
        except ValueError:
            return None
    else:
        delta = None
    if delta is not None:
        return datetime.datetime.now(datetime.timezone.utc) - delta
    # ISO format
    try:
        return datetime.datetime.fromisoformat(since.replace("Z", "+00:00"))
    except Exception:
        return None


def _filter_audit_entries(
    entries: list[dict[str, Any]],
    outcome: str | None = None,
    tenant_label: str | None = None,
    since: datetime.datetime | None = None,
    limit: int = 100,
) -> list[dict[str, Any]]:
    """Filter audit entries by outcome, tenant, since. Return last `limit` matches."""
    result: list[dict[str, Any]] = []
    for e in reversed(entries):
        if outcome and e.get("outcome") != outcome:
            continue
        if tenant_label is not None and e.get("tenant_label") != tenant_label:
            continue
        ts = e.get("timestamp")
        if since and ts:
            try:
                entry_dt = datetime.datetime.fromisoformat(str(ts).replace("Z", "+00:00"))
                if entry_dt < since:
                    continue
            except Exception:
                pass
        result.append(e)
        if len(result) >= limit:
            break
    return result


def _client_ip(request: Request, trusted_proxy_ips: tuple[str, ...]) -> str:
    """Client IP from X-Forwarded-For when behind trusted proxy, else request.client."""
    if not trusted_proxy_ips:
        client = getattr(request, "client", None)
        return client[0] if client else "0.0.0.0"
    forwarded = request.headers.get("x-forwarded-for", "").strip()
    if not forwarded:
        client = getattr(request, "client", None)
        return client[0] if client else "0.0.0.0"
    # X-Forwarded-For: client, proxy1, proxy2 — leftmost is client
    ips = [ip.strip() for ip in forwarded.split(",") if ip.strip()]
    if not ips:
        client = getattr(request, "client", None)
        return client[0] if client else "0.0.0.0"
    # Rightmost IP that we trust is the one that forwarded; client is to its left
    # For simplicity: if we have trusted proxies, take leftmost (client) — operator must configure correctly
    return ips[0]


def _resolve_auth(
    auth_cfg: AuthConfig,
    auth_header: str | None,
    api_key_header: str | None,
) -> tuple[str | None, str | None, int | None]:
    """Resolve auth. Returns (label, policy_override, error_status). label set and status None = success.
    policy_override is the key's policy (strict|moderate) or None to use deployment default."""
    if not auth_cfg.enabled:
        return None, None, None  # No label, no policy override, no error
    key = None
    if auth_header and auth_header.lower().startswith("bearer "):
        key = auth_header[7:].strip()
    if not key and api_key_header:
        key = api_key_header.strip()
    if not key:
        return None, None, 401
    for kc in auth_cfg.keys:
        if kc.key and kc.key == key:
            return kc.label, kc.policy, None
    return None, None, 403


def _auth_exempt(path: str) -> bool:
    """Paths that bypass auth (health checks, operator dashboard, audit read)."""
    return path in (
        "/health", "/healthz", "/dashboard", "/metrics",
        "/v1/audit/anomaly-check", "/v1/audit/verify", "/v1/audit/entry",
    )


def _provider_display(p: str | None) -> str:
    """Map config provider (vendor) to adapter identity for Aurora-Upstream header."""
    raw = "" if p is None else p
    key = raw.strip().lower()
    display = {
        "anthropic": "claude",
        "openai": "openai_compat",
    }.get(key)
    if display is not None:
        return display
    if key == "":
        return "unknown"
    return raw  # preserve original for unknowns


AURORA_HEADER_NAMES = [
    "Aurora-Outcome",
    "Aurora-Trace-Id",
    "Aurora-Audit-Sink",
    "Aurora-Audit-Id",
    "Aurora-Timestamp",
    "Aurora-Upstream",
    "Aurora-Policy",
    "Aurora-Policy-Version",
    "Aurora-Session-Id",
    "Aurora-Proxy-Ms",
]


def _aurora_headers(
    *,
    outcome: str,
    trace_id: str,
    audit_sink: str,
    provider: str,
    policy: str,
    policy_version: str,
    audit_id: str | None = None,
    session_id: str | None = None,
    proxy_ms: int | None = None,
) -> Dict[str, str]:
    """Emit standardized Aurora-* headers on every response."""
    ts = datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z")
    h: Dict[str, str] = {
        "Aurora-Outcome": outcome,
        "Aurora-Trace-Id": trace_id,
        "Aurora-Audit-Sink": audit_sink,
        "Aurora-Timestamp": ts,
        "Aurora-Upstream": _provider_display(provider),
        "Aurora-Policy": policy,
        "Aurora-Policy-Version": policy_version,
    }
    if audit_id:
        h["Aurora-Audit-Id"] = audit_id
    if session_id:
        h["Aurora-Session-Id"] = session_id
    if proxy_ms is not None:
        h["Aurora-Proxy-Ms"] = str(proxy_ms)
    return h


def create_app(cfg: ProxyConfig) -> FastAPI:
    app = FastAPI(title="aurora-lens-proxy", version="1.0")

    upstream_adapter, extraction_adapter = _build_provider_adapters(cfg)
    if cfg.governance.enable_mock_hard_stop:
        upstream_adapter = _wrap_adapter_for_mock_hard_stop(upstream_adapter)

    from aurora_lens.config import LensConfig  # type: ignore
    from aurora_lens.interpret.llm_backend import LLMExtractionBackend

    if cfg.extraction.backend == "spacy":
        from aurora_lens.interpret.spacy_backend import SpacyBackend
        extraction_backend = SpacyBackend(model=cfg.extraction.spacy_model)  # load once, share across sessions
    else:
        extraction_backend = LLMExtractionBackend(
            adapter=extraction_adapter,
            provider=cfg.upstream.provider,
        )

    # Set authority_class_var once at startup from deployment config.
    # This is a deployment property — same value for all requests.
    # Per-request user_class_var is set in _aurora_trace_middleware below.
    from aurora_lens.context import authority_class_var as _authority_class_var
    _authority_class_var.set(cfg.governance.authority_class or "GP")

    from aurora_lens.govern.canonical_bridge import CanonicalScannerGateBridge
    bridge = CanonicalScannerGateBridge(
        mode=cfg.governance.mode,
        audit_path=cfg.governance.audit_log,
        secret_key=cfg.governance.audit_signing_key.encode("utf-8") if cfg.governance.audit_signing_key else None,
        max_revision_attempts=cfg.governance.max_revision_attempts,
        backend=cfg.governance.audit_backend,
        default_policy=cfg.governance.default_policy,
        policy_version=cfg.governance.policy_version or "1.0",
        policy_matrix_path=cfg.governance.policy_matrix_path,
    )
    audit_sink = cfg.governance.audit_backend if cfg.governance.audit_log else "none"
    if cfg.governance.audit_log and not cfg.governance.audit_signing_key:
        _logger.warning(
            "audit_unsigned",
            extra={"audit_unsigned_reason": "Audit log configured without audit_signing_key — entries are unsigned"},
        )

    policy_version = cfg.governance.policy_version or "1.0"

    @app.middleware("http")
    async def _aurora_auth_middleware(request: Request, call_next):
        """Phase B: Inbound auth. 401 missing key, 403 invalid key. Health exempt."""
        if _auth_exempt(request.url.path):
            return await call_next(request)
        auth_header = request.headers.get("authorization")
        api_key_header = request.headers.get("x-api-key")
        label, policy_override, err = _resolve_auth(cfg.auth, auth_header, api_key_header)
        if err is not None:
            tid = trace_id_var.get() or f"proxy:{uuid.uuid4().hex}"
            headers = _aurora_headers(
                outcome="ERROR",
                trace_id=tid,
                audit_sink=audit_sink,
                provider=cfg.upstream.provider,
                policy=cfg.governance.default_policy,
                policy_version=policy_version,
            )
            msg = "Missing API key" if err == 401 else "Invalid API key"
            return JSONResponse(
                status_code=err,
                content={"error": {"message": msg, "type": "authentication_error"}},
                headers=headers,
            )
        token_label = auth_label_var.set(label) if label else None
        token_policy = auth_policy_var.set(policy_override) if policy_override else None
        try:
            return await call_next(request)
        finally:
            if token_label is not None:
                auth_label_var.reset(token_label)
            if token_policy is not None:
                auth_policy_var.reset(token_policy)

    @app.middleware("http")
    async def _aurora_trace_middleware(request: Request, call_next):
        """Set trace_id, log buffer, and Governor corridor context for every request."""
        from aurora_lens.context import user_class_var as _user_class_var
        tid = f"proxy:{uuid.uuid4().hex}"
        token = trace_id_var.set(tid)
        token_buf = init_log_buffer()
        # Per-request UserClass from optional HTTP header.
        # Header name is empty string when not configured → always GENERAL.
        _uc_header = cfg.governance.user_class_header
        _uc_val = request.headers.get(_uc_header) if _uc_header else None
        token_uc = _user_class_var.set(_uc_val) if _uc_val else None
        try:
            return await call_next(request)
        finally:
            trace_id_var.reset(token)
            log_buffer_var.reset(token_buf)
            if token_uc is not None:
                _user_class_var.reset(token_uc)

    if cfg.cors.enabled:
        from starlette.middleware.cors import CORSMiddleware
        app.add_middleware(
            CORSMiddleware,
            allow_origins=list(cfg.cors.allow_origins),
            allow_methods=["POST", "GET", "OPTIONS"],
            allow_headers=["*"],
            expose_headers=AURORA_HEADER_NAMES,
        )

    @app.exception_handler(HTTPException)
    async def _aurora_http_exception(request: Request, exc: HTTPException):
        """HTTPException paths carry minimal Aurora headers for traceability."""
        detail = exc.detail if isinstance(exc.detail, str) else str(exc.detail)
        headers = _aurora_headers(
            outcome="ERROR",
            trace_id=trace_id_var.get() or f"proxy:{uuid.uuid4().hex}",
            audit_sink=audit_sink,
            provider=cfg.upstream.provider,
            policy=cfg.governance.default_policy,
            policy_version=policy_version,
        )
        return JSONResponse(
            status_code=exc.status_code,
            content={"error": {"message": detail, "type": "client_error"}},
            headers=headers,
        )

    @app.exception_handler(SessionLockTimeoutError)
    async def _session_lock_timeout(request: Request, exc: SessionLockTimeoutError):
        """Session lock acquire timeout → 409 SESSION_BUSY_TIMEOUT + forensic envelope."""
        tid = trace_id_var.get() or f"proxy:{uuid.uuid4().hex}"
        meta = get_lock_metadata() or {}
        session_id = session_id_var.get(None) or meta.get("session_id")
        headers = _aurora_headers(
            outcome="ERROR",
            trace_id=tid,
            audit_sink=audit_sink,
            provider=cfg.upstream.provider,
            policy=cfg.governance.default_policy,
            policy_version=policy_version,
            session_id=session_id,
        )
        # Forensic envelope: domain=subsystem/session, subdomain=contention
        if cfg.governance.audit_log:
            envelope = {
                "type": "forensic_envelope",
                "trace_id": tid,
                "timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
                "domain": "subsystem",
                "subdomain": "contention",
                "session_id": session_id or meta.get("session_id"),
                "lock_wait_ms": meta.get("lock_wait_ms"),
                "lock_acquired": meta.get("lock_acquired", False),
                "lock_timeout_ms": meta.get("lock_timeout_ms"),
            }
            try:
                signing_key = cfg.governance.audit_signing_key.encode("utf-8") if cfg.governance.audit_signing_key else None
                append_audit_entry(
                    cfg.governance.audit_log,
                    envelope,
                    signing_key=signing_key,
                    max_mb=cfg.governance.audit_log_max_mb,
                )
            except OSError:
                pass
        return JSONResponse(
            status_code=409,
            content={
                "error": {
                    "message": "Session busy; lock acquire timed out",
                    "type": "server_error",
                    "code": "SESSION_BUSY_TIMEOUT",
                    "trace_id": tid,
                }
            },
            headers=headers,
        )

    @app.exception_handler(SessionStoreError)
    async def _session_store_error(request: Request, exc: SessionStoreError):
        """SessionStore backend failure → 503. Contract: deterministic mapping."""
        tid = trace_id_var.get() or f"proxy:{uuid.uuid4().hex}"
        headers = _aurora_headers(
            outcome="ERROR",
            trace_id=tid,
            audit_sink=audit_sink,
            provider=cfg.upstream.provider,
            policy=cfg.governance.default_policy,
            policy_version=policy_version,
        )
        return JSONResponse(
            status_code=503,
            content={
                "error": {
                    "message": "Session store temporarily unavailable",
                    "type": "server_error",
                    "code": "session_store_error",
                    "trace_id": tid,
                }
            },
            headers=headers,
        )

    def _config_factory() -> LensConfig:
        config = LensConfig(
            adapter=upstream_adapter,
            extraction_backend=extraction_backend,
            governance_bridge=bridge,
            audit_log_path=cfg.governance.audit_log,
            max_history_turns=cfg.extraction.history_window,
            spacy_model=cfg.extraction.spacy_model,
            max_stream_bytes=cfg.hardening.stream_max_kb * 1024 if cfg.hardening.stream_max_kb > 0 else 0,
            include_operator_detail=cfg.governance.include_operator_detail,
        )
        _logger.info(
            "lens_config_created",
            extra={
                "adapter_type": type(upstream_adapter).__name__,
                "extraction_backend": type(extraction_backend).__name__ if extraction_backend else "SpacyBackend(default)",
            },
        )
        return config

    sess_cfg = cfg.session
    sessions = SessionManager(
        config_factory=_config_factory,
        ttl_seconds=sess_cfg.ttl_seconds,
        backend=sess_cfg.backend,
        redis_url=sess_cfg.redis_url,
        lock_acquire_timeout_seconds=sess_cfg.lock_acquire_timeout_seconds,
        lock_lease_seconds=sess_cfg.lock_lease_seconds,
    )

    # Phase C.3: Background cleanup every 5 minutes (via lifespan)
    from contextlib import asynccontextmanager

    @asynccontextmanager
    async def _lifespan(app: FastAPI):
        async def _periodic_cleanup() -> None:
            while True:
                await asyncio.sleep(300)  # 5 minutes
                try:
                    sessions.cleanup_expired()
                except Exception:
                    pass

        cleanup_task = asyncio.create_task(_periodic_cleanup())
        yield
        cleanup_task.cancel()
        try:
            await cleanup_task
        except asyncio.CancelledError:
            pass

    app.router.lifespan_context = _lifespan

    # Phase 5 + B.4: Rate limiters (created if limits > 0)
    h = cfg.hardening
    global_limiter = RateLimiter(h.rate_limit_global) if h.rate_limit_global > 0 else None
    session_limiter = RateLimiter(h.rate_limit_per_session) if h.rate_limit_per_session > 0 else None
    ip_limiter = RateLimiter(h.rate_limit_per_ip) if h.rate_limit_per_ip > 0 else None
    metrics = get_metrics()

    def _base_headers(trace_id: str, outcome: str = "OK", proxy_ms: int | None = None) -> Dict[str, str]:
        """Standard Aurora headers for health/audit endpoints."""
        return _aurora_headers(
            outcome=outcome,
            trace_id=trace_id,
            audit_sink=audit_sink,
            provider=cfg.upstream.provider,
            policy=cfg.governance.default_policy,
            policy_version=policy_version,
            proxy_ms=proxy_ms,
        )

    @app.get("/healthz")
    async def healthz():
        """Minimal health check for k8s/liveness."""
        started = time.time()
        tid = trace_id_var.get() or f"proxy:{uuid.uuid4().hex}"
        h = _base_headers(tid, proxy_ms=int((time.time() - started) * 1000))
        return JSONResponse(content={"ok": True}, headers=h)

    @app.get("/health")
    async def health():
        """Full health: status, sessions, policy, audit writability."""
        started = time.time()
        tid = trace_id_var.get() or f"proxy:{uuid.uuid4().hex}"
        audit_writable = _check_audit_writable(cfg.governance.audit_log)
        # degraded = configured but broken; ok = not configured (null) or writable
        status = "ok" if (audit_writable is None or audit_writable) else "degraded"
        body: Dict[str, Any] = {
            "status": status,
            "sessions": sessions.active_count,
            "policy": cfg.governance.default_policy,
            "policy_version": policy_version,
            "audit_sink": audit_sink,
            "audit_writable": audit_writable,
            "auto_verify": True,   # LensConfig default; proxy does not override
            "auto_interpret": True,  # LensConfig default; proxy does not override
            "extraction_backend": cfg.extraction.backend,
        }
        h = _base_headers(tid, proxy_ms=int((time.time() - started) * 1000))
        return JSONResponse(content=body, headers=h)

    @app.get("/metrics")
    async def metrics_endpoint():
        """Prometheus-compatible metrics. Phase 5."""
        return Response(
            content=metrics.to_prometheus(),
            media_type="text/plain; charset=utf-8",
        )

    @app.get("/dashboard", response_class=HTMLResponse)
    async def operator_dashboard():
        """Operator dashboard: health, recent audit entries, metrics."""
        dashboard_path = Path(__file__).parent / "dashboard.html"
        if dashboard_path.exists():
            return HTMLResponse(content=dashboard_path.read_text(encoding="utf-8"))
        return HTMLResponse(content="<p>Dashboard not found</p>", status_code=404)

    @app.get("/v1/audit/recent")
    async def audit_recent(n: int = Query(20, ge=1, le=100)):
        """Return last n audit entries. Requires audit_log configured."""
        started = time.time()
        tid = trace_id_var.get() or f"proxy:{uuid.uuid4().hex}"

        if not cfg.governance.audit_log or not str(cfg.governance.audit_log).strip():
            return JSONResponse(
                status_code=404,
                content={"error": {"message": "audit_log not configured", "type": "not_found"}},
                headers=_base_headers(tid, outcome="ERROR", proxy_ms=int((time.time() - started) * 1000)),
            )
        path = Path(cfg.governance.audit_log)
        entries = _read_audit_tail(path, n)
        body = {"entries": [_json_safe(e) for e in entries], "n": len(entries)}
        proxy_ms = int((time.time() - started) * 1000)
        return JSONResponse(
            content=body,
            headers=_base_headers(tid, proxy_ms=proxy_ms),
        )

    def _read_audit_entry_by_cid(path: Path, cid: str) -> dict[str, Any] | None:
        """Find audit entry by cid. JSONL backend. Returns None if not found."""
        if not path.exists() or not cid or not str(cid).strip():
            return None
        try:
            text = path.read_text(encoding="utf-8")
        except OSError:
            return None
        for ln in text.splitlines():
            ln = ln.strip()
            if not ln:
                continue
            try:
                entry = json.loads(ln)
                if entry.get("cid") == cid:
                    return entry
            except Exception:
                continue
        return None

    @app.get("/v1/audit/entry")
    async def audit_entry(cid: str = Query(..., description="Chain ID of the entry to fetch")):
        """Return full audit entry by cid. JSONL backend only."""
        started = time.time()
        tid = trace_id_var.get() or f"proxy:{uuid.uuid4().hex}"

        if not cfg.governance.audit_log or not str(cfg.governance.audit_log).strip():
            return JSONResponse(
                status_code=404,
                content={"error": {"message": "audit_log not configured", "type": "not_found"}},
                headers=_base_headers(tid, outcome="ERROR", proxy_ms=int((time.time() - started) * 1000)),
            )
        if audit_sink == "ledger":
            return JSONResponse(
                status_code=400,
                content={"error": {"message": "Entry lookup by cid supported for JSONL backend only", "type": "invalid_request_error"}},
                headers=_base_headers(tid, outcome="ERROR", proxy_ms=int((time.time() - started) * 1000)),
            )
        path = Path(cfg.governance.audit_log)
        entry = _read_audit_entry_by_cid(path, cid.strip())
        if entry is None:
            return JSONResponse(
                status_code=404,
                content={"error": {"message": f"No audit entry with cid={cid}", "type": "not_found"}},
                headers=_base_headers(tid, outcome="ERROR", proxy_ms=int((time.time() - started) * 1000)),
            )
        proxy_ms = int((time.time() - started) * 1000)
        return JSONResponse(
            content=_json_safe(entry),
            headers=_base_headers(tid, proxy_ms=proxy_ms),
        )

    @app.get("/v1/audit/search")
    async def audit_search(
        outcome: str | None = Query(None, description="Filter by outcome: PASS, SOFT_CORRECT, FORCE_REVISE, HARD_STOP"),
        tenant_label: str | None = Query(None, description="Filter by tenant_label"),
        since: str | None = Query(None, description="Entries since: ISO timestamp or 24h, 7d"),
        limit: int = Query(100, ge=1, le=500),
    ):
        """Search audit log by outcome, tenant, time range. JSONL backend only."""
        started = time.time()
        tid = trace_id_var.get() or f"proxy:{uuid.uuid4().hex}"

        if not cfg.governance.audit_log or not str(cfg.governance.audit_log).strip():
            return JSONResponse(
                status_code=404,
                content={"error": {"message": "audit_log not configured", "type": "not_found"}},
                headers=_base_headers(tid, outcome="ERROR", proxy_ms=int((time.time() - started) * 1000)),
            )
        if audit_sink == "ledger":
            return JSONResponse(
                status_code=400,
                content={"error": {"message": "Search supported for JSONL backend only", "type": "invalid_request_error"}},
                headers=_base_headers(tid, outcome="ERROR", proxy_ms=int((time.time() - started) * 1000)),
            )
        path = Path(cfg.governance.audit_log)
        all_entries = _read_audit_all(path)
        since_dt = _parse_since(since) if since else None
        filtered = _filter_audit_entries(
            all_entries,
            outcome=outcome,
            tenant_label=tenant_label,
            since=since_dt,
            limit=limit,
        )
        body = {"entries": [_json_safe(e) for e in filtered], "count": len(filtered)}
        proxy_ms = int((time.time() - started) * 1000)
        return JSONResponse(
            content=body,
            headers=_base_headers(tid, proxy_ms=proxy_ms),
        )

    def _compute_anomaly_rates(entries: list[dict[str, Any]]) -> tuple[float, float, int]:
        """Compute intervention_rate and extraction_failure_rate from decision entries.

        Skips checkpoint entries. Returns (intervention_rate, extraction_failure_rate, total).
        """
        total = 0
        interventions = 0
        extraction_failures = 0
        for e in entries:
            if e.get("type") == "checkpoint":
                continue
            outcome = e.get("outcome")
            if outcome is None:
                continue
            total += 1
            if outcome in ("FORCE_REVISE", "HARD_STOP"):
                interventions += 1
            if outcome == "HARD_STOP":
                fe = e.get("forensic_event") or {}
                failed = fe.get("failed_constraints") or []
                if isinstance(failed, list) and "EXTRACTION_FAILED" in failed:
                    extraction_failures += 1
        if total == 0:
            return 0.0, 0.0, 0
        return interventions / total, extraction_failures / total, total

    @app.get("/v1/audit/anomaly-check")
    async def audit_anomaly_check(
        window: str = Query("24h", description="Window: 24h, 7d, or ISO timestamp"),
    ):
        """P.3: Check intervention/extraction-failure rates against thresholds. JSONL backend only."""
        started = time.time()
        tid = trace_id_var.get() or f"proxy:{uuid.uuid4().hex}"

        if not cfg.governance.audit_log or not str(cfg.governance.audit_log).strip():
            return JSONResponse(
                status_code=404,
                content={"error": {"message": "audit_log not configured", "type": "not_found"}},
                headers=_base_headers(tid, outcome="ERROR", proxy_ms=int((time.time() - started) * 1000)),
            )
        if audit_sink == "ledger":
            return JSONResponse(
                status_code=400,
                content={"error": {"message": "Anomaly check supported for JSONL backend only", "type": "invalid_request_error"}},
                headers=_base_headers(tid, outcome="ERROR", proxy_ms=int((time.time() - started) * 1000)),
            )
        path = Path(cfg.governance.audit_log)
        all_entries = _read_audit_all(path)
        since_dt = _parse_since(window) if window else None
        filtered = _filter_audit_entries(
            all_entries,
            outcome=None,
            tenant_label=None,
            since=since_dt,
            limit=2000,
        )
        interv_rate, extr_rate, total = _compute_anomaly_rates(filtered)
        thr_interv = cfg.governance.threshold_intervention_rate or 0
        thr_extr = cfg.governance.threshold_extraction_failure_rate or 0
        alerts: list[str] = []
        if thr_interv > 0 and interv_rate >= thr_interv:
            alerts.append(f"intervention_rate {interv_rate:.2%} >= threshold {thr_interv:.2%}")
        if thr_extr > 0 and extr_rate >= thr_extr:
            alerts.append(f"extraction_failure_rate {extr_rate:.2%} >= threshold {thr_extr:.2%}")
        if alerts:
            _logger.warning("anomaly_alert", extra={"alerts": alerts, "window": window, "total": total})
            webhook_url = cfg.governance.anomaly_webhook_url
            if webhook_url:
                payload = {"alerts": alerts, "window": window, "total": total, "intervention_rate": interv_rate, "extraction_failure_rate": extr_rate}
                async def _post_webhook() -> None:
                    try:
                        import httpx
                        async with httpx.AsyncClient(timeout=10.0) as client:
                            await client.post(webhook_url, json=payload)
                    except Exception as exc:
                        _logger.warning("anomaly_webhook_failed", extra={"url": webhook_url, "error": str(exc)})
                asyncio.create_task(_post_webhook())
        body: Dict[str, Any] = {
            "window": window,
            "total_entries": total,
            "intervention_rate": interv_rate,
            "extraction_failure_rate": extr_rate,
            "thresholds": {
                "intervention_rate": thr_interv,
                "extraction_failure_rate": thr_extr,
            },
            "alerts": alerts,
        }
        proxy_ms = int((time.time() - started) * 1000)
        return JSONResponse(
            content=body,
            headers=_base_headers(tid, proxy_ms=proxy_ms),
        )

    @app.get("/v1/audit/verify")
    async def audit_verify(n: int = Query(20, ge=1, le=1000)):
        """Verify audit chain. Supports JSONL (HMAC + hash-chain) and ledger (hash-chain) backends."""
        started = time.time()
        tid = trace_id_var.get() or f"proxy:{uuid.uuid4().hex}"

        if not cfg.governance.audit_log or not str(cfg.governance.audit_log).strip():
            return JSONResponse(
                status_code=404,
                content={"error": {"message": "audit_log not configured", "type": "not_found"}},
                headers=_base_headers(tid, outcome="ERROR", proxy_ms=int((time.time() - started) * 1000)),
            )

        # Ledger backend: verify hash chain via ForensicLedger (no signing key required)
        if audit_sink == "ledger":
            if bridge is None:
                return JSONResponse(
                    status_code=503,
                    content={"error": {"message": "Ledger bridge not initialized", "type": "unavailable"}},
                    headers=_base_headers(tid, outcome="ERROR", proxy_ms=int((time.time() - started) * 1000)),
                )
            ledger_ok = bridge.verify_ledger()
            stats = bridge.ledger_stats or {}
            entry_count = stats.get("entries", stats.get("entry_count", 0))
            ledger_ms = int((time.time() - started) * 1000)
            body: Dict[str, Any] = {
                "verified": ledger_ok,
                "entries": entry_count,
                # hmac_verified: null means "not applicable" — ledger handles its own
                # hash-chain integrity; per-entry HMAC is not performed on this path.
                "hmac_verified": None,
                "chain_verified": ledger_ok,
                "backend": "ledger",
            }
            return JSONResponse(content=body, headers=_base_headers(tid, proxy_ms=ledger_ms))

        if not cfg.governance.audit_signing_key:
            return JSONResponse(
                status_code=400,
                content={
                    "error": {
                        "message": "audit_signing_key required for verification",
                        "type": "invalid_request_error",
                    }
                },
                headers=_base_headers(tid, outcome="ERROR", proxy_ms=int((time.time() - started) * 1000)),
            )
        # D4 multi-key: primary key + any historical keys for logs spanning key rotation
        _primary = cfg.governance.audit_signing_key.encode("utf-8")
        _extra = [k.encode("utf-8") for k in cfg.governance.audit_signing_keys if k]
        all_keys = [_primary] + _extra
        hmac_ok, entries_checked, first_failed = verify_audit_entries(
            cfg.governance.audit_log, n, signing_keys=all_keys
        )
        chain_ok, chain_entries, first_break_idx, chain_reason = verify_chain(
            cfg.governance.audit_log, n, signing_keys=all_keys
        )
        proxy_ms = int((time.time() - started) * 1000)
        verified = hmac_ok and chain_ok
        # chain_verified: True when the slice is internally consistent, even if not
        # anchored to genesis (unanchored_slice).  verified requires both hmac and
        # full-chain anchoring — so unanchored_slice sets chain_verified=True, verified=False.
        chain_verified = chain_ok or chain_reason == "unanchored_slice"
        body: Dict[str, Any] = {
            "verified": verified,
            "entries": entries_checked,
            "hmac_verified": hmac_ok,
            "chain_verified": chain_verified,
            "backend": "jsonl",
        }
        if chain_reason == "unanchored_slice":
            # Slice is internally consistent but not anchored to the chain head.
            # entry[0].prev_cid points to a predecessor outside the requested window.
            body["reason"] = "unanchored_slice"
            body["reason_detail"] = (
                "The slice is internally consistent but not anchored to the chain head. "
                "entry[0].prev_cid references a predecessor outside this window. "
                "Increase n or verify from the start of the log to prove global chain integrity."
            )
        elif not verified:
            if first_failed is not None:
                body["first_failed_entry"] = first_failed  # cid of first HMAC failure
            if first_break_idx is not None:
                body["first_break_index"] = first_break_idx
                body["reason"] = chain_reason
                if chain_reason == "chain_break":
                    body["reason_detail"] = (
                        "Chain break: entry at index {} has prev_cid that does not match "
                        "the previous entry's cid. Verify from the start of the log or "
                        "increase n to include the chain head."
                    ).format(first_break_idx)
        return JSONResponse(content=body, headers=_base_headers(tid, proxy_ms=proxy_ms))

    @app.post("/v1/chat/completions")
    async def chat_completions(request: Request):
        started = time.time()
        trace_id = trace_id_var.get() or f"proxy:{uuid.uuid4().hex}"
        proxy_ms = lambda: int((time.time() - started) * 1000)

        def _err_headers(session_id: str | None = None) -> Dict[str, str]:
            return _aurora_headers(
                outcome="ERROR",
                trace_id=trace_id,
                audit_sink=audit_sink,
                provider=cfg.upstream.provider,
                policy=cfg.governance.default_policy,
                policy_version=policy_version,
                session_id=session_id,
                proxy_ms=proxy_ms(),
            )

        # Phase 5: Read body with size limit
        try:
            body = await request.body()
        except Exception:
            return JSONResponse(
                status_code=400,
                content={"error": {"message": "Unable to read request body", "type": "invalid_request_error"}},
                headers=_err_headers(),
            )

        if h.max_payload_bytes > 0 and len(body) > h.max_payload_bytes:
            return JSONResponse(
                status_code=413,
                content={
                    "error": {
                        "message": f"Payload too large: {len(body)} bytes (max {h.max_payload_bytes})",
                        "type": "invalid_request_error",
                    }
                },
                headers=_err_headers(None),
            )

        try:
            payload = json.loads(body)
        except json.JSONDecodeError:
            return JSONResponse(
                status_code=400,
                content={"error": {"message": "Invalid JSON body", "type": "invalid_request_error"}},
                headers=_err_headers(),
            )
        except Exception:
            return JSONResponse(
                status_code=400,
                content={"error": {"message": "Unable to parse JSON", "type": "invalid_request_error"}},
                headers=_err_headers(),
            )

        # Phase 5: Validate payload structure
        if h.max_messages > 0 or h.max_content_chars > 0:
            try:
                _validate_payload(
                    payload,
                    max_messages=h.max_messages or 999_999,
                    max_content_chars=h.max_content_chars or 999_999_999,
                )
            except ValueError as e:
                return JSONResponse(
                    status_code=422,
                    content={"error": {"message": str(e), "type": "invalid_request_error"}},
                    headers=_err_headers(None),
                )

        # Phase 5: Rate limits (check before session_id so we can 429 early)
        if global_limiter is not None:
            allowed, retry_after = global_limiter.check(key=None)
            if not allowed:
                return JSONResponse(
                    status_code=429,
                    content={
                        "error": {
                            "message": "Rate limit exceeded (global)",
                            "type": "rate_limit_error",
                        }
                    },
                    headers={**_err_headers(None), "Retry-After": retry_after or "60"},
                )

        # Phase B.4: Per-IP rate limit
        if ip_limiter is not None:
            client_ip = _client_ip(request, h.trusted_proxy_ips)
            allowed, retry_after = ip_limiter.check(key=client_ip)
            if not allowed:
                return JSONResponse(
                    status_code=429,
                    content={
                        "error": {
                            "message": "Rate limit exceeded (per IP)",
                            "type": "rate_limit_error",
                        }
                    },
                    headers={**_err_headers(None), "Retry-After": retry_after or "60"},
                )

        try:
            parsed = parse_chat_request(payload)
        except ValueError as e:
            return JSONResponse(
                status_code=422,
                content={"error": {"message": str(e), "type": "invalid_request_error"}},
                headers=_err_headers(None),
            )

        session_id = _extract_session_id(request, payload)
        if not session_id or not str(session_id).strip():
            session_id = f"session-{uuid.uuid4().hex[:12]}"

        # L.2: X-Aurora-Mock-Hard-Stop — demo only. Quarantined: never active in enterprise (production) mode.
        mock_hard_stop = (
            cfg.governance.mode != "enterprise"
            and cfg.governance.enable_mock_hard_stop
            and str(request.headers.get("x-aurora-mock-hard-stop", "")).strip().lower() in ("1", "true", "yes")
        )
        token_mock = mock_hard_stop_var.set(mock_hard_stop)

        # Phase 5: Per-session rate limit
        if session_limiter is not None:
            allowed, retry_after = session_limiter.check(key=session_id)
            if not allowed:
                return JSONResponse(
                    status_code=429,
                    content={
                        "error": {
                            "message": "Rate limit exceeded (per session)",
                            "type": "rate_limit_error",
                        }
                    },
                    headers={**_err_headers(session_id), "Retry-After": retry_after or "60"},
                )

        lock_ctx = sessions.with_lock(session_id)
        lock_ctx.__enter__()
        try:
            lens, _is_new_session = sessions.get_or_create(session_id)

            _logger.info(
                "chat_completions_request",
                extra={
                    "aurora_trace_id": trace_id,
                    "aurora_session_id": session_id,
                    "stream": parsed.stream,
                    "user_message_len": len(parsed.user_message),
                    "history_turns": len(parsed.conversation_history),
                },
            )

            # Standard OpenAI clients send the full message history in every POST.
            # For a newly-created session the PEF is empty — replay prior user messages
            # through extraction (no LLM call) so entity context is available.
            if _is_new_session and parsed.conversation_history:
                await lens.seed_history(parsed.conversation_history)

            token = session_id_var.set(session_id)
            req_hash = hashlib.sha256(
                json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
            ).hexdigest()
            token_req = request_hash_var.set(req_hash)
            try:
                if parsed.stream:
                    try:
                        stream_iter = lens.process_stream(parsed.user_message, parsed.external_flags)
                        first = await stream_iter.__anext__()
                    except Exception as e:
                        import httpx as _httpx
                        sessions.persist(session_id, lens)
                        try:
                            lock_ctx.__exit__(None, None, None)
                        except Exception:
                            pass
                        if isinstance(e, _httpx.HTTPStatusError):
                            upstream_status = e.response.status_code
                            try:
                                upstream_detail = e.response.json().get("error", {}).get("message", "")
                            except Exception:
                                upstream_detail = e.response.text[:200]
                            _logger.error(
                                "aurora_upstream_http_error",
                                extra={"aurora_trace_id": trace_id, "aurora_session_id": session_id, "upstream_status": upstream_status, "upstream_detail": upstream_detail},
                            )
                            return JSONResponse(
                                status_code=502,
                                content={
                                    "error": {
                                        "message": f"Upstream API error {upstream_status}: {upstream_detail}",
                                        "type": "upstream_error",
                                        "code": "upstream_http_error",
                                        "upstream_status": upstream_status,
                                        "trace_id": trace_id,
                                    }
                                },
                                headers=_err_headers(session_id),
                            )
                        _logger.error(
                            "aurora_proxy_internal_error",
                            extra={"aurora_trace_id": trace_id, "aurora_session_id": session_id, "error": str(e)},
                            exc_info=True,
                        )
                        return JSONResponse(
                            status_code=500,
                            content={
                                "error": {
                                    "message": "Internal error during processing",
                                    "type": "server_error",
                                    "code": "aurora_proxy_internal_error",
                                    "trace_id": trace_id,
                                }
                            },
                            headers=_err_headers(session_id),
                        )
                    kind, payload = first
                    if kind == "extraction_failed":
                        sessions.persist(session_id, lens)
                        try:
                            lock_ctx.__exit__(None, None, None)
                        except Exception:
                            pass
                        metrics.inc("chat_completions_total", labels={"outcome": "EXTRACTION_FAILED"})
                        body = format_chat_response(
                            payload,
                            request_model=parsed.model or cfg.upstream.model,
                            include_operator_detail=cfg.governance.include_operator_detail,
                            session_id=session_id,
                        )
                        ah = _aurora_headers(
                            outcome=payload.action.name,
                            trace_id=trace_id,
                            audit_sink=audit_sink,
                            provider=cfg.upstream.provider,
                            policy=cfg.governance.default_policy,
                            policy_version=policy_version,
                            audit_id=payload.decision.cid if payload.decision and payload.decision.cid else None,
                            session_id=session_id,
                            proxy_ms=proxy_ms(),
                        )
                        return JSONResponse(content=_json_safe(body), status_code=200, headers=ah)

                    def _inject_session(aurora: dict) -> dict:
                        """Inject session_id routing handle into streaming aurora metadata."""
                        if session_id:
                            aurora["session_id"] = session_id
                        return aurora

                    async def _stream_gen():
                        _stream_aborted = False
                        try:
                            k, p = kind, payload
                            if k == "chunk":
                                chunk_dict, _ = p
                                yield f"data: {json.dumps(chunk_dict, separators=(',', ':'))}\n\n"
                            elif k == "governed_chunk":
                                # Non-ADMIT governed continuation: same SSE format as "chunk".
                                chunk_dict, _ = p
                                yield f"data: {json.dumps(chunk_dict, separators=(',', ':'))}\n\n"
                            elif k == "progress":
                                # Safe scaffolding signal — no content, no governance metadata.
                                yield f"data: {json.dumps(p.as_event_dict(), separators=(',', ':'))}\n\n"
                            elif k == "metadata":
                                _gflags = p.pop("_log_flags", [])
                                _logger.info(
                                    "governance_outcome",
                                    extra={
                                        "outcome": p.get("governance", "UNKNOWN"),
                                        "flags": _gflags,
                                        "flags_count": len(_gflags),
                                        "blocked": p.get("governance") == "HARD_STOP",
                                        "revised": p.get("governance") == "FORCE_REVISE",
                                        "audit_id": p.get("audit_id"),
                                        "policy": p.pop("_log_policy", None),
                                        "pathway_id": p.pop("_log_pathway", None),
                                        "commitment_closed": p.pop("_log_commitment_closed", None),
                                        "stream": True,
                                    },
                                )
                                yield format_stream_metadata_event(_inject_session(p))
                                yield "data: [DONE]\n\n"
                                return
                            async for k, p in stream_iter:
                                if k == "chunk":
                                    chunk_dict, _ = p
                                    yield f"data: {json.dumps(chunk_dict, separators=(',', ':'))}\n\n"
                                elif k == "governed_chunk":
                                    chunk_dict, _ = p
                                    yield f"data: {json.dumps(chunk_dict, separators=(',', ':'))}\n\n"
                                elif k == "progress":
                                    yield f"data: {json.dumps(p.as_event_dict(), separators=(',', ':'))}\n\n"
                                elif k == "metadata":
                                    _gflags = p.pop("_log_flags", [])
                                    _logger.info(
                                        "governance_outcome",
                                        extra={
                                            "outcome": p.get("governance", "UNKNOWN"),
                                            "flags": _gflags,
                                            "flags_count": len(_gflags),
                                            "blocked": p.get("governance") == "HARD_STOP",
                                            "revised": p.get("governance") == "FORCE_REVISE",
                                            "audit_id": p.get("audit_id"),
                                            "policy": p.pop("_log_policy", None),
                                            "pathway_id": p.pop("_log_pathway", None),
                                            "commitment_closed": p.pop("_log_commitment_closed", None),
                                            "stream": True,
                                        },
                                    )
                                    yield format_stream_metadata_event(_inject_session(p))
                                    yield "data: [DONE]\n\n"
                        except asyncio.CancelledError:
                            # Client disconnected mid-stream (uvicorn cancels the task).
                            # Suppress — generator ends cleanly; FastAPI sees StopAsyncIteration,
                            # not an unhandled exception, so no 500 is emitted.
                            _stream_aborted = True
                            _logger.info(
                                "aurora_proxy_stream_client_disconnect",
                                extra={"aurora_trace_id": trace_id, "aurora_session_id": session_id},
                            )
                        except (BrokenPipeError, ConnectionResetError) as e:
                            _stream_aborted = True
                            _logger.info(
                                "aurora_proxy_stream_connection_reset",
                                extra={"aurora_trace_id": trace_id, "aurora_session_id": session_id, "error": str(e)},
                            )
                        except Exception as e:
                            _logger.error(
                                "aurora_proxy_stream_error",
                                extra={"aurora_trace_id": trace_id, "aurora_session_id": session_id, "error": str(e)},
                                exc_info=True,
                            )
                            err_event = json.dumps({
                                "error": {
                                    "message": "Internal error during streaming",
                                    "type": "server_error",
                                    "code": "aurora_proxy_internal_error",
                                    "trace_id": trace_id,
                                }
                            }, separators=(",", ":"))
                            yield f"data: {err_event}\n\n"
                        finally:
                            sessions.persist(session_id, lens)
                            if _stream_aborted and cfg.governance.audit_log:
                                try:
                                    _sk = (
                                        cfg.governance.audit_signing_key.encode("utf-8")
                                        if cfg.governance.audit_signing_key else None
                                    )
                                    append_audit_entry(
                                        cfg.governance.audit_log,
                                        {
                                            "type": "stream_abort",
                                            "trace_id": trace_id,
                                            "session_id": session_id,
                                            "timestamp": datetime.datetime.now(
                                                datetime.timezone.utc
                                            ).isoformat(),
                                            "stream_completed": False,
                                            "stream_abort_reason": "client_disconnect",
                                        },
                                        signing_key=_sk,
                                        max_mb=cfg.governance.audit_log_max_mb,
                                    )
                                except OSError:
                                    pass
                            try:
                                lock_ctx.__exit__(None, None, None)
                            except Exception:
                                pass

                    metrics.inc("chat_completions_total", labels={"outcome": "STREAM"})
                    return StreamingResponse(
                        _stream_gen(),
                        media_type="text/event-stream",
                        headers=_aurora_headers(
                            outcome="STREAM",
                            trace_id=trace_id,
                            audit_sink=audit_sink,
                            provider=cfg.upstream.provider,
                            policy=cfg.governance.default_policy,
                            policy_version=policy_version,
                            session_id=session_id,
                            proxy_ms=proxy_ms(),
                        ),
                    )

                try:
                    out = lens.process(parsed.user_message, parsed.external_flags)
                    if inspect.isawaitable(out):
                        out = await out
                except Exception as e:
                    import httpx as _httpx
                    if isinstance(e, _httpx.HTTPStatusError):
                        upstream_status = e.response.status_code
                        try:
                            upstream_detail = e.response.json().get("error", {}).get("message", "")
                        except Exception:
                            upstream_detail = e.response.text[:200]
                        _logger.error(
                            "aurora_upstream_http_error",
                            extra={"aurora_trace_id": trace_id, "aurora_session_id": session_id, "upstream_status": upstream_status, "upstream_detail": upstream_detail},
                        )
                        return JSONResponse(
                            status_code=502,
                            content={
                                "error": {
                                    "message": f"Upstream API error {upstream_status}: {upstream_detail}",
                                    "type": "upstream_error",
                                    "code": "upstream_http_error",
                                    "upstream_status": upstream_status,
                                    "trace_id": trace_id,
                                }
                            },
                            headers=_err_headers(session_id),
                        )
                    if isinstance(e, _httpx.RequestError):
                        _logger.error(
                            "aurora_upstream_request_error",
                            extra={"aurora_trace_id": trace_id, "aurora_session_id": session_id, "error": str(e)},
                        )
                        return JSONResponse(
                            status_code=502,
                            content={
                                "error": {
                                    "message": f"Upstream connection error: {type(e).__name__}: {e}",
                                    "type": "upstream_error",
                                    "code": "upstream_request_error",
                                    "trace_id": trace_id,
                                }
                            },
                            headers=_err_headers(session_id),
                        )
                    _logger.error(
                        "aurora_proxy_internal_error",
                        extra={"aurora_trace_id": trace_id, "aurora_session_id": session_id, "error": str(e)},
                        exc_info=True,
                    )
                    return JSONResponse(
                        status_code=500,
                        content={
                            "error": {
                                "message": "Internal error during processing",
                                "type": "server_error",
                                "code": "aurora_proxy_internal_error",
                                "trace_id": trace_id,
                            }
                        },
                        headers=_err_headers(session_id),
                    )

                if isinstance(out, LensResult):
                    metrics.inc("chat_completions_total", labels={"outcome": out.action.name})
                    body = format_chat_response(out, request_model=parsed.model or cfg.upstream.model, include_operator_detail=cfg.governance.include_operator_detail, session_id=session_id)
                    outcome = out.action.name
                    audit_id = out.decision.cid if out.decision and out.decision.cid else None
                    _decision_flags = out.decision.flags if out.decision else out.flags
                    _logger.info(
                        "governance_outcome",
                        extra={
                            "outcome": outcome,
                            "flags": [f.flag_type.name for f in _decision_flags],
                            "flags_count": len(_decision_flags),
                            "blocked": outcome == "HARD_STOP",
                            "revised": outcome == "FORCE_REVISE",
                            "audit_id": audit_id,
                            "policy": out.decision.policy if out.decision else cfg.governance.default_policy,
                            "pathway_id": out.decision.pathway_id if out.decision else None,
                            "commitment_closed": out.decision.commitment_closed if out.decision else False,
                            "stream": False,
                        },
                    )
                    ah = _aurora_headers(
                        outcome=outcome,
                        trace_id=trace_id,
                        audit_sink=audit_sink,
                        provider=cfg.upstream.provider,
                        policy=cfg.governance.default_policy,
                        policy_version=policy_version,
                        audit_id=audit_id,
                        session_id=session_id,
                        proxy_ms=proxy_ms(),
                    )
                    return JSONResponse(content=_json_safe(body), status_code=200, headers=ah)

                # Invariant breach: Lens must return LensResult
                _logger.error(
                    "aurora_proxy_invariant_breach",
                    extra={
                        "aurora_trace_id": trace_id,
                        "aurora_session_id": session_id,
                        "out_type": type(out).__name__,
                    },
                )
                return JSONResponse(
                    status_code=500,
                    content={
                        "error": {
                            "message": "Internal invariant breach: pipeline did not return LensResult",
                            "type": "server_error",
                            "code": "aurora_proxy_invariant_breach",
                            "trace_id": trace_id,
                        }
                    },
                    headers=_err_headers(session_id),
                )
            finally:
                request_hash_var.reset(token_req)
                session_id_var.reset(token)
                mock_hard_stop_var.reset(token_mock)
        finally:
            # Non-streaming path: persist and release lock
            if not parsed.stream:
                sessions.persist(session_id, lens)
                try:
                    lock_ctx.__exit__(None, None, None)
                except Exception:
                    pass

    return app


def build_app_from_config_path(config_path: str) -> FastAPI:
    cfg = ProxyConfig.from_yaml(config_path)
    return create_app(cfg)
