"""AEP reverse proxy — sits between agent and LLM API.

Intercepts OpenAI-compatible requests, runs safety checks on input and output,
tracks cost, and enforces PASS/FLAG/BLOCK decisions.
"""

from __future__ import annotations

import asyncio
import json
import logging
import os
import uuid
from collections.abc import Sequence
from datetime import UTC, datetime
from decimal import Decimal
from pathlib import Path
from typing import Any
from urllib.parse import urlparse

import httpx
from pydantic import ValidationError
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import JSONResponse, RedirectResponse, Response
from starlette.routing import Mount, Route

from ..costs import CostTracker
from ..enforcement import EnforcementDecision, EnforcementPolicy, evaluate
from ..observability.events import FlaggedCall, ObservabilityEvent
from ..observability.store import EventStore
from ..safety.base import DetectorRegistry, SafetyDetector, SafetySignal
from ..safety.custom import (
    CustomPolicy,
    CustomPolicyStore,
    CustomSafetyDetector,
    default_custom_policies,
)
from ..spans import SpanTracker
from ..types import Usage
from .headers import build_response_headers, parse_aep_headers, strip_aep_headers
from .logutil import configure_proxy_debug_logging
from .openclaw_sync import get_configured_path as _openclaw_config_path
from .openclaw_sync import refresh_openclaw_config
from .redis_publisher import build_event, publish_event
from .state_manager import (
    ProxyStateManager,
    get_max_entities,
    is_multi_tenant_enabled,
    per_entity_state_path,
)
from .state_persistence import (
    get_configured_path as _state_persistence_path,
)
from .state_persistence import (
    load_persisted_state,
    save_persisted_state,
)

log = logging.getLogger(__name__)

# Models where usage field uses different names
_ANTHROPIC_STYLE = frozenset({"claude", "anthropic"})


async def _read_json_body(request: Request) -> tuple[Any | None, Response | None]:
    try:
        return await request.json(), None
    except Exception:
        return None, Response(
            '{"error": "invalid JSON"}', status_code=400, media_type="application/json"
        )


def _parse_custom_policy_write_fields(body: Any) -> dict[str, Any] | JSONResponse:
    """Require ``name``, ``rule``, and ``enabled``; optional ``applies_to``, ``severity``."""
    if not isinstance(body, dict):
        return JSONResponse({"error": "body must be a JSON object"}, status_code=400)
    required = ("name", "rule", "enabled")
    for k in required:
        if k not in body:
            return JSONResponse(
                {"error": "name, rule, and enabled are required"},
                status_code=400,
            )
    fields = {k: body[k] for k in required}
    if "applies_to" in body:
        fields["applies_to"] = body["applies_to"]
    if "severity" in body:
        fields["severity"] = body["severity"]
    return fields


def _custom_policy_from_write_fields(
    fields: dict[str, Any], *, policy_id: str | None = None
) -> CustomPolicy | JSONResponse:
    """Build a ``CustomPolicy`` from parsed fields; POST omits ``policy_id`` (new uuid)."""
    payload = dict(fields)
    if policy_id is not None:
        payload["id"] = policy_id
    try:
        return CustomPolicy.model_validate(payload).eager()
    except ValidationError as exc:
        return JSONResponse(
            {"error": "validation failed", "detail": exc.errors()},
            status_code=422,
        )


def _extract_text_from_messages(messages: list[dict[str, Any]]) -> str:
    """Extract text content from OpenAI-format messages."""
    parts: list[str] = []
    for msg in messages:
        content = msg.get("content", "")
        if isinstance(content, str):
            parts.append(content)
        elif isinstance(content, list):
            for block in content:
                if isinstance(block, dict) and block.get("type") == "text":
                    parts.append(block.get("text", ""))
    return " ".join(parts)


def _extract_output_text(data: dict[str, Any]) -> str:
    """Extract assistant text from an OpenAI-format response."""
    parts: list[str] = []
    for choice in data.get("choices", []):
        msg = choice.get("message", {})
        content = msg.get("content")
        if content:
            parts.append(content)
    return " ".join(parts)


def _extract_usage(data: dict[str, Any]) -> tuple[str, int, int]:
    """Extract (model, input_tokens, output_tokens) from response."""
    model = data.get("model", "unknown")
    usage = data.get("usage", {})
    input_tokens = usage.get("prompt_tokens", 0) or usage.get("input_tokens", 0) or 0
    output_tokens = usage.get("completion_tokens", 0) or usage.get("output_tokens", 0) or 0
    return model, input_tokens, output_tokens


def _detect_provider(target_url: str) -> str:
    """Extract a short provider name from the target URL."""
    lower = target_url.lower()
    if "anthropic" in lower:
        return "anthropic"
    if "openai" in lower:
        return "openai"
    if "ollama" in lower or "11434" in lower:
        return "ollama"
    if "google" in lower or "generativelanguage" in lower:
        return "google"
    return urlparse(target_url).hostname or target_url


def _ensure_openai_stream_usage(body: dict[str, Any], path: str) -> None:
    """Ask OpenAI-compatible chat completion streams to include token usage in SSE.

    Without ``stream_options.include_usage``, OpenAI omits ``usage`` from all stream
    chunks, so the proxy records zero tokens and the dashboard shows no spend.
    """
    if not body.get("stream") or "/chat/completions" not in path:
        return
    opts = body.get("stream_options")
    if not isinstance(opts, dict):
        body["stream_options"] = {}
    body["stream_options"]["include_usage"] = True


