"""Dashboard API routes for savings, metrics, and status.

Mounted under ``/api`` by :func:`llmhost.proxy.app.create_app`.
Provides all the data the web and TUI dashboards need.
"""

from __future__ import annotations

import asyncio
import json
import logging
from datetime import datetime, timezone
from typing import TYPE_CHECKING

from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect

# NOTE: ``Request`` must be imported at runtime (not inside TYPE_CHECKING)
# because FastAPI needs to resolve the annotation to identify it as an
# injected dependency when ``from __future__ import annotations`` is active.
from starlette.requests import Request  # noqa: TC002

from llmhosts.proxy.dashboard_models import (
    BackendsResponse,
    BackendStatus,
    CacheEntriesResponse,
    CacheEntryItem,
    CacheInvalidateRequest,
    CacheInvalidateResponse,
    CacheNamespaceItem,
    CacheNamespacesResponse,
    CacheStatsResponse,
    CacheTierStats,
    ConfigResponse,
    CostBreakdownItem,
    MetricsResponse,
    RequestDetailResponse,
    RequestLogItem,
    RequestLogResponse,
    SavingsResponse,
    TimelinePoint,
)
from llmhosts.proxy.transparency import TransparencyEngine, TransparentDecision

if TYPE_CHECKING:
    from llmhosts.cache.manager import CacheManager
    from llmhosts.config import LLMHostsConfig
    from llmhosts.metrics.collector import MetricsCollector
    from llmhosts.onboarding.manager import SpendingBaseline
    from llmhosts.proxy.dispatcher import BackendDispatcher
    from llmhosts.router.cost import CostTracker
    from llmhosts.savings.calculator import HonestSavings

logger = logging.getLogger(__name__)

# Shared transparency engine instance (stateless, safe to reuse)
_transparency_engine = TransparencyEngine()

router = APIRouter(prefix="/api", tags=["dashboard"])

# ---------------------------------------------------------------------------
# Helpers to fetch shared state from app
# ---------------------------------------------------------------------------


def _get_cost_tracker(request: Request) -> CostTracker | None:
    """Retrieve the CostTracker from ``app.state``, or None."""
    return getattr(request.app.state, "cost_tracker", None)


def _get_metrics(request: Request) -> MetricsCollector | None:
    """Retrieve the MetricsCollector from ``app.state``, or None."""
    return getattr(request.app.state, "metrics_collector", None)


def _get_honest_savings(request: Request) -> HonestSavings | None:
    """Retrieve the HonestSavings calculator from ``app.state``, or build one on the fly."""
    existing: HonestSavings | None = getattr(request.app.state, "honest_savings", None)
    if existing is not None:
        return existing

    # Fallback: construct one from available state
    tracker = _get_cost_tracker(request)
    if tracker is None:
        return None

    from llmhosts.savings.calculator import HonestSavings as _HonestSavings

    baseline: SpendingBaseline | None = getattr(request.app.state, "spending_baseline", None)
    return _HonestSavings(cost_tracker=tracker, baseline=baseline)


# ---------------------------------------------------------------------------
# /api/savings
# ---------------------------------------------------------------------------


@router.get("/savings")
async def get_savings(
    request: Request,
    period: str = Query("month", pattern="^(day|week|month|all)$"),
) -> SavingsResponse:
    """Get honest savings report for period (day/week/month/all).

    Uses :class:`~llmhost.savings.calculator.HonestSavings` for conservative,
    validated savings numbers with an honesty score and methodology string.
    """
    honest = _get_honest_savings(request)
    if honest is None:
        return SavingsResponse(period=period)

    report = await honest.calculate(period)
    return SavingsResponse(
        total_requests=report.total_requests,
        local_requests=report.local_requests,
        cloud_requests=report.cloud_requests,
        cache_hits=report.cache_hits,
        actual_cost=report.actual_cost,
        estimated_without_llmhosts=report.cloud_equivalent,
        savings_amount=report.savings_amount,
        savings_percent=report.savings_percent,
        period=report.period,
        baseline_monthly=report.baseline_monthly,
        vs_baseline_savings=report.vs_baseline_savings,
        honesty_score=report.honesty_score,
        methodology=report.methodology,
    )


# ---------------------------------------------------------------------------
# /api/savings/timeline
# ---------------------------------------------------------------------------