class ProxyState:
    """Shared state for the proxy — cost tracking, safety signals, spans."""

    def __init__(
        self,
        target_base_url: str = "https://api.openai.com",
        detectors: Sequence[SafetyDetector] | None = None,
        policy: EnforcementPolicy | dict[str, Any] | str | None = None,
        budget: float | None = None,
        budget_per_session: float | None = None,
        event_store: EventStore | None = None,
        state_path: Path | None = None,
    ) -> None:
        self.target_base_url = target_base_url.rstrip("/")
        self.event_store = event_store
        # When set, ProxyState mirrors a small slice of itself (api_key,
        # connected_account, target_base_url, safety_enabled) to this file
        # so a restart doesn't wipe the dashboard-configured state. None
        # disables persistence — that's the wrap()/test path.
        self._state_path = state_path
        self.session_id = uuid.uuid4().hex
        self.cost_tracker = CostTracker()
        self.span_tracker = SpanTracker()
        self.registry = DetectorRegistry()
        self.policy = EnforcementPolicy.from_config(policy)
        self.signals: list[SafetySignal] = []
        self.call_count = 0
        self.decisions: list[EnforcementDecision] = []
        self._call_costs: list[Decimal] = []
        self.governance_contexts: list[dict[str, Any]] = []
        self._started_at = datetime.now(UTC)
        self.blocked_count = 0
        self.safety_enabled = True
        # Runtime BYOK API key — when set, the proxy owns auth and substitutes
        # this key for whatever Authorization the caller sent. Set via
        # POST /dashboard/api/api-key, or seeded from the matching env var at
        # startup so users who supply OPENAI_API_KEY / ANTHROPIC_API_KEY via
        # .env don't need to re-enter it in the dashboard.
        env_key_name = (
            "ANTHROPIC_API_KEY" if "anthropic" in self.target_base_url else "OPENAI_API_KEY"
        )
        env_key = os.environ.get(env_key_name, "").strip()
        self.api_key: str | None = env_key or None
        # Identity associated with the active key, populated when the user
        # comes through the AceTeam connect flow (or, eventually, via a
        # /api/whoami introspect on existing keys). None for raw BYOK users
        # since we have no way to look them up.
        self.connected_account: dict[str, Any] | None = None
        # Last status from the openclaw.json auto-sync (when AEP_OPENCLAW_CONFIG_PATH
        # is configured). The dashboard polls this to surface a "catalog
        # updated — restart gateway to apply" banner. None until the first
        # sync attempt for this state.
        self.openclaw_config_status: dict[str, Any] | None = None
        self.budget = Decimal(str(budget)) if budget is not None else None
        self.budget_per_session = (
            Decimal(str(budget_per_session)) if budget_per_session is not None else None
        )

        to_register: list[SafetyDetector] = list(
            detectors if detectors is not None else _default_proxy_detectors()
        )
        custom_dets = [d for d in to_register if isinstance(d, CustomSafetyDetector)]
        if len(custom_dets) > 1:
            raise ValueError(
                "At most one CustomSafetyDetector may be supplied; "
                f"found {len(custom_dets)} instances"
            )
        if custom_dets:
            self.custom_policy_store = custom_dets[0].store
        else:
            self.custom_policy_store = CustomPolicyStore(initial=default_custom_policies())
            to_register.append(CustomSafetyDetector(self.custom_policy_store))
        for det in to_register:
            self.registry.add(det)

        # Apply any state from a prior run last, so the persisted api_key
        # (e.g. an act_* from a dashboard connect) overrides the env-seeded
        # one above. Persistence is opt-in via state_path; wrap()/tests pass
        # None and get the original in-memory behavior.
        if self._state_path is not None:
            persisted = load_persisted_state(self._state_path)
            if persisted is not None:
                self._apply_persisted(persisted)

    def _apply_persisted(self, fields: dict[str, Any]) -> None:
        """Restore fields from a prior run. Silently skips bad/missing values."""
        api_key = fields.get("api_key")
        if isinstance(api_key, str) and api_key:
            self.api_key = api_key
        connected = fields.get("connected_account")
        if isinstance(connected, dict) or connected is None:
            self.connected_account = connected
        url = fields.get("target_base_url")
        if isinstance(url, str) and url:
            self.target_base_url = url.rstrip("/")
        enabled = fields.get("safety_enabled")
        if isinstance(enabled, bool):
            self.safety_enabled = enabled

    def _persist(self) -> None:
        """Write the current control-plane slice to disk; no-op if disabled."""
        if self._state_path is None:
            return
        save_persisted_state(
            self._state_path,
            {
                "api_key": self.api_key,
                "connected_account": self.connected_account,
                "target_base_url": self.target_base_url,
                "safety_enabled": self.safety_enabled,
            },
        )

    @property
    def cost_usd(self) -> Decimal:
        return self.cost_tracker.total_spent()

    def check_budget(self) -> str | None:
        """Check if budget is exceeded. Returns error message or None."""
        cost = self.cost_usd
        if self.budget is not None and cost >= self.budget:
            return f"Total budget exceeded: ${cost:.4f} >= ${self.budget:.4f}"
        if self.budget_per_session is not None and cost >= self.budget_per_session:
            return f"Session budget exceeded: ${cost:.4f} >= ${self.budget_per_session:.4f}"
        return None

    @property
    def budget_remaining(self) -> float | None:
        """Remaining budget in USD, or None if no budget set."""
        if self.budget is not None:
            return float(self.budget - self.cost_usd)
        if self.budget_per_session is not None:
            return float(self.budget_per_session - self.cost_usd)
        return None

    @property
    def latest_enforcement(self) -> EnforcementDecision:
        if self.decisions:
            return self.decisions[-1]
        return EnforcementDecision(action="pass")

    @property
    def estimated_savings(self) -> Decimal:
        """Estimated cost saved by blocking calls before they reach the LLM."""
        if not self._call_costs:
            return Decimal("0")
        avg = Decimal(sum(self._call_costs) / len(self._call_costs))
        return avg * self.blocked_count

    def _cost_by_span_id(self) -> dict[str, float]:
        """Build a lookup of span_id → cost in USD."""
        lookup: dict[str, float] = {}
        for node in self.cost_tracker.get_cost_tree():
            sid = node.metadata.get("span_id")
            if sid:
                lookup[sid] = lookup.get(sid, 0.0) + float(node.compute_cost)
        return lookup

    def to_dict(self) -> dict[str, Any]:
        """State as JSON-serializable dict for the dashboard."""
        decision = self.latest_enforcement
        cost_lookup = self._cost_by_span_id()
        return {
            "cost": float(self.cost_usd),
            "calls": self.call_count,
            "action": decision.action,
            "reason": decision.reason,
            "session_started": self._started_at.isoformat(),
            "safety_enabled": self.safety_enabled,
            "signals": [
                {
                    "type": s.signal_type,
                    "severity": s.severity,
                    "detail": s.detail,
                    "call_id": s.call_id,
                    "detector": s.detector,
                    "timestamp": s.timestamp,
                    "score": s.score,
                }
                for s in self.signals
            ],
            "spans": [
                {
                    "id": s.span_id,
                    "call_id": s.metadata.get("call_id"),
                    "executor": s.executor_id,
                    "status": s.status,
                    "duration_ms": s.duration_ms,
                    "started_at": s.started_at,
                    "cost": cost_lookup.get(s.span_id, 0.0),
                }
                for s in self.span_tracker.get_spans()
            ],
            "governance": self.governance_contexts,
            "budget": {
                "total": float(self.budget) if self.budget else None,
                "per_session": float(self.budget_per_session) if self.budget_per_session else None,
                "spent": float(self.cost_usd),
                "remaining": self.budget_remaining,
            }
            if (self.budget or self.budget_per_session)
            else None,
            "savings": {
                "blocked_calls": self.blocked_count,
                "estimated_savings_usd": float(self.estimated_savings),
                "avg_call_cost_usd": float(sum(self._call_costs) / len(self._call_costs))
                if self._call_costs
                else 0.0,
            },
            "attestation": None,  # populated by proxy when signing enabled
        }


def _default_proxy_detectors() -> Sequence[SafetyDetector]:
    """Default detectors for the proxy.

    Returns an empty sequence: :class:`ProxyState` always registers
    :class:`CustomSafetyDetector` with a :class:`CustomPolicyStore` so
    natural-language policies from the dashboard/API are enforced. Add other
    detectors via ``--detector``, policy-driven ``build_detectors_from_policy``,
    or by passing an explicit ``detectors=`` list to :func:`create_proxy_app`.
    """
    return ()


def create_proxy_app(
    target_base_url: str = "https://api.openai.com",
    detectors: Sequence[SafetyDetector] | None = None,
    policy: EnforcementPolicy | dict[str, Any] | str | None = None,
    dashboard: bool = True,
    sign_key: Any | None = None,
    signer_id: str = "proxy:default",
    budget: float | None = None,
    budget_per_session: float | None = None,
    debug: bool = False,
    event_store: EventStore | None = None,
) -> Starlette:
    """Create the AEP proxy ASGI app."""

    base_state_path = _state_persistence_path()

    state = ProxyState(
        target_base_url=target_base_url,
        detectors=detectors,
        policy=policy,
        budget=budget,
        budget_per_session=budget_per_session,
        event_store=event_store,
        state_path=base_state_path,
    )

    # Multi-tenant manager — opt-in via AEP_MULTI_TENANT=1. When the env flag
    # is unset (the default), the manager always returns the singleton ``state``
    # so existing single-tenant deployments and tests are unaffected. When
    # enabled, requests with ``X-AEP-Entity`` (or distinct ``Authorization``
    # bearer keys) get isolated ``ProxyState`` buckets, capped at
    # ``AEP_MAX_ENTITIES`` (default 64) with LRU eviction.
    def _entity_state_factory(entity_id: str) -> ProxyState:
        return ProxyState(
            target_base_url=target_base_url,
            detectors=detectors,
            policy=policy,
            budget=budget,
            budget_per_session=budget_per_session,
            event_store=event_store,
            state_path=per_entity_state_path(base_state_path, entity_id),
        )

    state_manager = ProxyStateManager(
        multi_tenant=is_multi_tenant_enabled(),
        max_entities=get_max_entities(),
        default=state,
        factory=_entity_state_factory,
    )

    # Enable debug logging if requested (see logutil; uvicorn/defaults drop DEBUG)
    if debug:
        configure_proxy_debug_logging()

    # Attestation engine (optional — enabled when sign_key is provided)
    attestation_engine = None
    if sign_key is not None:
        from ..attestation import AttestationEngine

        attestation_engine = AttestationEngine(_private_key=sign_key, signer_id=signer_id)

    # Override state.to_dict to include attestation data
    _orig_to_dict = state.to_dict

    def _state_with_attestation() -> dict[str, Any]:
        d = _orig_to_dict()
        if attestation_engine is not None:
            d["attestation"] = {
                "enabled": True,
                "signer_id": attestation_engine.signer_id,
                "chain_height": attestation_engine.chain_height,
                "latest_hash": (
                    attestation_engine.chain[-1]["chain_hash"] if attestation_engine.chain else None
                ),
            }
        return d

    # Replace the get_state callable used by dashboard
    _get_state = _state_with_attestation

    async def proxy_handler(request: Request) -> Response:
        """Forward request to target API with safety interception."""
        # Resolve per-request state at the very top so any deferred work
        # (streaming on-complete callbacks, asyncio.ensure_future events)
        # captures *this* request's state via closure rather than re-resolving
        # later when the contextual entity may differ.
        state = state_manager.get_for_request(request)
        call_id = uuid.uuid4().hex[:8]
        path = request.url.path
        body_bytes = await request.body()

        # --- CALL_START EVENT ---
        # Parse body early to get model for call_start event
        try:
            _body_peek = json.loads(body_bytes) if body_bytes else {}
        except json.JSONDecodeError:
            _body_peek = {}
        if state.event_store:
            asyncio.ensure_future(state.event_store.record(ObservabilityEvent(
                session_id=state.session_id, type="call_start", call_id=call_id,
                model=_body_peek.get("model") if isinstance(_body_peek, dict) else None,
                provider=_detect_provider(state.target_base_url),
            )))

        # Debug logging for all requests (both input and output)
        if debug:
            try:
                body = json.loads(body_bytes) if body_bytes else {}
            except json.JSONDecodeError:
                body = {}
            log.debug(
                "REQUEST %s %s %s: headers=%s, body=%s",
                call_id,
                request.method,
                path,
                dict(request.headers),
                body,
            )

        # Parse request body
        try:
            body = json.loads(body_bytes) if body_bytes else {}
        except json.JSONDecodeError:
            body = {}

        # --- BUDGET CHECK ---
        budget_error = state.check_budget()
        if budget_error:
            _instance_id = os.environ.get("AEP_INSTANCE_ID", "")
            if _instance_id:
                await publish_event(
                    build_event(
                        instance_id=_instance_id,
                        event_type="budget_warning",
                        action="block",
                        message=budget_error,
                    )
                )
            return Response(
                json.dumps(
                    {
                        "error": {
                            "message": f"AEP budget: {budget_error}",
                            "type": "aep_budget_exceeded",
                            "code": "budget_exceeded",
                        }
                    }
                ),
                status_code=429,
                media_type="application/json",
            )

        # --- PARSE AEP GOVERNANCE HEADERS ---
        aep_ctx = parse_aep_headers(request.headers)
        gov_context: dict[str, Any] | None = None
        if aep_ctx.entity != "default" or aep_ctx.classification != "public" or aep_ctx.consent:
            gov_context = {
                "call_id": call_id,
                "entity": aep_ctx.entity,
                "classification": aep_ctx.classification,
                "consent": aep_ctx.consent,
                "budget": str(aep_ctx.budget) if aep_ctx.budget is not None else None,
                "sources": aep_ctx.sources,
                "trace_id": aep_ctx.trace_id,
            }
            state.governance_contexts.append(gov_context)

        # --- INPUT SAFETY CHECK ---
        input_text = ""
        if "messages" in body:
            input_text = _extract_text_from_messages(body["messages"])

        if state.safety_enabled:
            input_signals = await state.registry.run_all(
                input_text=input_text,
                output_text="",
                call_id=call_id,
            )

            if len(input_signals) > 0:
                input_decision = evaluate(input_signals, state.policy)
                if input_decision.action == "block":
                    state.signals.extend(input_signals)
                    state.decisions.append(input_decision)
                    state.call_count += 1
                    state.blocked_count += 1
                    log.warning("BLOCKED request %s: %s", call_id, input_decision.reason)
                    _instance_id = os.environ.get("AEP_INSTANCE_ID", "")
                    if _instance_id:
                        _detector = input_signals[0].detector if input_signals else None
                        _severity = input_signals[0].severity if input_signals else None
                        await publish_event(
                            build_event(
                                instance_id=_instance_id,
                                event_type="safety_block",
                                action="block",
                                message=input_decision.reason or "Input blocked by safety filter",
                                detector=_detector,
                                severity=_severity,
                            )
                        )
                    # --- OBSERVABILITY: input-blocked early return ---
                    if state.event_store:
                        for sig in input_signals:
                            asyncio.ensure_future(state.event_store.record(ObservabilityEvent(
                                session_id=state.session_id, type="safety_signal", call_id=call_id,
                                detector=sig.detector, severity=sig.severity, reason=sig.detail,
                            )))
                        asyncio.ensure_future(state.event_store.record(ObservabilityEvent(
                            session_id=state.session_id, type="enforcement", call_id=call_id,
                            action="block", reason=input_decision.reason,
                            metadata={"policy": {"block_on": sorted(state.policy.block_on), "flag_on": sorted(state.policy.flag_on)}},
                        )))
                        _block_detector = input_signals[0].detector if input_signals else None
                        _block_severity = input_signals[0].severity if input_signals else None
                        asyncio.ensure_future(state.event_store.record_flagged_call(FlaggedCall(
                            call_id=call_id, session_id=state.session_id,
                            action="block",
                            detector=_block_detector, severity=_block_severity,
                            reason=input_decision.reason,
                            model=body.get("model") if isinstance(body, dict) else None,
                            input_messages=body.get("messages", []) if isinstance(body, dict) else [],
                            output_text=None,
                        )))
                        asyncio.ensure_future(state.event_store.record(ObservabilityEvent(
                            session_id=state.session_id, type="call_end", call_id=call_id,
                            model=body.get("model") if isinstance(body, dict) else None,
                            provider=_detect_provider(state.target_base_url),
                            tokens_in=0, tokens_out=0, cost_usd=0.0, latency_ms=None,
                        )))
                    return JSONResponse(
                        status_code=400,
                        content={
                            "error": {
                                "message": (
                                    f"AEP safety: request blocked — {input_decision.reason}"
                                ),
                                "type": "aep_safety_block",
                                "code": "safety_block",
                            }
                        },
                    )
            # Emit safety_signal events for non-blocked input signals
            if state.event_store and len(input_signals) > 0:
                for sig in input_signals:
                    asyncio.ensure_future(state.event_store.record(ObservabilityEvent(
                        session_id=state.session_id, type="safety_signal", call_id=call_id,
                        detector=sig.detector, severity=sig.severity, reason=sig.detail,
                    )))
        else:
            input_signals = ()

        # --- FORWARD TO TARGET API ---
        target_url = f"{state.target_base_url}{path}"

        # Forward headers (especially Authorization), stripping AEP governance headers
        forward_headers: dict[str, str] = {}
        if request.headers.get("authorization"):
            forward_headers["Authorization"] = request.headers["authorization"]
        if request.headers.get("content-type"):
            forward_headers["Content-Type"] = request.headers["content-type"]
        # Forward anthropic-specific headers
        for key in ("x-api-key", "anthropic-version"):
            if request.headers.get(key):
                forward_headers[key] = request.headers[key]
        # When the proxy owns a managed key (set via env or dashboard BYOK), it
        # overrides whatever Authorization the caller sent. This lets clients
        # (OpenClaw, the in-dashboard playground, etc.) send any placeholder
        # key — e.g. the "aep-proxy-managed" sentinel — and have the proxy
        # substitute the real credential before forwarding.
        if state.api_key:
            if "anthropic" in state.target_base_url or path == "/v1/messages":
                forward_headers["x-api-key"] = state.api_key
                forward_headers.setdefault("anthropic-version", "2023-06-01")
                forward_headers.pop("Authorization", None)
            else:
                forward_headers["Authorization"] = f"Bearer {state.api_key}"
                forward_headers.pop("x-api-key", None)
        # Strip X-AEP-* headers so they don't leak to the LLM provider
        forward_headers = strip_aep_headers(forward_headers)

        # --- STREAMING BRANCH ---
        if body.get("stream"):
            _ensure_openai_stream_usage(body, path)
            body_bytes = json.dumps(body).encode()

            from .streaming import handle_streaming_request

            # Start span BEFORE the stream begins to measure actual latency
            stream_span = state.span_tracker.start_span(
                executor_type="llm",
                executor_id="pending",
                metadata={"call_id": call_id, "streaming": True},
            )

            def on_stream_complete(**kwargs: Any) -> None:
                model = kwargs.get("model", "unknown")
                inp = kwargs.get("input_tokens", 0)
                out = kwargs.get("output_tokens", 0)
                signals = kwargs.get("signals", [])
                decision = kwargs.get("decision")

                # Update span with actual model info
                stream_span.executor_id = model
                usage = Usage(
                    prompt_tokens=inp,
                    completion_tokens=out,
                    total_tokens=inp + out,
                )
                cost_node = state.cost_tracker.record_llm_cost(
                    span_id=stream_span.span_id,
                    model=model,
                    usage=usage,
                )
                state._call_costs.append(cost_node.compute_cost)
                state.span_tracker.end_span(stream_span.span_id)
                state.call_count += 1
                state.signals.extend(signals)
                if decision:
                    state.decisions.append(decision)

                _instance_id = os.environ.get("AEP_INSTANCE_ID", "")
                if _instance_id and decision:
                    _event_type = "safety_block" if decision.action == "block" else "llm_call"
                    _detector = decision.signals[0].detector if decision.signals else None
                    _severity = decision.signals[0].severity if decision.signals else None
                    asyncio.ensure_future(
                        publish_event(
                            build_event(
                                instance_id=_instance_id,
                                event_type=_event_type,
                                action=decision.action,
                                message=decision.reason
                                or f"{model} streaming call: {inp} in, {out} out",
                                cost_usd=float(cost_node.compute_cost) if cost_node else 0,
                                model=model,
                                tokens_in=inp,
                                tokens_out=out,
                                detector=_detector,
                                severity=_severity,
                            )
                        )
                    )

                # --- OBSERVABILITY: streaming call_end + enforcement + flagged_call ---
                if state.event_store:
                    for sig in signals:
                        asyncio.ensure_future(state.event_store.record(ObservabilityEvent(
                            session_id=state.session_id, type="safety_signal", call_id=call_id,
                            detector=sig.detector, severity=sig.severity, reason=sig.detail,
                        )))
                    _stream_action = decision.action if decision else "pass"
                    _stream_reason = decision.reason if decision else None
                    asyncio.ensure_future(state.event_store.record(ObservabilityEvent(
                        session_id=state.session_id, type="enforcement", call_id=call_id,
                        action=_stream_action, reason=_stream_reason,
                        metadata={"policy": {"block_on": sorted(state.policy.block_on), "flag_on": sorted(state.policy.flag_on)}},
                    )))
                    asyncio.ensure_future(state.event_store.record(ObservabilityEvent(
                        session_id=state.session_id, type="call_end", call_id=call_id,
                        model=model, provider=_detect_provider(state.target_base_url),
                        tokens_in=inp, tokens_out=out,
                        cost_usd=float(cost_node.compute_cost), latency_ms=stream_span.duration_ms,
                    )))
                    if decision and decision.action in ("flag", "block"):
                        _s_detector = signals[0].detector if signals else None
                        _s_severity = signals[0].severity if signals else None
                        _s_output = kwargs.get("output_text")
                        asyncio.ensure_future(state.event_store.record_flagged_call(FlaggedCall(
                            call_id=call_id, session_id=state.session_id,
                            action=decision.action,
                            detector=_s_detector, severity=_s_severity,
                            reason=decision.reason,
                            model=model,
                            input_messages=body.get("messages", []) if isinstance(body, dict) else [],
                            output_text=_s_output if decision.action == "flag" else None,
                        )))

            return await handle_streaming_request(
                target_url=target_url,
                body_bytes=body_bytes,
                headers=forward_headers,
                call_id=call_id,
                input_text=input_text,
                registry=state.registry,
                policy=state.policy,
                on_complete=on_stream_complete,
                debug=debug,
            )

        # --- NON-STREAMING BRANCH ---
        # Start span BEFORE the upstream call to measure actual latency
        span = state.span_tracker.start_span(
            executor_type="llm",
            executor_id="pending",
            metadata={"call_id": call_id},
        )

        async with httpx.AsyncClient(timeout=120.0) as client:
            upstream_resp = await client.request(
                method=request.method,
                url=target_url,
                content=body_bytes,
                headers=forward_headers,
            )

        # If upstream errored, pass through
        if upstream_resp.status_code >= 400:
            state.span_tracker.end_span(span.span_id, status="ERROR")
            return Response(
                content=upstream_resp.content,
                status_code=upstream_resp.status_code,
                headers=dict(upstream_resp.headers),
            )

        # Parse response
        try:
            resp_data = upstream_resp.json()
        except json.JSONDecodeError:
            state.span_tracker.end_span(span.span_id, status="ERROR")
            return Response(
                content=upstream_resp.content,
                status_code=upstream_resp.status_code,
            )

        # --- RECORD COST + SPANS ---
        model, input_tokens, output_tokens = _extract_usage(resp_data)
        output_text = _extract_output_text(resp_data)

        # Update span with actual model info now that we have the response
        span.executor_id = model
        usage = Usage(
            prompt_tokens=input_tokens,
            completion_tokens=output_tokens,
            total_tokens=input_tokens + output_tokens,
        )
        cost_node = state.cost_tracker.record_llm_cost(
            span_id=span.span_id,
            model=model,
            usage=usage,
        )
        state._call_costs.append(cost_node.compute_cost)
        state.span_tracker.end_span(span.span_id)
        state.call_count += 1

        # --- OUTPUT SAFETY CHECK ---
        if state.safety_enabled:
            output_signals = await state.registry.run_all(
                input_text=input_text,
                output_text=output_text,
                call_id=call_id,
                call_cost=cost_node.compute_cost,
            )

            # Emit safety_signal events for output signals
            if state.event_store:
                for sig in output_signals:
                    asyncio.ensure_future(state.event_store.record(ObservabilityEvent(
                        session_id=state.session_id, type="safety_signal", call_id=call_id,
                        detector=sig.detector, severity=sig.severity, reason=sig.detail,
                    )))

            all_signals = (*input_signals, *output_signals)
            state.signals.extend(all_signals)

            decision = evaluate(all_signals, state.policy)
            state.decisions.append(decision)

            # --- OBSERVABILITY: enforcement + call_end + flagged_call ---
            if state.event_store:
                asyncio.ensure_future(state.event_store.record(ObservabilityEvent(
                    session_id=state.session_id, type="enforcement", call_id=call_id,
                    action=decision.action, reason=decision.reason,
                    metadata={"policy": {"block_on": sorted(state.policy.block_on), "flag_on": sorted(state.policy.flag_on)}},
                )))
                asyncio.ensure_future(state.event_store.record(ObservabilityEvent(
                    session_id=state.session_id, type="call_end", call_id=call_id,
                    model=model, provider=_detect_provider(state.target_base_url),
                    tokens_in=input_tokens, tokens_out=output_tokens,
                    cost_usd=float(cost_node.compute_cost), latency_ms=span.duration_ms,
                )))
                if decision.action in ("flag", "block"):
                    _obs_detector = all_signals[0].detector if all_signals else None
                    _obs_severity = all_signals[0].severity if all_signals else None
                    asyncio.ensure_future(state.event_store.record_flagged_call(FlaggedCall(
                        call_id=call_id, session_id=state.session_id,
                        action=decision.action,
                        detector=_obs_detector, severity=_obs_severity,
                        reason=decision.reason,
                        model=model, input_messages=body.get("messages", []) if isinstance(body, dict) else [],
                        output_text=output_text if decision.action == "flag" else None,
                    )))

            if decision.action == "block":
                log.warning("BLOCKED response %s: %s", call_id, decision.reason)
                return JSONResponse(
                    status_code=400,
                    content={
                        "error": {
                            "message": (f"AEP safety: response blocked — {decision.reason}"),
                            "type": "aep_safety_block",
                            "code": "safety_block",
                        }
                    },
                )
        else:
            all_signals = []
            decision = EnforcementDecision(action="pass")
            state.decisions.append(decision)
            # --- OBSERVABILITY: enforcement + call_end (safety disabled) ---
            if state.event_store:
                asyncio.ensure_future(state.event_store.record(ObservabilityEvent(
                    session_id=state.session_id, type="enforcement", call_id=call_id,
                    action="pass", reason=None,
                )))
                asyncio.ensure_future(state.event_store.record(ObservabilityEvent(
                    session_id=state.session_id, type="call_end", call_id=call_id,
                    model=model, provider=_detect_provider(state.target_base_url),
                    tokens_in=input_tokens, tokens_out=output_tokens,
                    cost_usd=float(cost_node.compute_cost), latency_ms=span.duration_ms,
                )))

        # --- PUBLISH EVENT TO REDIS ---
        _instance_id = os.environ.get("AEP_INSTANCE_ID", "")
        if _instance_id:
            _event_type = "safety_block" if decision.action == "block" else "llm_call"
            _detector = decision.signals[0].detector if decision.signals else None
            _severity = decision.signals[0].severity if decision.signals else None
            await publish_event(
                build_event(
                    instance_id=_instance_id,
                    event_type=_event_type,
                    action=decision.action,
                    message=decision.reason
                    or f"{model} call: {input_tokens} in, {output_tokens} out",
                    cost_usd=float(cost_node.compute_cost) if cost_node else 0,
                    model=model,
                    tokens_in=input_tokens if usage else None,
                    tokens_out=output_tokens if usage else None,
                    detector=_detector,
                    severity=_severity,
                )
            )

        # --- PASS THROUGH (with AEP metadata header) ---
        resp_headers = build_response_headers(
            cost=cost_node.compute_cost,
            enforcement=decision.action,
            call_id=call_id,
            classification=aep_ctx.classification,
            flag_reason=decision.reason if decision.action == "flag" else "",
            trace_id=aep_ctx.trace_id,
        )

        # Sign verdict and add attestation headers (if signing enabled)
        if attestation_engine is not None:
            signal_dicts = [
                {"signal_type": s.signal_type, "severity": s.severity, "detail": s.detail}
                for s in all_signals
            ]
            attest_headers = attestation_engine.sign_verdict(
                call_id=call_id,
                action=decision.action,
                signals=signal_dicts,
                confidence=next(
                    (
                        s.score
                        for s in all_signals
                        if s.signal_type == "trust_engine" and s.score is not None
                    ),
                    None,
                ),
            )
            resp_headers.update(attest_headers)

        # Debug: log the response
        if debug:
            log.debug(
                (
                    "RESPONSE %s %s %s: model=%s, input_tokens=%d, output_tokens=%d, "
                    "status=%d, body=%s"
                ),
                call_id,
                request.method,
                path,
                model,
                input_tokens,
                output_tokens,
                upstream_resp.status_code,
                resp_data,
            )

        return Response(
            content=upstream_resp.content,
            status_code=upstream_resp.status_code,
            headers=resp_headers,
        )

    # Dashboard routes
    routes: list[Route | Mount] = [
        # Catch-all proxy route for OpenAI-compatible paths
        Route("/v1/{path:path}", proxy_handler, methods=["POST", "GET"]),
        # Also handle Anthropic paths
        Route("/v1/messages", proxy_handler, methods=["POST"]),
    ]

    if dashboard:
        from ..dashboard.app import create_app as create_dashboard

        dashboard_app = create_dashboard(get_state=_get_state)
        # Look up endpoints by path to avoid fragile positional indices
        dash_endpoints = {r.path: r.endpoint for r in dashboard_app.routes}  # type: ignore[union-attr]

        async def _redirect_dashboard_trailing_slash(_request: Request) -> RedirectResponse:
            # Relative URLs like `api/state` resolve to /dashboard/api/state only when
            # the page URL ends with /dashboard/; otherwise the browser resolves
            # `api/state` to /api/state.
            return RedirectResponse(url="/dashboard/", status_code=307)

        routes.extend(
            [
                Route("/dashboard", _redirect_dashboard_trailing_slash, methods=["GET"]),
                Route("/dashboard/", dash_endpoints["/"]),
                Route("/dashboard/ciso", dash_endpoints["/ciso"]),
                Route("/dashboard/api/state", dash_endpoints["/api/state"]),
            ]
        )

    # Server-side feedback store — path is NOT user-configurable via API
    # to prevent path traversal attacks.
    from ..feedback import FeedbackStore

    _feedback_store = FeedbackStore("aep-feedback.jsonl")

    # Feedback API — POST /dashboard/api/feedback
    async def feedback_handler(request: Request) -> Response:
        """Record a signal verdict (confirmed/dismissed) from an operator."""
        try:
            body = await request.json()
        except Exception:
            return Response('{"error": "invalid JSON"}', status_code=400)

        signal_type = body.get("signal_type")
        verdict = body.get("verdict")
        if not signal_type or verdict not in ("confirmed", "dismissed"):
            return Response(
                '{"error": "signal_type and verdict (confirmed|dismissed) required"}',
                status_code=400,
            )

        v = _feedback_store.record(
            signal_type,
            score=body.get("score"),
            verdict=verdict,
            detail=body.get("detail", ""),
        )
        return Response(
            json.dumps({"ok": True, "verdict": v.to_dict()}),
            media_type="application/json",
        )

    # Feedback summary API — GET /dashboard/api/feedback/summary
    async def feedback_summary_handler(request: Request) -> Response:
        """Return feedback analysis and threshold recommendations."""
        from ..feedback import recommend_thresholds

        store = _feedback_store

        # Extract current thresholds from policy
        current_thresholds: dict[str, float | None] = {}
        for name, override in state.policy.overrides.items():
            current_thresholds[name] = override.threshold

        summary = recommend_thresholds(
            store,
            current_thresholds=current_thresholds,
            min_verdicts=int(request.query_params.get("min_verdicts", "5")),
        )

        recs = {}
        for sig_type, rec in summary.recommendations.items():
            recs[sig_type] = {
                "current_threshold": rec.current_threshold,
                "suggested_threshold": rec.suggested_threshold,
                "total_verdicts": rec.total_verdicts,
                "confirmed": rec.confirmed,
                "dismissed": rec.dismissed,
                "false_positive_rate": round(rec.false_positive_rate, 3),
                "reason": rec.reason,
            }

        return Response(
            json.dumps({"total_verdicts": summary.total_verdicts, "recommendations": recs}),
            media_type="application/json",
        )

    # Safety toggle API — GET/POST /dashboard/api/safety
    async def safety_toggle_handler(request: Request) -> Response:
        """Toggle safety on/off or swap the enforcement policy at runtime.

        GET returns current state. POST accepts {"enabled": bool} and/or {"policy": dict}.
        """
        state = state_manager.get_for_request(request)
        if request.method == "GET":
            return Response(
                json.dumps(
                    {
                        "safety_enabled": state.safety_enabled,
                        "policy": {
                            "default_action": state.policy.default_action,
                            "block_on": sorted(state.policy.block_on),
                            "flag_on": sorted(state.policy.flag_on),
                            "detectors": {
                                k: {
                                    "action": v.action,
                                    "threshold": v.threshold,
                                    "enabled": v.enabled,
                                    **({"extra": dict(v.extra)} if v.extra else {}),
                                }
                                for k, v in state.policy.overrides.items()
                            },
                        },
                    }
                ),
                media_type="application/json",
            )

        try:
            body = await request.json()
        except Exception:
            return Response('{"error": "invalid JSON"}', status_code=400)

        # Toggle safety enabled/disabled
        if "enabled" in body:
            state.safety_enabled = bool(body["enabled"])
            log.info("Safety %s", "enabled" if state.safety_enabled else "disabled")
            state._persist()

        # Hot-swap policy from inline dict (not file paths — prevents path traversal)
        if "policy" in body:
            policy_data = body["policy"]
            if isinstance(policy_data, dict):
                state.policy = EnforcementPolicy.from_dict(policy_data)
                log.info("Policy swapped at runtime")
            else:
                return Response(
                    '{"error": "policy must be a JSON object, not a file path"}',
                    status_code=400,
                )

        return Response(
            json.dumps(
                {
                    "safety_enabled": state.safety_enabled,
                    "policy": {
                        "default_action": state.policy.default_action,
                        "block_on": sorted(state.policy.block_on),
                        "flag_on": sorted(state.policy.flag_on),
                        "detectors": {
                            k: {
                                "action": v.action,
                                "threshold": v.threshold,
                                "enabled": v.enabled,
                                **({"extra": dict(v.extra)} if v.extra else {}),
                            }
                            for k, v in state.policy.overrides.items()
                        },
                    },
                }
            ),
            media_type="application/json",
        )

    def _normalize_policy_id(policy_id: str) -> str | None:
        try:
            return str(uuid.UUID(policy_id))
        except ValueError:
            return None

    # Custom policies CRUD (collection + item routes below).
    async def custom_policies_collection_handler(request: Request) -> Response:
        state = state_manager.get_for_request(request)
        if request.method == "GET":
            policies = [p.model_dump() for p in state.custom_policy_store.all()]
            return Response(
                json.dumps({"policies": policies}),
                media_type="application/json",
            )

        body, json_err = await _read_json_body(request)
        if json_err is not None:
            return json_err
        fields = _parse_custom_policy_write_fields(body)
        if isinstance(fields, JSONResponse):
            return fields
        policy = _custom_policy_from_write_fields(fields, policy_id=None)
        if isinstance(policy, JSONResponse):
            return policy
        state.custom_policy_store.upsert(policy)
        return Response(
            json.dumps(policy.model_dump()),
            status_code=201,
            media_type="application/json",
        )

    async def custom_policy_item_handler(request: Request) -> Response:
        state = state_manager.get_for_request(request)
        raw_id = request.path_params["policy_id"]
        policy_id = _normalize_policy_id(raw_id)
        if policy_id is None:
            return JSONResponse({"error": "invalid policy id"}, status_code=400)

        if request.method == "GET":
            policy = state.custom_policy_store.get(policy_id)
            if policy is None:
                return JSONResponse({"error": "custom policy not found"}, status_code=404)
            return Response(json.dumps(policy.model_dump()), media_type="application/json")

        if request.method == "DELETE":
            if state.custom_policy_store.get(policy_id) is None:
                return JSONResponse({"error": "custom policy not found"}, status_code=404)
            state.custom_policy_store.delete(policy_id)
            return Response(status_code=204)

        # PUT — replace policy fields (id fixed from URL). Omitted ``applies_to`` /
        # ``severity`` keep the previous values so toggles can send only name/rule/enabled.
        body, json_err = await _read_json_body(request)
        if json_err is not None:
            return json_err
        existing_before = state.custom_policy_store.get(policy_id)
        if existing_before is None:
            return JSONResponse({"error": "custom policy not found"}, status_code=404)
        fields = _parse_custom_policy_write_fields(body)
        if isinstance(fields, JSONResponse):
            return fields
        payload = dict(fields)
        if isinstance(body, dict):
            if "applies_to" not in body:
                payload["applies_to"] = existing_before.applies_to
            if "severity" not in body:
                payload["severity"] = existing_before.severity
        updated = _custom_policy_from_write_fields(payload, policy_id=policy_id)
        if isinstance(updated, JSONResponse):
            return updated
        state.custom_policy_store.upsert(updated)
        return Response(json.dumps(updated.model_dump()), media_type="application/json")

    # Best-effort upstream identity refresh. For act_* gateway keys we can
    # ask the upstream `/api/whoami` endpoint who owns the key; the dashboard
    # then renders "Connected as <email>" instead of the cryptic prefix.
    # No-op for BYOK keys (sk-/sk-ant-) since OpenAI/Anthropic don't expose
    # a comparable identity endpoint and we have no way to look up identity.
    async def _refresh_connected_account(state: ProxyState = state) -> None:
        key = state.api_key
        if not key or not key.startswith("act_"):
            return
        parsed = urlparse(state.target_base_url)
        if not parsed.scheme or not parsed.netloc:
            return
        whoami_url = f"{parsed.scheme}://{parsed.netloc}/api/whoami"
        try:
            async with httpx.AsyncClient(timeout=10.0) as client:
                resp = await client.get(
                    whoami_url, headers={"Authorization": f"Bearer {key}"}
                )
            if resp.status_code != 200:
                log.warning(
                    "whoami refresh: %s returned %d", whoami_url, resp.status_code
                )
                return
            data = resp.json()
            email = data.get("email")
            if not isinstance(email, str) or not email.strip():
                # Without an email there's nothing useful to display, so
                # don't shadow the friendly-label fallback with a half-empty
                # account record.
                return
            state.connected_account = {
                "email": email.strip(),
                "organization_id": data.get("organization_id")
                if isinstance(data.get("organization_id"), str)
                else None,
                "organization_name": data.get("organization_name")
                if isinstance(data.get("organization_name"), str)
                else None,
            }
        except Exception as err:  # noqa: BLE001 — best-effort fetch
            log.warning("whoami refresh failed: %s", err)

    # BYOK API key management — GET returns a hint, POST sets the key, DELETE clears.
    # The raw key is only held in process memory (never written to disk) and is used
    # as the fallback Authorization header when a /v1/* request arrives without one.
    async def api_key_handler(request: Request) -> Response:
        state = state_manager.get_for_request(request)

        def _classify(key: str) -> str:
            # Surface where the key came from so the dashboard can show
            # "Connected to AceTeam" instead of the cryptic "KEY: act_bb3..."
            # when the user used the sign-in flow. Prefixes are stable and
            # vendor-published; matching here avoids round-tripping to the
            # mint service just to know what to label the chip.
            if key.startswith("act_"):
                return "aceteam"
            if key.startswith("sk-ant-"):
                return "anthropic"
            if key.startswith("sk-"):
                return "openai"
            return "byok"

        def _hint(key: str | None) -> dict[str, Any]:
            if not key:
                return {
                    "set": False,
                    "hint": None,
                    "provider": None,
                    "connected_account": None,
                }
            # prefix + suffix lets users match the hint against entries in
            # their AceTeam API keys list. 7+4 keeps the surface area visible
            # but reveals less than half of any reasonable-length key (act_*
            # is 32 chars, sk-* is 51, sk-ant-* is 100+).
            if len(key) >= 12:
                hint = f"{key[:7]}...{key[-4:]}"
            elif len(key) > 7:
                hint = f"{key[:7]}..."
            else:
                hint = f"{key[:3]}..."
            return {
                "set": True,
                "hint": hint,
                "provider": _classify(key),
                "connected_account": state.connected_account,
            }

        if request.method == "GET":
            return JSONResponse(_hint(state.api_key))

        if request.method == "DELETE":
            state.api_key = None
            state.connected_account = None
            state._persist()
            return JSONResponse(_hint(None))

        body, json_err = await _read_json_body(request)
        if json_err is not None:
            return json_err
        if not isinstance(body, dict):
            return JSONResponse({"error": "body must be a JSON object"}, status_code=400)
        key = body.get("api_key")
        if not isinstance(key, str) or not key.strip():
            return JSONResponse(
                {"error": "api_key must be a non-empty string"}, status_code=400
            )
        state.api_key = key.strip()
        # Reset identity so a stale account doesn't shadow the new key.
        # The dashboard re-supplies identity for keys minted via the connect
        # flow; for everything else we'll try the upstream /api/whoami refresh
        # below if the prefix looks like a gateway key.
        state.connected_account = None
        connected = body.get("connected_account")
        if isinstance(connected, dict):
            email = connected.get("email")
            if isinstance(email, str) and email.strip():
                state.connected_account = {
                    "email": email.strip(),
                    "organization_id": connected.get("organization_id")
                    if isinstance(connected.get("organization_id"), str)
                    else None,
                    "organization_name": connected.get("organization_name")
                    if isinstance(connected.get("organization_name"), str)
                    else None,
                }
        base_url = body.get("base_url")
        if isinstance(base_url, str) and base_url.strip():
            candidate = base_url.strip().rstrip("/")
            if not (candidate.startswith("http://") or candidate.startswith("https://")):
                return JSONResponse(
                    {"error": "base_url must start with http:// or https://"},
                    status_code=400,
                )
            # Proxy forwards by concatenating target_base_url with the incoming
            # request path, which already starts with /v1/... A trailing /v1 on
            # the target produces /v1/v1/chat/completions and 404s. Strip it so
            # OpenAI-SDK conventions (base_url ending in /v1) work transparently.
            if candidate.endswith("/v1"):
                candidate = candidate[: -len("/v1")]
            state.target_base_url = candidate
            log.info("Upstream base URL updated to %s", candidate)
        log.info("BYOK API key updated (len=%d)", len(state.api_key))
        # If the caller didn't pre-supply identity, try fetching it from the
        # upstream gateway. Skip for non-act_* keys inside _refresh_*.
        if state.connected_account is None:
            await _refresh_connected_account(state)
        # Auto-sync the OpenClaw catalog from the AceTeam gateway whenever a
        # fresh act_* key lands. Opt-in via env: when AEP_OPENCLAW_CONFIG_PATH
        # is set (i.e., compose mounts the openclaw config dir into the proxy),
        # we mirror /v1/models into openclaw.json so users don't have to
        # hand-edit it as new models get seeded into the AceTeam catalog.
        config_path = _openclaw_config_path()
        if config_path and state.api_key and state.api_key.startswith("act_"):
            state.openclaw_config_status = await refresh_openclaw_config(
                api_key=state.api_key,
                target_base_url=state.target_base_url,
                config_path=config_path,
            )
        # Persist after _refresh_connected_account / catalog sync so the
        # written file reflects the post-refresh identity, not just the raw
        # caller-supplied bits.
        state._persist()
        return JSONResponse(_hint(state.api_key))

    # Policy tester — evaluate a single custom policy against arbitrary text,
    # so users can sanity-check a rule from the dashboard without wiring up an
    # agent. Mirrors programasweights.com/browser but against policies that
    # already live in this process.
    async def policy_test_handler(request: Request) -> Response:
        state = state_manager.get_for_request(request)
        body, json_err = await _read_json_body(request)
        if json_err is not None:
            return json_err
        if not isinstance(body, dict):
            return JSONResponse({"error": "body must be a JSON object"}, status_code=400)
        raw_id = body.get("policy_id")
        text = body.get("text", "")
        if not isinstance(raw_id, str) or not isinstance(text, str):
            return JSONResponse(
                {"error": "policy_id (string) and text (string) required"}, status_code=400
            )
        policy_id = _normalize_policy_id(raw_id)
        if policy_id is None:
            return JSONResponse({"error": "invalid policy id"}, status_code=400)
        policy = state.custom_policy_store.get(policy_id)
        if policy is None:
            return JSONResponse({"error": "custom policy not found"}, status_code=404)
        try:
            passes = await policy(text)
        except Exception as exc:
            log.warning("Policy test failed for %s: %s", policy.name, exc)
            return JSONResponse(
                {"error": "policy evaluation failed", "detail": str(exc)},
                status_code=500,
            )
        return JSONResponse(
            {
                "policy_id": policy.id,
                "policy_name": policy.name,
                "passes": passes,
                "severity": policy.severity,
                "applies_to": policy.applies_to,
            }
        )

    # OpenClaw config sync status — drives the dashboard banner that tells
    # users when openclaw.json was auto-refreshed from the AceTeam catalog
    # and whether they need to restart the gateway to apply it. Status is
    # populated by api_key_handler after a successful act_* connect.
    async def openclaw_config_status_handler(request: Request) -> Response:
        if request.method != "GET":
            return JSONResponse({"error": "GET only"}, status_code=405)
        config_path = _openclaw_config_path()
        return JSONResponse(
            {
                "enabled": config_path is not None,
                "config_path": config_path,
                "status": state.openclaw_config_status,
            }
        )

    # Routing topology — describes the chain of hops the proxy is configured
    # to use, and what each hop enforces. Drives the dashboard's topology
    # panel so the user can see "OpenClaw → aep-proxy → AceTeam Gateway →
    # OpenAI" and which layer their policies live in.
    async def routing_handler(request: Request) -> Response:
        if request.method != "GET":
            return JSONResponse({"error": "GET only"}, status_code=405)
        state = state_manager.get_for_request(request)

        # Layer 1: this proxy. Always present. List the active detectors and
        # the count of enabled custom policies so the user can see what
        # actually fires before traffic leaves the machine.
        detector_names = [d.name for d in state.registry._detectors]  # noqa: SLF001
        enabled_custom = sum(
            1 for p in state.custom_policy_store.all() if p.enabled
        )
        # End-to-end LLM latency (proxy → upstream → response → proxy) — the
        # only hop pair we can measure from inside the proxy. We surface it
        # under the local-proxy card since that's where the timer lives, but
        # it's really the full proxy↔provider round trip.
        from statistics import stdev  # noqa: PLC0415

        durations = [
            s.duration_ms
            for s in state.span_tracker.get_spans()
            if s.executor_type == "llm" and s.duration_ms is not None
        ]
        latency_stats: dict[str, Any] | None
        if durations:
            avg = sum(durations) / len(durations)
            sd = stdev(durations) if len(durations) > 1 else 0.0
            latency_stats = {
                "count": len(durations),
                "avg_ms": round(avg, 1),
                "stddev_ms": round(sd, 1),
            }
        else:
            latency_stats = None
        local_hop: dict[str, Any] = {
            "name": "aep-proxy",
            "label": "Local proxy",
            "scope": "local",
            "url": "http://localhost:8899/v1",
            "detectors": detector_names,
            "custom_policies_enabled": enabled_custom,
            "safety_enabled": state.safety_enabled,
            "latency": latency_stats,
        }

        # Layer 2 (optional): the AceTeam gateway, when target_base_url
        # points at aceteam.ai. Otherwise we go straight to the provider
        # (OpenAI/Anthropic/etc.) via the proxy's BYOK target.
        hops: list[dict[str, Any]] = [local_hop]
        target = state.target_base_url
        target_label: str | None = None
        target_provider: str | None = None
        # When the gateway is the middle hop, the actual upstream LLM URL
        # depends on which model is requested (gateway routes openai-* to
        # api.openai.com, claude-* to api.anthropic.com, etc.). Don't echo
        # the gateway URL on the provider card — that just looks duplicated.
        provider_url: str | None = target
        if "aceteam.ai" in target:
            hops.append(
                {
                    "name": "aceteam-gateway",
                    "label": "AceTeam Gateway",
                    "scope": "upstream",
                    "url": target,
                    "enforces": [
                        "auth (act_* key)",
                        "credit accounting",
                        "AceTeam baseline policies",
                    ],
                }
            )
            target_label = "OpenAI / Anthropic"
            target_provider = "via aceteam.ai"
            provider_url = None
        elif "anthropic" in target:
            target_label = "Anthropic"
            target_provider = "anthropic"
        elif "openai" in target:
            target_label = "OpenAI"
            target_provider = "openai"
        elif "palebluedot" in target or "tokenrouter" in target:
            target_label = "TokenRouter (Pale Blue Dot)"
            target_provider = "tokenrouter"
        else:
            # Custom BYOK target — show the host so the user knows where
            # their traffic is going.
            from urllib.parse import urlparse  # noqa: PLC0415

            target_label = urlparse(target).netloc or target
            target_provider = "byok"

        hops.append(
            {
                "name": "provider",
                "label": target_label,
                "scope": "provider",
                "url": provider_url,
                "provider": target_provider,
            }
        )

        return JSONResponse(
            {
                "client": "OpenClaw",
                "target_base_url": target,
                "hops": hops,
            }
        )

    routes.extend(
        [
            Route("/dashboard/api/feedback", feedback_handler, methods=["POST"]),
            Route(
                "/dashboard/api/feedback/summary", feedback_summary_handler, methods=["GET"]
            ),
            Route("/dashboard/api/safety", safety_toggle_handler, methods=["POST", "GET"]),
            Route(
                "/dashboard/api/custom-policies/{policy_id}",
                custom_policy_item_handler,
                methods=["GET", "PUT", "DELETE"],
            ),
            Route(
                "/dashboard/api/custom-policies",
                custom_policies_collection_handler,
                methods=["GET", "POST"],
            ),
            Route(
                "/dashboard/api/api-key",
                api_key_handler,
                methods=["GET", "POST", "DELETE"],
            ),
            Route(
                "/dashboard/api/policy-test",
                policy_test_handler,
                methods=["POST"],
            ),
            Route(
                "/dashboard/api/routing",
                routing_handler,
                methods=["GET"],
            ),
            Route(
                "/dashboard/api/openclaw-config-status",
                openclaw_config_status_handler,
                methods=["GET"],
            ),
        ]
    )

    # --- Observability API routes (require both dashboard and event_store) ---
    if dashboard and event_store:
        from ..observability.incidents import build_incident_bundle
        from ..observability.views.timeline import get_timeline
        from ..observability.views.topology import get_topology
        from ..observability.views.traffic import get_traffic_stats

        async def incidents_list_handler(request: Request) -> Response:
            params = request.query_params
            incidents = await event_store.query_flagged_calls(
                session_id=params.get("session_id"),
                verdict=params.get("verdict"),
                limit=int(params.get("limit", "50")),
            )
            return Response(
                json.dumps({"incidents": [fc.model_dump() for fc in incidents]}),
                media_type="application/json",
            )

        async def incidents_detail_handler(request: Request) -> Response:
            call_id = request.path_params["call_id"]
            flagged = await event_store.query_flagged_calls()
            match = [fc for fc in flagged if fc.call_id == call_id]
            if not match:
                return Response(
                    '{"error": "not found"}',
                    status_code=404,
                    media_type="application/json",
                )
            fc = match[0]
            events = await event_store.query_events(session_id=fc.session_id, limit=500)
            return Response(
                json.dumps(
                    {
                        "flagged_call": fc.model_dump(),
                        "events": [e.model_dump() for e in events],
                    }
                ),
                media_type="application/json",
            )

        async def incidents_export_handler(request: Request) -> Response:
            call_id = request.path_params["call_id"]
            bundle = await build_incident_bundle(event_store, call_id=call_id)
            if bundle is None:
                return Response(
                    '{"error": "not found"}',
                    status_code=404,
                    media_type="application/json",
                )
            return Response(
                content=bundle,
                media_type="application/zip",
                headers={
                    "Content-Disposition": f"attachment; filename=incident_{call_id}.zip"
                },
            )

        async def incidents_verdict_handler(request: Request) -> Response:
            call_id = request.path_params["call_id"]
            try:
                body = await request.json()
            except Exception:
                return Response(
                    '{"error": "invalid JSON"}',
                    status_code=400,
                    media_type="application/json",
                )
            verdict = body.get("verdict")
            if verdict not in ("confirmed", "dismissed"):
                return Response(
                    '{"error": "verdict must be confirmed or dismissed"}',
                    status_code=400,
                    media_type="application/json",
                )
            await event_store.update_verdict(
                call_id=call_id,
                verdict=verdict,
                verdict_by=body.get("verdict_by", "unknown"),
                verdict_note=body.get("verdict_note"),
            )
            return Response(json.dumps({"ok": True}), media_type="application/json")

        async def timeline_handler(request: Request) -> Response:
            params = request.query_params
            spans = await get_timeline(
                event_store,
                session_id=params.get("session_id"),
                since=params.get("since"),
                limit=int(params.get("limit", "200")),
            )
            return Response(
                json.dumps({"spans": spans}),
                media_type="application/json",
            )

        async def traffic_handler(request: Request) -> Response:
            params = request.query_params
            stats = await get_traffic_stats(
                event_store,
                session_id=params.get("session_id"),
                since=params.get("since"),
            )
            return Response(json.dumps(stats), media_type="application/json")

        async def topology_handler(request: Request) -> Response:
            params = request.query_params
            topo = await get_topology(
                event_store,
                session_id=params.get("session_id"),
                since=params.get("since"),
            )
            return Response(json.dumps(topo), media_type="application/json")

        routes.extend(
            [
                Route(
                    "/dashboard/api/incidents",
                    incidents_list_handler,
                    methods=["GET"],
                ),
                Route(
                    "/dashboard/api/incidents/{call_id}/export",
                    incidents_export_handler,
                    methods=["GET"],
                ),
                Route(
                    "/dashboard/api/incidents/{call_id}/verdict",
                    incidents_verdict_handler,
                    methods=["PATCH"],
                ),
                Route(
                    "/dashboard/api/incidents/{call_id}",
                    incidents_detail_handler,
                    methods=["GET"],
                ),
                Route("/dashboard/api/timeline", timeline_handler, methods=["GET"]),
                Route("/dashboard/api/traffic", traffic_handler, methods=["GET"]),
                Route("/dashboard/api/topology", topology_handler, methods=["GET"]),
            ]
        )

    # Mount MCP gateway if fastmcp is installed
    mcp_http_app = None
    try:
        from ..mcp_gateway import create_mcp_app

        mcp_http_app = create_mcp_app(state)
        if mcp_http_app is not None:
            from starlette.routing import Mount

            routes.append(Mount("/mcp", app=mcp_http_app))
            log.info("MCP gateway mounted at /mcp/")
    except Exception as exc:
        log.debug("MCP gateway not available: %s", exc)

    # Always provide a lifespan so we can refresh `connected_account` for
    # an env-seeded act_* key at startup. If MCP is also mounted, nest its
    # lifespan inside ours so FastMCP still gets its task group init.
    from contextlib import asynccontextmanager

    @asynccontextmanager
    async def lifespan(app):
        # Best-effort identity refresh for keys that survived a restart
        # (env-seeded or, eventually, persisted). Runs in the background
        # so a slow/unreachable upstream doesn't block server startup.
        asyncio.create_task(_refresh_connected_account())
        if mcp_http_app is not None and hasattr(mcp_http_app, "lifespan"):
            async with mcp_http_app.lifespan(app):
                yield
        else:
            yield

    return Starlette(routes=routes, lifespan=lifespan)


__all__ = ["ProxyState", "create_proxy_app"]