@router.get("/savings/timeline")
async def get_savings_timeline(
    request: Request,
    period: str = Query("month", pattern="^(day|week|month|all)$"),
    granularity: str = Query("day", pattern="^(minute|hour|day)$"),
) -> list[TimelinePoint]:
    """Get savings over time for charts."""
    metrics = _get_metrics(request)
    if metrics is None:
        return []

    # Fetch cost and savings timeseries in parallel
    cost_task = metrics.get_timeseries("total_cost", period=period, granularity=granularity)
    savings_task = metrics.get_timeseries("total_savings", period=period, granularity=granularity)
    requests_task = metrics.get_timeseries("requests_total", period=period, granularity=granularity)

    cost_points, savings_points, request_points = await asyncio.gather(cost_task, savings_task, requests_task)

    # Merge into TimelinePoint list keyed by bucket timestamp
    buckets: dict[str, TimelinePoint] = {}
    for pt in cost_points:
        key = pt.timestamp.isoformat()
        if key not in buckets:
            buckets[key] = TimelinePoint(date=key)
        buckets[key].cost = round(pt.value, 6)

    for pt in savings_points:
        key = pt.timestamp.isoformat()
        if key not in buckets:
            buckets[key] = TimelinePoint(date=key)
        buckets[key].savings = round(pt.value, 6)

    for pt in request_points:
        key = pt.timestamp.isoformat()
        if key not in buckets:
            buckets[key] = TimelinePoint(date=key)
        buckets[key].requests = int(pt.value)

    # Sort chronologically
    return sorted(buckets.values(), key=lambda p: p.date)


# ---------------------------------------------------------------------------
# /api/metrics
# ---------------------------------------------------------------------------


@router.get("/metrics")
async def get_metrics(request: Request) -> MetricsResponse:
    """Get current real-time metrics snapshot."""
    collector = _get_metrics(request)
    if collector is None:
        return MetricsResponse()

    snap = collector.snapshot()
    return MetricsResponse(
        uptime=snap.uptime_seconds,
        requests_total=snap.total_requests,
        rpm=snap.requests_per_minute,
        cache_hit_rate=snap.cache_hit_rate,
        avg_latency=snap.avg_latency_ms,
        savings_total=snap.total_savings,
        by_backend=snap.requests_by_backend,
        by_model=snap.active_models,
    )


# ---------------------------------------------------------------------------
# /api/requests
# ---------------------------------------------------------------------------


@router.get("/requests")
async def get_request_log(
    request: Request,
    limit: int = Query(50, ge=1, le=500),
    offset: int = Query(0, ge=0),
) -> RequestLogResponse:
    """Get recent requests with routing decisions."""
    tracker = _get_cost_tracker(request)
    if tracker is None:
        return RequestLogResponse()

    logs = await tracker.get_request_log(limit=limit, offset=offset)
    items = [
        RequestLogItem(
            id=log.request_id,
            timestamp=log.timestamp,
            model_requested=log.model_requested,
            model_used=log.model_used,
            backend=log.backend_type,
            tier=log.routing_tier,
            cost=log.actual_cost,
            savings=round(log.cloud_equivalent_cost - log.actual_cost, 6),
            cached=log.cached,
            latency_ms=log.latency_ms,
            status="ok",
        )
        for log in logs
    ]

    # For total count we fetch a separate query (fast on indexed table)
    total = len(items) + offset  # conservative estimate; exact count below
    if tracker._db is not None:
        try:
            async with tracker._db.execute("SELECT COUNT(*) FROM request_log") as cur:
                row = await cur.fetchone()
                if row:
                    total = row[0]
        except Exception:
            pass

    return RequestLogResponse(items=items, total=total, offset=offset, limit=limit)


# ---------------------------------------------------------------------------
# /api/requests/{request_id}
# ---------------------------------------------------------------------------


@router.get("/requests/{request_id}")
async def get_request_detail(request: Request, request_id: str) -> RequestDetailResponse:
    """Get full detail for a specific request."""
    tracker = _get_cost_tracker(request)
    if tracker is None:
        return RequestDetailResponse(
            id=request_id,
            timestamp=datetime.now(tz=timezone.utc),
            model_requested="",
            model_used="",
            backend="",
            tier="",
            status="not_found",
        )

    # Query the single request
    if tracker._db is None:
        return RequestDetailResponse(
            id=request_id,
            timestamp=datetime.now(tz=timezone.utc),
            model_requested="",
            model_used="",
            backend="",
            tier="",
            status="not_found",
        )

    query = """
        SELECT request_id, timestamp, model_requested, model_used, backend_type,
               routing_tier, routing_reasoning, input_tokens, output_tokens,
               actual_cost, cloud_equivalent_cost, latency_ms, cached
        FROM request_log
        WHERE request_id = ?
    """
    async with tracker._db.execute(query, (request_id,)) as cursor:
        row = await cursor.fetchone()

    if row is None:
        return RequestDetailResponse(
            id=request_id,
            timestamp=datetime.now(tz=timezone.utc),
            model_requested="",
            model_used="",
            backend="",
            tier="",
            status="not_found",
        )

    actual_cost = row[9]
    cloud_equiv = row[10]
    savings = round(cloud_equiv - actual_cost, 6)

    return RequestDetailResponse(
        id=row[0],
        timestamp=datetime.fromisoformat(row[1]),
        model_requested=row[2],
        model_used=row[3],
        backend=row[4],
        tier=row[5],
        routing_reasoning=row[6],
        input_tokens=row[7],
        output_tokens=row[8],
        cost=actual_cost,
        savings=savings,
        cached=bool(row[12]),
        latency_ms=row[11],
        status="ok",
        routing_decision={
            "tier": row[5],
            "reasoning": row[6],
            "backend": row[4],
            "model": row[3],
        },
        full_cost_breakdown=[
            CostBreakdownItem(label="Actual cost", amount=actual_cost),
            CostBreakdownItem(label="Cloud equivalent", amount=cloud_equiv),
            CostBreakdownItem(label="Savings", amount=savings),
        ],
    )


# ---------------------------------------------------------------------------
# /api/requests/{request_id}/decision
# ---------------------------------------------------------------------------


@router.get("/requests/{request_id}/decision")
async def get_routing_decision(request: Request, request_id: str) -> TransparentDecision:
    """Get the full transparent routing decision for a request.

    Returns a human-readable breakdown of *why* the router selected a
    particular backend and model, including scored factors, alternatives
    considered, and a step-by-step reasoning chain.
    """
    from llmhosts.router.models import RoutingDecision

    tracker = _get_cost_tracker(request)
    if tracker is None or tracker._db is None:
        # Return a minimal "not found" decision
        return _empty_transparent_decision(request_id)

    # Fetch core request data
    query = """
        SELECT request_id, model_requested, model_used, backend_type,
               routing_tier, routing_reasoning, actual_cost,
               cloud_equivalent_cost, latency_ms, cached
        FROM request_log
        WHERE request_id = ?
    """
    async with tracker._db.execute(query, (request_id,)) as cursor:
        row = await cursor.fetchone()

    if row is None:
        return _empty_transparent_decision(request_id)

    model_requested: str = row[1]
    model_used: str = row[2]
    backend_type: str = row[3]
    tier: str = row[4] or "rule"
    reasoning: str = row[5] or ""
    actual_cost: float = row[6] or 0.0
    cloud_equiv: float = row[7] or 0.0
    latency_ms: float = row[8] or 0.0

    # Reconstruct a RoutingDecision from stored data
    decision = RoutingDecision(
        backend_type=backend_type,
        backend_url="",
        model=model_used,
        tier=tier,
        confidence=0.95 if tier == "rule" else 0.85,
        reasoning=reasoning,
        factors={
            "cost": {
                "score": 1.0 if backend_type.lower() in {"ollama", "vllm", "exo", "local"} else 0.5,
                "detail": "Free (local)"
                if backend_type.lower() in {"ollama", "vllm", "exo", "local"}
                else f"${actual_cost:.4f}",
                "actual": actual_cost,
            },
        },
        alternatives=await _load_alternatives(tracker, request_id),
        latency_ms=latency_ms,
    )

    return _transparency_engine.format_decision(
        decision,
        model_requested=model_requested,
        cloud_equivalent_cost=cloud_equiv,
    )


async def _load_alternatives(tracker: CostTracker, request_id: str) -> list:  # list[AlternativeRoute]
    """Load alternative routes from the DB if stored, otherwise return empty list."""
    from llmhosts.router.models import AlternativeRoute

    if tracker._db is None:
        return []

    try:
        query = """
            SELECT model, backend_type, score, reason_not_chosen
            FROM routing_alternatives
            WHERE request_id = ?
        """
        async with tracker._db.execute(query, (request_id,)) as cursor:
            rows = await cursor.fetchall()

        return [
            AlternativeRoute(
                backend_type=r[1],
                model=r[0],
                score=r[2],
                reason_not_chosen=r[3],
            )
            for r in rows
        ]
    except Exception:
        # Table may not exist yet -- gracefully return empty
        logger.debug("routing_alternatives table not available for request %s", request_id)
        return []


def _empty_transparent_decision(request_id: str) -> TransparentDecision:
    """Return a minimal transparent decision when no data is available."""
    from llmhosts.proxy.transparency import CostBreakdown, TierInfo

    return TransparentDecision(
        summary=f"No routing data available for request {request_id}",
        tier=TierInfo(name="Unknown", tier_number=-1),
        confidence=0.0,
        reasoning_steps=["1. Request not found or no routing data recorded"],
        factors={},
        alternatives=[],
        cost_breakdown=CostBreakdown(actual=0.0, cloud_equivalent=0.0, saved=0.0),
    )


# ---------------------------------------------------------------------------
# Cache helpers
# ---------------------------------------------------------------------------


def _get_cache_manager(request: Request) -> CacheManager | None:
    """Retrieve the CacheManager from ``app.state``, or None."""
    return getattr(request.app.state, "cache_manager", None)


# ---------------------------------------------------------------------------
# /api/cache/stats
# ---------------------------------------------------------------------------


@router.get("/cache/stats")
async def get_cache_stats(request: Request) -> CacheStatsResponse:
    """Get per-tier cache statistics."""
    cm = _get_cache_manager(request)
    if cm is None:
        return CacheStatsResponse()

    manager_stats = await cm.stats()
    tiers: list[CacheTierStats] = []
    total_entries = 0
    total_size = 0

    # Tier 0: Exact hash
    if manager_stats.exact_stats:
        es = manager_stats.exact_stats
        total_entries += es.get("total_entries", 0)
        total_size += es.get("total_size_bytes", 0)
        tiers.append(
            CacheTierStats(
                tier="exact",
                name="Tier 0: Exact Hash",
                total_entries=es.get("total_entries", 0),
                total_size_bytes=es.get("total_size_bytes", 0),
                hit_count=es.get("hit_count", 0),
                miss_count=es.get("miss_count", 0),
                hit_rate=es.get("hit_rate", 0.0),
                entries_by_model=es.get("entries_by_model", {}),
            )
        )

    # Tier 1: Namespace
    if manager_stats.namespace_stats:
        ns = manager_stats.namespace_stats
        total_entries += ns.get("total_entries", 0)
        tiers.append(
            CacheTierStats(
                tier="namespace",
                name="Tier 1: Namespace",
                total_entries=ns.get("total_entries", 0),
                hit_rate=sum(ns.get("hit_rate_by_namespace", {}).values())
                / max(len(ns.get("hit_rate_by_namespace", {})), 1),
                total_namespaces=ns.get("total_namespaces", 0),
                entries_by_namespace=ns.get("entries_by_namespace", {}),
                hit_rate_by_namespace=ns.get("hit_rate_by_namespace", {}),
            )
        )

    # Tier 2: vCache
    if manager_stats.vcache_stats:
        vc = manager_stats.vcache_stats
        total_entries += vc.get("total_entries", 0)
        tiers.append(
            CacheTierStats(
                tier="vcache",
                name="Tier 2: vCache Semantic",
                total_entries=vc.get("total_entries", 0),
                hit_count=vc.get("total_hits", 0),
                miss_count=vc.get("total_misses", 0),
                hit_rate=vc.get("hit_rate", 0.0),
                false_positive_rate=vc.get("empirical_fp_rate", 0.0),
                delta_target=vc.get("delta_target", 0.01),
                avg_threshold=vc.get("avg_threshold"),
                min_threshold=vc.get("min_threshold"),
                max_threshold=vc.get("max_threshold"),
                threshold_distribution=vc.get("threshold_distribution"),
            )
        )

    return CacheStatsResponse(
        tiers_active=manager_stats.tiers_active,
        combined_hit_rate=manager_stats.combined_hit_rate,
        total_entries=total_entries,
        total_size_bytes=total_size,
        tiers=tiers,
    )


# ---------------------------------------------------------------------------
# /api/cache/entries
# ---------------------------------------------------------------------------


@router.get("/cache/entries")
async def get_cache_entries(
    request: Request,
    tier: str = Query("exact", pattern="^(exact|namespace|vcache)$"),
    limit: int = Query(50, ge=1, le=200),
    offset: int = Query(0, ge=0),
    model: str | None = Query(None),
) -> CacheEntriesResponse:
    """List cached entries for a specific tier (paginated)."""
    cm = _get_cache_manager(request)
    if cm is None:
        return CacheEntriesResponse()

    entries: list[CacheEntryItem] = []
    total = 0

    if tier == "exact" and cm.exact._db is not None:
        db = cm.exact._db
        where = "WHERE 1=1"
        params: list[str | int] = []
        if model:
            where += " AND model = ?"
            params.append(model)

        async with db.execute(f"SELECT COUNT(*) FROM cache_entries {where}", params) as cur:  # nosec B608
            row = await cur.fetchone()
            total = row[0] if row else 0

        async with db.execute(
            f"SELECT cache_key, model, hit_count, size_bytes, created_at, expires_at "  # nosec B608
            f"FROM cache_entries {where} ORDER BY created_at DESC LIMIT ? OFFSET ?",
            [*params, limit, offset],
        ) as cur:
            async for row in cur:
                entries.append(
                    CacheEntryItem(
                        cache_key=row[0],
                        tier="exact",
                        model=row[1],
                        hit_count=row[2],
                        size_bytes=row[3],
                        created_at=row[4] or "",
                        expires_at=row[5] or "",
                    )
                )

    elif tier == "namespace" and cm.namespace is not None and cm.namespace._db is not None:
        db = cm.namespace._db
        where = "WHERE 1=1"
        params = []
        if model:
            where += " AND model = ?"
            params.append(model)

        async with db.execute(f"SELECT COUNT(*) FROM namespace_cache {where}", params) as cur:  # nosec B608
            row = await cur.fetchone()
            total = row[0] if row else 0

        async with db.execute(
            f"SELECT cache_key, model, hit_count, size_bytes, created_at, expires_at, namespace_key "  # nosec B608
            f"FROM namespace_cache {where} ORDER BY created_at DESC LIMIT ? OFFSET ?",
            [*params, limit, offset],
        ) as cur:
            async for row in cur:
                entries.append(
                    CacheEntryItem(
                        cache_key=row[0],
                        tier="namespace",
                        model=row[1],
                        hit_count=row[2],
                        size_bytes=row[3],
                        created_at=row[4] or "",
                        expires_at=row[5] or "",
                        namespace=row[6],
                    )
                )

    elif tier == "vcache" and cm.vcache is not None and cm.vcache._db is not None:
        db = cm.vcache._db
        where = "WHERE 1=1"
        params = []
        if model:
            where += " AND model = ?"
            params.append(model)

        async with db.execute(f"SELECT COUNT(*) FROM vcache_entries {where}", params) as cur:  # nosec B608
            row = await cur.fetchone()
            total = row[0] if row else 0

        async with db.execute(
            f"SELECT cache_key, model, hit_count, size_bytes, created_at, expires_at, namespace "  # nosec B608
            f"FROM vcache_entries {where} ORDER BY created_at DESC LIMIT ? OFFSET ?",
            [*params, limit, offset],
        ) as cur:
            async for row in cur:
                entries.append(
                    CacheEntryItem(
                        cache_key=row[0],
                        tier="vcache",
                        model=row[1],
                        hit_count=row[2],
                        size_bytes=row[3],
                        created_at=row[4] or "",
                        expires_at=row[5] or "",
                        namespace=row[6],
                    )
                )

    return CacheEntriesResponse(entries=entries, total=total, offset=offset, limit=limit)


# ---------------------------------------------------------------------------
# /api/cache/namespaces
# ---------------------------------------------------------------------------


@router.get("/cache/namespaces")
async def get_cache_namespaces(request: Request) -> CacheNamespacesResponse:
    """List all namespace buckets with entry counts and hit rates."""
    cm = _get_cache_manager(request)
    if cm is None or cm.namespace is None:
        return CacheNamespacesResponse()

    ns_stats = await cm.namespace.stats()
    items: list[CacheNamespaceItem] = []
    for ns_key, count in ns_stats.entries_by_namespace.items():
        items.append(
            CacheNamespaceItem(
                namespace_key=ns_key,
                entry_count=count,
                hit_rate=ns_stats.hit_rate_by_namespace.get(ns_key, 0.0),
            )
        )

    # Sort by entry count descending
    items.sort(key=lambda x: x.entry_count, reverse=True)

    return CacheNamespacesResponse(namespaces=items, total=len(items))


# ---------------------------------------------------------------------------
# /api/cache/invalidate
# ---------------------------------------------------------------------------


@router.post("/cache/invalidate")
async def invalidate_cache(
    request: Request,
    body: CacheInvalidateRequest,
) -> CacheInvalidateResponse:
    """Invalidate cache entries by key, namespace, model, or clear all."""
    cm = _get_cache_manager(request)
    if cm is None:
        return CacheInvalidateResponse()

    removed = 0

    if body.clear_all:
        # Clear all tiers
        removed += await cm.exact.clear(model=body.model)
        # Namespace and vCache don't have a clear() method, so we use direct SQL
        if cm.namespace is not None and cm.namespace._db is not None:
            if body.model:
                cursor = await cm.namespace._db.execute("DELETE FROM namespace_cache WHERE model = ?", (body.model,))
            else:
                cursor = await cm.namespace._db.execute("DELETE FROM namespace_cache")
            await cm.namespace._db.commit()
            removed += cursor.rowcount
        if cm.vcache is not None and cm.vcache._db is not None:
            if body.model:
                cursor = await cm.vcache._db.execute("DELETE FROM vcache_entries WHERE model = ?", (body.model,))
            else:
                cursor = await cm.vcache._db.execute("DELETE FROM vcache_entries")
            await cm.vcache._db.commit()
            removed += cursor.rowcount
        return CacheInvalidateResponse(removed=removed)

    # Invalidate specific keys
    if body.cache_keys:
        for key in body.cache_keys:
            if await cm.exact.invalidate(key):
                removed += 1
            # Also try namespace and vcache tiers
            if cm.namespace is not None and cm.namespace._db is not None:
                cursor = await cm.namespace._db.execute("DELETE FROM namespace_cache WHERE cache_key = ?", (key,))
                await cm.namespace._db.commit()
                removed += cursor.rowcount
            if cm.vcache is not None and cm.vcache._db is not None:
                cursor = await cm.vcache._db.execute("DELETE FROM vcache_entries WHERE cache_key = ?", (key,))
                await cm.vcache._db.commit()
                removed += cursor.rowcount

    # Invalidate by namespace
    if body.namespace and cm.namespace is not None and cm.namespace._db is not None:
        cursor = await cm.namespace._db.execute(
            "DELETE FROM namespace_cache WHERE namespace_key = ?", (body.namespace,)
        )
        await cm.namespace._db.commit()
        removed += cursor.rowcount

    return CacheInvalidateResponse(removed=removed, tier=body.tier)


# ---------------------------------------------------------------------------
# /api/backends
# ---------------------------------------------------------------------------


@router.get("/backends")
async def get_backends(request: Request) -> BackendsResponse:
    """Get status of all backends (Ollama, cloud APIs)."""

    dispatcher: BackendDispatcher | None = getattr(request.app.state, "dispatcher", None)
    if dispatcher is None:
        return BackendsResponse()

    statuses: list[BackendStatus] = []
    for entry in dispatcher._backends:
        statuses.append(
            BackendStatus(
                type=entry.backend_type,
                url=entry.base_url,
                healthy=entry.healthy,
                latency_ms=0.0,
                models=entry.models,
                model_count=len(entry.models),
                uptime_pct=100.0 if entry.healthy else 0.0,
            )
        )

    return BackendsResponse(backends=statuses)


# ---------------------------------------------------------------------------
# /api/config
# ---------------------------------------------------------------------------


@router.get("/config")
async def get_config(request: Request) -> ConfigResponse:
    """Get current server configuration (sanitised, no secrets)."""

    config: LLMHostsConfig | None = getattr(request.app.state, "llmhosts_config", None)
    if config is None:
        return ConfigResponse()

    return ConfigResponse(
        server={
            "host": config.server.host,
            "port": config.server.port,
            "workers": config.server.workers,
            "log_level": config.server.log_level,
        },
        router={
            "prefer_local": config.router.prefer_local,
            "cost_optimize": config.router.cost_optimize,
            "fallback_to_cloud": config.router.fallback_to_cloud,
            "enable_knn": config.router.enable_knn,
            "enable_modernbert": config.router.enable_modernbert,
            "enable_qwen": config.router.enable_qwen,
        },
        cache={
            "enabled": config.cache.enabled,
            "max_size_mb": config.cache.max_size_mb,
            "ttl_seconds": config.cache.ttl_seconds,
            "enable_namespace": config.cache.enable_namespace,
            "enable_vcache": config.cache.enable_vcache,
        },
    )


# ---------------------------------------------------------------------------
# /api/ws/live -- WebSocket for real-time updates
# ---------------------------------------------------------------------------

# Connected clients (simple broadcast hub)
_ws_clients: set[WebSocket] = set()


@router.websocket("/ws/live")
async def websocket_live(websocket: WebSocket) -> None:
    """WebSocket for real-time dashboard updates.

    Pushes: new requests, metrics updates, backend status changes.
    Clients receive JSON messages with a ``type`` field.
    """
    await websocket.accept()
    _ws_clients.add(websocket)
    logger.debug("WebSocket client connected (%d total)", len(_ws_clients))

    try:
        # Send an initial metrics snapshot
        collector: MetricsCollector | None = getattr(websocket.app.state, "metrics_collector", None)
        if collector is not None:
            snap = collector.snapshot()
            await websocket.send_json(
                {
                    "type": "metrics",
                    "data": MetricsResponse(
                        uptime=snap.uptime_seconds,
                        requests_total=snap.total_requests,
                        rpm=snap.requests_per_minute,
                        cache_hit_rate=snap.cache_hit_rate,
                        avg_latency=snap.avg_latency_ms,
                        savings_total=snap.total_savings,
                        by_backend=snap.requests_by_backend,
                        by_model=snap.active_models,
                    ).model_dump(),
                }
            )

        # Keep connection alive, send periodic metrics updates
        while True:
            try:
                # Wait for a client message (ping/pong) or timeout for periodic push
                data = await asyncio.wait_for(websocket.receive_text(), timeout=5.0)
                # Client can send "ping" to keep alive
                if data == "ping":
                    await websocket.send_text("pong")
            except asyncio.TimeoutError:
                # Push periodic metrics update
                if collector is not None:
                    snap = collector.snapshot()
                    await websocket.send_json(
                        {
                            "type": "metrics",
                            "data": MetricsResponse(
                                uptime=snap.uptime_seconds,
                                requests_total=snap.total_requests,
                                rpm=snap.requests_per_minute,
                                cache_hit_rate=snap.cache_hit_rate,
                                avg_latency=snap.avg_latency_ms,
                                savings_total=snap.total_savings,
                                by_backend=snap.requests_by_backend,
                                by_model=snap.active_models,
                            ).model_dump(),
                        }
                    )
    except WebSocketDisconnect:
        pass
    except Exception as exc:
        logger.debug("WebSocket error: %s", exc)
    finally:
        _ws_clients.discard(websocket)
        logger.debug("WebSocket client disconnected (%d remaining)", len(_ws_clients))


async def broadcast_event(event_type: str, data: dict) -> None:
    """Broadcast an event to all connected WebSocket clients.

    Called by other parts of the system (e.g. after a request completes)
    to push real-time updates to connected dashboards.

    Parameters
    ----------
    event_type:
        Event type string (e.g. ``"request"``, ``"metrics"``, ``"backend_status"``).
    data:
        Serialisable dict payload.
    """
    if not _ws_clients:
        return

    message = json.dumps({"type": event_type, "data": data})
    stale: list[WebSocket] = []

    for ws in _ws_clients:
        try:
            await ws.send_text(message)
        except Exception:
            stale.append(ws)

    for ws in stale:
        _ws_clients.discard(ws)
