"""Argus — Diagnostics dashboard + search API for the Teleo pipeline.

Separate aiohttp service (port 8081) that reads pipeline.db read-only.
Provides Chart.js operational dashboard, quality vital signs, contributor analytics,
semantic search via Qdrant, and claim usage logging.

Owner: Argus <69AF7290-758F-464B-B472-04AFCA4AB340>
Data source: Epimetheus's pipeline.db (read-only SQLite), Qdrant vector DB
"""

import json
import logging
import os
import sqlite3
import statistics
import sys
import urllib.request
from datetime import datetime, timezone
from pathlib import Path

# Add pipeline lib to path so we can import shared modules
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "pipeline"))

from aiohttp import web
from daily_digest_routes import register_daily_digest_routes
from kb_claim_routes import (
    KB_CLAIM_GLOBAL_AUTH_PREFIXES,
    KB_CLAIM_LIST_API_KEY,
    KB_CLAIM_SELF_AUTH_PATHS,
    load_claim_list_api_key,
    register_kb_claim_routes,
)
from kb_proposal_routes import KB_PROPOSAL_PUBLIC_PATHS, register_kb_proposal_routes
from leaderboard_routes import LEADERBOARD_PUBLIC_PATHS, register_leaderboard_routes
from lib.search import embed_query, search as kb_search, search_qdrant
from response_audit_routes import RESPONSE_AUDIT_PUBLIC_PATHS, register_response_audit_routes
from review_queue_routes import register_review_queue_routes

logger = logging.getLogger("argus")

# --- Config ---
DB_PATH = Path(os.environ.get("PIPELINE_DB", "/opt/teleo-eval/pipeline/pipeline.db"))
PORT = int(os.environ.get("ARGUS_PORT", "8081"))
REPO_DIR = Path(os.environ.get("REPO_DIR", "/opt/teleo-eval/workspaces/main"))
CLAIM_INDEX_URL = os.environ.get("CLAIM_INDEX_URL", "http://localhost:8080/claim-index")

# Search config — moved to lib/search.py (shared with Telegram bot + agents)

# Auth config
API_KEY_FILE = Path(os.environ.get("ARGUS_API_KEY_FILE", "/opt/teleo-eval/secrets/argus-api-key"))

# Endpoints that skip auth (dashboard is public for now, can lock later)
_PUBLIC_PATHS = frozenset({"/", "/prs", "/ops", "/health", "/agents", "/epistemic", "/legacy", "/audit", "/api/metrics", "/api/snapshots", "/api/vital-signs",
                           "/api/contributors", "/api/domains", "/api/audit", "/api/yield", "/api/cost-per-claim", "/api/fix-rates", "/api/compute-profile", "/api/review-queue", "/api/daily-digest", "/api/search"})


def _get_db() -> sqlite3.Connection:
    """Open read-only connection to pipeline.db."""
    # URI mode for true OS-level read-only (Rhea: belt and suspenders)
    conn = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True, timeout=30)
    conn.row_factory = sqlite3.Row
    conn.execute("PRAGMA journal_mode=WAL")
    conn.execute("PRAGMA busy_timeout=10000")
    return conn


def _conn(request) -> sqlite3.Connection:
    """Get DB connection with health check. Reopens if stale."""
    conn = request.app["db"]
    try:
        conn.execute("SELECT 1")
    except sqlite3.Error:
        conn = _get_db()
        request.app["db"] = conn
    return conn


# ─── Data queries ────────────────────────────────────────────────────────────


def _current_metrics(conn) -> dict:
    """Compute current operational metrics from live DB state."""
    # Throughput (merged in last hour)
    merged_1h = conn.execute(
        "SELECT COUNT(*) as n FROM prs WHERE merged_at > datetime('now', '-1 hour')"
    ).fetchone()["n"]

    # PR status counts
    statuses = conn.execute("SELECT status, COUNT(*) as n FROM prs GROUP BY status").fetchall()
    status_map = {r["status"]: r["n"] for r in statuses}

    # Approval rate (24h) from audit_log
    evaluated = conn.execute(
        "SELECT COUNT(*) as n FROM audit_log WHERE stage='evaluate' "
        "AND event IN ('approved','changes_requested','domain_rejected','tier05_rejected') "
        "AND timestamp > datetime('now','-24 hours')"
    ).fetchone()["n"]
    approved = conn.execute(
        "SELECT COUNT(*) as n FROM audit_log WHERE stage='evaluate' "
        "AND event='approved' AND timestamp > datetime('now','-24 hours')"
    ).fetchone()["n"]
    approval_rate = round(approved / evaluated, 3) if evaluated else 0

    # Rejection reasons (24h) — count events AND unique PRs
    reasons = conn.execute(
        """SELECT value as tag, COUNT(*) as cnt,
           COUNT(DISTINCT json_extract(detail, '$.pr')) as unique_prs
           FROM audit_log, json_each(json_extract(detail, '$.issues'))
           WHERE stage='evaluate'
           AND event IN ('changes_requested','domain_rejected','tier05_rejected')
           AND timestamp > datetime('now','-24 hours')
           GROUP BY tag ORDER BY cnt DESC LIMIT 10"""
    ).fetchall()

    # Fix cycle
    fix_stats = conn.execute(
        "SELECT COUNT(*) as attempted, "
        "SUM(CASE WHEN status='merged' THEN 1 ELSE 0 END) as succeeded "
        "FROM prs WHERE fix_attempts > 0"
    ).fetchone()
    fix_attempted = fix_stats["attempted"] or 0
    fix_succeeded = fix_stats["succeeded"] or 0
    fix_rate = round(fix_succeeded / fix_attempted, 3) if fix_attempted else 0

    # Median time to merge (24h)
    merge_times = conn.execute(
        "SELECT (julianday(merged_at) - julianday(created_at)) * 24 * 60 as minutes "
        "FROM prs WHERE merged_at IS NOT NULL AND merged_at > datetime('now', '-24 hours')"
    ).fetchall()
    durations = [r["minutes"] for r in merge_times if r["minutes"] and r["minutes"] > 0]
    median_ttm = round(statistics.median(durations), 1) if durations else None

    # Source pipeline
    source_statuses = conn.execute(
        "SELECT status, COUNT(*) as n FROM sources GROUP BY status"
    ).fetchall()
    source_map = {r["status"]: r["n"] for r in source_statuses}

    # Domain breakdown
    domain_counts = conn.execute(
        "SELECT domain, status, COUNT(*) as n FROM prs GROUP BY domain, status"
    ).fetchall()
    domains = {}
    for r in domain_counts:
        d = r["domain"] or "unknown"
        if d not in domains:
            domains[d] = {}
        domains[d][r["status"]] = r["n"]

    # Breakers
    breakers = conn.execute(
        "SELECT name, state, failures, last_success_at FROM circuit_breakers"
    ).fetchall()
    breaker_map = {}
    for b in breakers:
        info = {"state": b["state"], "failures": b["failures"]}
        if b["last_success_at"]:
            last = datetime.fromisoformat(b["last_success_at"])
            if last.tzinfo is None:
                last = last.replace(tzinfo=timezone.utc)
            age_s = (datetime.now(timezone.utc) - last).total_seconds()
            info["age_s"] = round(age_s)
        breaker_map[b["name"]] = info

    return {
        "throughput_1h": merged_1h,
        "approval_rate": approval_rate,
        "evaluated_24h": evaluated,
        "approved_24h": approved,
        "status_map": status_map,
        "source_map": source_map,
        "rejection_reasons": [{"tag": r["tag"], "count": r["cnt"], "unique_prs": r["unique_prs"]} for r in reasons],
        "fix_rate": fix_rate,
        "fix_attempted": fix_attempted,
        "fix_succeeded": fix_succeeded,
        "median_ttm_minutes": median_ttm,
        "domains": domains,
        "breakers": breaker_map,
    }


def _snapshot_history(conn, days: int = 7) -> list[dict]:
    """Get metrics_snapshots time series."""
    rows = conn.execute(
        "SELECT * FROM metrics_snapshots WHERE ts > datetime('now', ? || ' days') ORDER BY ts ASC",
        (f"-{days}",),
    ).fetchall()
    return [dict(r) for r in rows]


def _version_changes(conn, days: int = 30) -> list[dict]:
    """Get prompt/pipeline version change events for chart annotations."""
    rows = conn.execute(
        "SELECT ts, prompt_version, pipeline_version FROM metrics_snapshots "
        "WHERE ts > datetime('now', ? || ' days') ORDER BY ts ASC",
        (f"-{days}",),
    ).fetchall()
    changes = []
    prev_prompt = prev_pipeline = None
    for row in rows:
        if row["prompt_version"] != prev_prompt and prev_prompt is not None:
            changes.append({"ts": row["ts"], "type": "prompt", "from": prev_prompt, "to": row["prompt_version"]})
        if row["pipeline_version"] != prev_pipeline and prev_pipeline is not None:
            changes.append({"ts": row["ts"], "type": "pipeline", "from": prev_pipeline, "to": row["pipeline_version"]})
        prev_prompt = row["prompt_version"]
        prev_pipeline = row["pipeline_version"]
    return changes


def _has_column(conn, table: str, column: str) -> bool:
    """Check if a column exists in a table (graceful schema migration support)."""
    cols = conn.execute(f"PRAGMA table_info({table})").fetchall()
    return any(c["name"] == column for c in cols)


def _contributor_leaderboard(conn, limit: int = 20, view: str = "principal") -> list[dict]:
    """Top contributors by CI score.

    view="agent"     — one row per contributor handle (original behavior)
    view="principal"  — rolls up agent contributions to their principal (human)
    """
    has_principal = _has_column(conn, "contributors", "principal")

    rows = conn.execute(
        "SELECT handle, tier, claims_merged, sourcer_count, extractor_count, "
        "challenger_count, synthesizer_count, reviewer_count, domains, last_contribution"
        + (", principal" if has_principal else "") +
        " FROM contributors ORDER BY claims_merged DESC",
    ).fetchall()

    # Weights reward quality over volume (Cory-approved)
    weights = {"sourcer": 0.15, "extractor": 0.05, "challenger": 0.35, "synthesizer": 0.25, "reviewer": 0.20}
    role_keys = list(weights.keys())

    if view == "principal" and has_principal:
        # Aggregate by principal — agents with a principal roll up to the human
        buckets: dict[str, dict] = {}
        for r in rows:
            principal = r["principal"]
            key = principal if principal else r["handle"]
            if key not in buckets:
                buckets[key] = {
                    "handle": key,
                    "tier": r["tier"],
                    "claims_merged": 0,
                    "domains": set(),
                    "last_contribution": None,
                    "agents": [],
                    **{f"{role}_count": 0 for role in role_keys},
                }
            b = buckets[key]
            b["claims_merged"] += r["claims_merged"] or 0
            for role in role_keys:
                b[f"{role}_count"] += r[f"{role}_count"] or 0
            if r["domains"]:
                b["domains"].update(json.loads(r["domains"]))
            if r["last_contribution"]:
                if not b["last_contribution"] or r["last_contribution"] > b["last_contribution"]:
                    b["last_contribution"] = r["last_contribution"]
            # Upgrade tier (veteran > contributor > new)
            tier_rank = {"veteran": 2, "contributor": 1, "new": 0}
            if tier_rank.get(r["tier"], 0) > tier_rank.get(b["tier"], 0):
                b["tier"] = r["tier"]
            if principal:
                b["agents"].append(r["handle"])

        result = []
        for b in buckets.values():
            ci = sum(b[f"{role}_count"] * w for role, w in weights.items())
            result.append({
                "handle": b["handle"],
                "tier": b["tier"],
                "claims_merged": b["claims_merged"],
                "ci": round(ci, 2),
                "domains": sorted(b["domains"])[:5],
                "last_contribution": b["last_contribution"],
                "agents": b["agents"],
            })
    else:
        # By-agent view (original behavior)
        result = []
        for r in rows:
            ci = sum((r[f"{role}_count"] or 0) * w for role, w in weights.items())
            entry = {
                "handle": r["handle"],
                "tier": r["tier"],
                "claims_merged": r["claims_merged"] or 0,
                "ci": round(ci, 2),
                "domains": json.loads(r["domains"]) if r["domains"] else [],
                "last_contribution": r["last_contribution"],
            }
            if has_principal:
                entry["principal"] = r["principal"]
            result.append(entry)

    result = sorted(result, key=lambda x: x["ci"], reverse=True)
    return result[:limit]


# ─── Vital signs (Vida's five) ───────────────────────────────────────────────


def _fetch_claim_index() -> dict | None:
    """Fetch claim-index from Epimetheus. Returns parsed JSON or None on failure."""
    try:
        with urllib.request.urlopen(CLAIM_INDEX_URL, timeout=5) as resp:
            return json.loads(resp.read())
    except Exception as e:
        logger.warning("Failed to fetch claim-index from %s: %s", CLAIM_INDEX_URL, e)
        return None


def _compute_vital_signs(conn) -> dict:
    """Compute Vida's five vital signs from DB state + claim-index."""

    # 1. Review throughput — backlog and latency
    # Query Forgejo directly for authoritative PR counts (DB misses agent-created PRs)
    forgejo_open = 0
    forgejo_unmergeable = 0
    try:
        import requests as _req
        _token = Path("/opt/teleo-eval/secrets/forgejo-token").read_text().strip() if Path("/opt/teleo-eval/secrets/forgejo-token").exists() else ""
        _resp = _req.get(
            "http://localhost:3000/api/v1/repos/teleo/teleo-codex/pulls?state=open&limit=50",
            headers={"Authorization": f"token {_token}"} if _token else {},
            timeout=10,
        )
        if _resp.status_code == 200:
            _prs = _resp.json()
            forgejo_open = len(_prs)
            forgejo_unmergeable = sum(1 for p in _prs if not p.get("mergeable", True))
    except Exception:
        # Fallback to DB counts if Forgejo unreachable
        forgejo_open = conn.execute("SELECT COUNT(*) as n FROM prs WHERE status='open'").fetchone()["n"]

    open_prs = forgejo_open
    conflict_prs = forgejo_unmergeable
    conflict_permanent_prs = conn.execute("SELECT COUNT(*) as n FROM prs WHERE status='conflict_permanent'").fetchone()["n"]
    approved_prs = conn.execute("SELECT COUNT(*) as n FROM prs WHERE status='approved'").fetchone()["n"]
    reviewing_prs = conn.execute("SELECT COUNT(*) as n FROM prs WHERE status='reviewing'").fetchone()["n"]
    backlog = open_prs

    oldest_open = conn.execute(
        "SELECT MIN(created_at) as oldest FROM prs WHERE status='open'"
    ).fetchone()
    review_latency_h = None
    if oldest_open and oldest_open["oldest"]:
        oldest = datetime.fromisoformat(oldest_open["oldest"])
        if oldest.tzinfo is None:
            oldest = oldest.replace(tzinfo=timezone.utc)
        review_latency_h = round((datetime.now(timezone.utc) - oldest).total_seconds() / 3600, 1)

    # 2-5. Claim-index vital signs
    ci = _fetch_claim_index()
    orphan_ratio = None
    linkage_density = None
    confidence_dist = {}
    evidence_freshness = None
    claim_index_status = "unavailable"

    if ci and ci.get("claims"):
        claims = ci["claims"]
        total = len(claims)
        claim_index_status = "live"

        # 2. Orphan ratio (Vida: <15% healthy)
        orphan_count = ci.get("orphan_count", sum(1 for c in claims if c.get("incoming_count", 0) == 0))
        orphan_ratio = round(orphan_count / total, 3) if total else 0

        # 3. Linkage density — avg outgoing links per claim + cross-domain ratio
        total_outgoing = sum(c.get("outgoing_count", 0) for c in claims)
        avg_links = round(total_outgoing / total, 2) if total else 0
        cross_domain = ci.get("cross_domain_links", 0)
        linkage_density = {
            "avg_outgoing_links": avg_links,
            "cross_domain_links": cross_domain,
            "cross_domain_ratio": round(cross_domain / total_outgoing, 3) if total_outgoing else 0,
        }

        # 4. Confidence distribution + calibration
        for c in claims:
            conf = c.get("confidence", "unknown")
            confidence_dist[conf] = confidence_dist.get(conf, 0) + 1
        # Normalize to percentages
        confidence_pct = {k: round(v / total * 100, 1) for k, v in sorted(confidence_dist.items())}

        # 5. Evidence freshness — avg age of claims in days
        today = datetime.now(timezone.utc).date()
        ages = []
        for c in claims:
            try:
                if c.get("created"):
                    created = datetime.strptime(c["created"], "%Y-%m-%d").date()
                    ages.append((today - created).days)
            except (ValueError, KeyError, TypeError):
                pass
        avg_age_days = round(statistics.mean(ages)) if ages else None
        median_age_days = round(statistics.median(ages)) if ages else None
        fresh_30d = sum(1 for a in ages if a <= 30)
        evidence_freshness = {
            "avg_age_days": avg_age_days,
            "median_age_days": median_age_days,
            "fresh_30d_count": fresh_30d,
            "fresh_30d_pct": round(fresh_30d / total * 100, 1) if total else 0,
        }

    # Domain activity (last 7 days) — stagnation detection
    domain_activity = conn.execute(
        "SELECT domain, COUNT(*) as n, MAX(last_attempt) as latest "
        "FROM prs WHERE last_attempt > datetime('now', '-7 days') GROUP BY domain"
    ).fetchall()
    stagnant_domains = []
    active_domains = []
    for r in domain_activity:
        active_domains.append({"domain": r["domain"], "prs_7d": r["n"], "latest": r["latest"]})
    all_domains = conn.execute("SELECT DISTINCT domain FROM prs WHERE domain IS NOT NULL").fetchall()
    active_names = {r["domain"] for r in domain_activity}
    for r in all_domains:
        if r["domain"] not in active_names:
            stagnant_domains.append(r["domain"])

    # Pipeline funnel
    total_sources = conn.execute("SELECT COUNT(*) as n FROM sources").fetchone()["n"]
    queued_sources = conn.execute(
        "SELECT COUNT(*) as n FROM sources WHERE status='unprocessed'"
    ).fetchone()["n"]
    extracted_sources = conn.execute(
        "SELECT COUNT(*) as n FROM sources WHERE status='extracted'"
    ).fetchone()["n"]
    merged_prs = conn.execute("SELECT COUNT(*) as n FROM prs WHERE status='merged'").fetchone()["n"]
    total_prs = conn.execute("SELECT COUNT(*) as n FROM prs").fetchone()["n"]
    funnel = {
        "sources_total": total_sources,
        "sources_queued": queued_sources,
        "sources_extracted": extracted_sources,
        "prs_total": total_prs,
        "prs_merged": merged_prs,
        "conversion_rate": round(merged_prs / total_prs, 3) if total_prs else 0,
    }

    # Queue staleness — sources unprocessed for >7 days
    stale_buckets = conn.execute("""
        SELECT
            CASE
                WHEN created_at < datetime('now', '-30 days') THEN '30d+'
                WHEN created_at < datetime('now', '-14 days') THEN '14-30d'
                WHEN created_at < datetime('now', '-7 days') THEN '7-14d'
                ELSE 'fresh'
            END as age_bucket,
            COUNT(*) as cnt
        FROM sources
        WHERE status = 'unprocessed'
        GROUP BY age_bucket
    """).fetchall()
    stale_map = {r["age_bucket"]: r["cnt"] for r in stale_buckets}
    stale_total = sum(v for k, v in stale_map.items() if k != "fresh")

    oldest_unprocessed = conn.execute(
        "SELECT MIN(created_at) as oldest FROM sources WHERE status='unprocessed'"
    ).fetchone()
    oldest_age_days = None
    if oldest_unprocessed and oldest_unprocessed["oldest"]:
        oldest_dt = datetime.fromisoformat(oldest_unprocessed["oldest"])
        if oldest_dt.tzinfo is None:
            oldest_dt = oldest_dt.replace(tzinfo=timezone.utc)
        oldest_age_days = round((datetime.now(timezone.utc) - oldest_dt).total_seconds() / 86400, 1)

    queue_staleness = {
        "stale_count": stale_total,
        "buckets": stale_map,
        "oldest_age_days": oldest_age_days,
        "status": "healthy" if stale_total == 0 else ("warning" if stale_total <= 10 else "critical"),
    }

    return {
        "claim_index_status": claim_index_status,
        "review_throughput": {
            "backlog": backlog,
            "open_prs": open_prs,
            "approved_waiting": approved_prs,
            "conflict_prs": conflict_prs,
            "conflict_permanent_prs": conflict_permanent_prs,
            "reviewing_prs": reviewing_prs,
            "oldest_open_hours": review_latency_h,
            "status": "healthy" if backlog <= 3 else ("warning" if backlog <= 10 else "critical"),
        },
        "orphan_ratio": {
            "ratio": orphan_ratio,
            "count": ci.get("orphan_count") if ci else None,
            "total": ci.get("total_claims") if ci else None,
            "status": "healthy" if orphan_ratio and orphan_ratio < 0.15 else ("warning" if orphan_ratio and orphan_ratio < 0.30 else "critical") if orphan_ratio is not None else "unavailable",
        },
        "linkage_density": linkage_density,
        "confidence_distribution": confidence_dist,
        "evidence_freshness": evidence_freshness,
        "domain_activity": {
            "active": active_domains,
            "stagnant": stagnant_domains,
            "status": "healthy" if not stagnant_domains else "warning",
        },
        "funnel": funnel,
        "queue_staleness": queue_staleness,
    }


# ─── Auth ────────────────────────────────────────────────────────────────────


def _load_secret(path: Path) -> str | None:
    """Load a secret from a file. Returns None if missing."""
    try:
        return path.read_text().strip()
    except Exception:
        return None


@web.middleware
async def auth_middleware(request, handler):
    """API key check. Public paths skip auth. Protected paths require X-Api-Key header."""
    if (
        request.path in _PUBLIC_PATHS
        or request.path in RESPONSE_AUDIT_PUBLIC_PATHS
        or request.path in LEADERBOARD_PUBLIC_PATHS
        or request.path in KB_PROPOSAL_PUBLIC_PATHS
        or (request.method == "GET" and request.path in KB_CLAIM_SELF_AUTH_PATHS)
        or request.path.startswith("/api/response-audit/")
    ):
        return await handler(request)
    expected = request.app.get("api_key")
    if not expected:
        if request.path.startswith(KB_CLAIM_GLOBAL_AUTH_PREFIXES):
            return web.json_response({"error": "argus_auth_unconfigured"}, status=503)
        # No key configured — all endpoints open (development mode)
        return await handler(request)
    provided = request.headers.get("X-Api-Key", "")
    if provided != expected:
        return web.json_response({"error": "unauthorized"}, status=401)
    return await handler(request)


# ─── Embedding + Search ──────────────────────────────────────────────────────
# Moved to lib/search.py — imported at top of file as kb_search, embed_query, search_qdrant


# ─── Usage logging ───────────────────────────────────────────────────────────


def _get_write_db() -> sqlite3.Connection | None:
    """Open read-write connection for usage logging only.

    Separate from the main read-only connection. Returns None if DB unavailable.
    """
    try:
        conn = sqlite3.connect(str(DB_PATH), timeout=10)
        conn.execute("PRAGMA journal_mode=WAL")
        conn.execute("PRAGMA busy_timeout=10000")
        # Ensure claim_usage table exists (Epimetheus creates it, but be safe)
        conn.execute("""
            CREATE TABLE IF NOT EXISTS claim_usage (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                claim_path TEXT NOT NULL,
                agent TEXT,
                context TEXT,
                ts TEXT DEFAULT (datetime('now'))
            )
        """)
        conn.commit()
        return conn
    except Exception as e:
        logger.warning("Failed to open write DB for usage logging: %s", e)
        return None


# ─── Route handlers ─────────────────────────────────────────────────────────


async def handle_dashboard(request):
    """GET / — main Chart.js operational dashboard."""
    try:
        conn = _conn(request)
        metrics = _current_metrics(conn)
        snapshots = _snapshot_history(conn, days=7)
        changes = _version_changes(conn, days=30)
        vital_signs = _compute_vital_signs(conn)
        contributors_principal = _contributor_leaderboard(conn, limit=10, view="principal")
        contributors_agent = _contributor_leaderboard(conn, limit=10, view="agent")
        domain_breakdown = _domain_breakdown(conn)
    except sqlite3.Error as e:
        return web.Response(
            text=_render_error(f"Pipeline database unavailable: {e}"),
            content_type="text/html",
            status=503,
        )
    now = datetime.now(timezone.utc)
    html = _render_dashboard(metrics, snapshots, changes, vital_signs, contributors_principal, contributors_agent, domain_breakdown, now)
    return web.Response(text=html, content_type="text/html")


async def handle_api_metrics(request):
    """GET /api/metrics — JSON operational metrics."""
    conn = _conn(request)
    return web.json_response(_current_metrics(conn))


async def handle_api_snapshots(request):
    """GET /api/snapshots?days=7 — time-series data for charts."""
    conn = _conn(request)
    days = int(request.query.get("days", "7"))
    snapshots = _snapshot_history(conn, days)
    changes = _version_changes(conn, days)
    return web.json_response({"snapshots": snapshots, "version_changes": changes, "days": days})


async def handle_api_vital_signs(request):
    """GET /api/vital-signs — Vida's five vital signs."""
    conn = _conn(request)
    return web.json_response(_compute_vital_signs(conn))


async def handle_api_contributors(request):
    """GET /api/contributors — contributor leaderboard.

    Query params:
      limit: max entries (default 50)
      view:  "principal" (default, rolls up agents) or "agent" (one row per handle)
    """
    conn = _conn(request)
    limit = int(request.query.get("limit", "50"))
    view = request.query.get("view", "principal")
    if view not in ("principal", "agent"):
        view = "principal"
    contributors = _contributor_leaderboard(conn, limit, view=view)
    return web.json_response({"contributors": contributors, "view": view})


def _domain_breakdown(conn) -> dict:
    """Per-domain contribution breakdown: claims, contributors, sources, decisions."""
    # Claims per domain from merged knowledge PRs
    domain_stats = {}
    for r in conn.execute("""
        SELECT domain, count(*) as prs,
               SUM(CASE WHEN commit_type='knowledge' THEN 1 ELSE 0 END) as knowledge_prs
        FROM prs WHERE status='merged' AND domain IS NOT NULL
        GROUP BY domain ORDER BY prs DESC
    """).fetchall():
        domain_stats[r["domain"]] = {
            "total_prs": r["prs"],
            "knowledge_prs": r["knowledge_prs"] or 0,
            "contributors": [],
        }

    # Top contributors per domain (from PR agent field + principal roll-up)
    has_principal = _has_column(conn, "contributors", "principal")
    for r in conn.execute("""
        SELECT p.domain,
               COALESCE(c.principal, p.agent, 'unknown') as contributor,
               count(*) as cnt
        FROM prs p
        LEFT JOIN contributors c ON LOWER(p.agent) = c.handle
        WHERE p.status='merged' AND p.commit_type='knowledge' AND p.domain IS NOT NULL
        GROUP BY p.domain, contributor
        ORDER BY p.domain, cnt DESC
    """).fetchall():
        domain = r["domain"]
        if domain in domain_stats:
            domain_stats[domain]["contributors"].append({
                "handle": r["contributor"],
                "claims": r["cnt"],
            })

    return domain_stats


async def handle_api_domains(request):
    """GET /api/domains — per-domain contribution breakdown.

    Returns claims, contributors, and knowledge PR counts per domain.
    """
    conn = _conn(request)
    breakdown = _domain_breakdown(conn)
    return web.json_response({"domains": breakdown})


def _qdrant_hits_to_results(hits, include_expanded=False):
    """Shape raw Qdrant hits into Ship's chat-API contract."""
    results = []
    for h in hits:
        payload = h.get("payload", {}) or {}
        path = payload.get("claim_path", "") or ""
        slug = path.rsplit("/", 1)[-1]
        if slug.endswith(".md"):
            slug = slug[:-3]
        results.append({
            "slug": slug,
            "path": path,
            "title": payload.get("claim_title", ""),
            "domain": payload.get("domain"),
            "confidence": payload.get("confidence"),
            "score": round(float(h.get("score", 0.0) or 0.0), 4),
            "body_excerpt": payload.get("snippet", "") or "",
        })
    return results


async def handle_api_search(request):
    """Semantic search over claims via Qdrant.

    POST contract (Ship's chat API):
      body: {"query": str, "limit": int, "min_score": float?, "domain": str?, "confidence": str?, "exclude": [str]?}
      response: {"query": str, "results": [{"slug","path","title","domain","confidence","score","body_excerpt"}], "total": int}

    GET (legacy + hackathon debug):
      q: search query (required)
      limit, domain, confidence, exclude, expand
      min_score: if set, bypasses two-pass lib threshold (default lib behavior otherwise)
    """
    if request.method == "POST":
        try:
            body = await request.json()
        except Exception:
            return web.json_response({"error": "invalid JSON body"}, status=400)

        query = (body.get("query") or "").strip()
        if not query:
            return web.json_response({"error": "query required"}, status=400)

        try:
            limit = min(int(body.get("limit") or 5), 50)
        except (TypeError, ValueError):
            return web.json_response({"error": "limit must be int"}, status=400)
        try:
            min_score = float(body.get("min_score") if body.get("min_score") is not None else 0.25)
        except (TypeError, ValueError):
            return web.json_response({"error": "min_score must be float"}, status=400)

        domain = body.get("domain")
        confidence = body.get("confidence")
        exclude = body.get("exclude") or None

        vector = embed_query(query)
        if vector is None:
            return web.json_response({"error": "embedding failed"}, status=502)

        hits = search_qdrant(vector, limit=limit, domain=domain,
                             confidence=confidence, exclude=exclude,
                             score_threshold=min_score)
        results = _qdrant_hits_to_results(hits)
        return web.json_response({"query": query, "results": results, "total": len(results)})

    # GET path
    query = request.query.get("q", "").strip()
    if not query:
        return web.json_response({"error": "q parameter required"}, status=400)

    domain = request.query.get("domain")
    confidence = request.query.get("confidence")
    try:
        limit = min(int(request.query.get("limit", "10")), 50)
    except ValueError:
        return web.json_response({"error": "limit must be int"}, status=400)
    exclude_raw = request.query.get("exclude", "")
    exclude = [p.strip() for p in exclude_raw.split(",") if p.strip()] if exclude_raw else None
    expand = request.query.get("expand", "true").lower() != "false"
    min_score_raw = request.query.get("min_score")

    if min_score_raw is not None:
        try:
            min_score = float(min_score_raw)
        except ValueError:
            return web.json_response({"error": "min_score must be float"}, status=400)
        vector = embed_query(query)
        if vector is None:
            return web.json_response({"error": "embedding failed"}, status=502)
        hits = search_qdrant(vector, limit=limit, domain=domain,
                             confidence=confidence, exclude=exclude,
                             score_threshold=min_score)
        direct = _qdrant_hits_to_results(hits)
        return web.json_response({
            "query": query,
            "direct_results": direct,
            "expanded_results": [],
            "total": len(direct),
        })

    # Default GET: Layer 1 + Layer 2 via lib
    result = kb_search(query, expand=expand,
                       domain=domain, confidence=confidence, exclude=exclude)
    if "error" in result:
        error = result["error"]
        if error == "embedding_failed":
            return web.json_response({"error": "embedding failed"}, status=502)
        return web.json_response({"error": error}, status=500)
    return web.json_response(result)


async def handle_api_audit(request):
    """GET /api/audit — query response_audit table for agent response diagnostics.

    Query params:
      agent:    filter by agent name (optional)
      query:    search in query text (optional)
      limit:    max results, default 50, max 200 (optional)
      offset:   pagination offset (optional)
      days:     how many days back, default 7 (optional)
    """
    conn = _conn(request)

    # Check if response_audit table exists
    table_check = conn.execute(
        "SELECT name FROM sqlite_master WHERE type='table' AND name='response_audit'"
    ).fetchone()
    if not table_check:
        return web.json_response({"error": "response_audit table not found"}, status=404)

    agent = request.query.get("agent")
    status_filter = request.query.get("status", "").strip()
    query_filter = request.query.get("query", "").strip()
    limit = min(int(request.query.get("limit", "50")), 200)
    offset = int(request.query.get("offset", "0"))
    days = int(request.query.get("days", "7"))

    where_clauses = ["timestamp > datetime('now', ?||' days')"]
    params: list = [f"-{days}"]

    if agent:
        where_clauses.append("agent = ?")
        params.append(agent)
    if status_filter:
        where_clauses.append("retrieval_status LIKE ?")
        params.append(f"{status_filter}%")
    if query_filter:
        where_clauses.append("query LIKE ?")
        params.append(f"%{query_filter}%")

    where_sql = " AND ".join(where_clauses)

    rows = conn.execute(
        f"""SELECT id, timestamp, agent, chat_id, user, model, query,
               conversation_window, entities_matched, claims_matched,
               retrieval_layers_hit, retrieval_gap, research_context,
               tool_calls, display_response, confidence_score, response_time_ms,
               retrieval_status
           FROM response_audit
           WHERE {where_sql}
           ORDER BY timestamp DESC
           LIMIT ? OFFSET ?""",
        params + [limit, offset],
    ).fetchall()

    total = conn.execute(
        f"SELECT COUNT(*) as n FROM response_audit WHERE {where_sql}",
        params,
    ).fetchone()["n"]

    results = []
    for r in rows:
        row_dict = dict(r)
        # Parse JSON fields for the response
        for json_field in ("claims_matched", "entities_matched", "retrieval_layers_hit",
                           "tool_calls", "conversation_window"):
            if row_dict.get(json_field):
                try:
                    row_dict[json_field] = json.loads(row_dict[json_field])
                except (json.JSONDecodeError, TypeError):
                    pass
        results.append(row_dict)

    return web.json_response({"total": total, "results": results})


async def handle_audit_page(request):
    """GET /audit — HTML page for browsing response audit data."""
    return web.Response(content_type="text/html", text=_render_audit_page())


async def handle_api_usage(request):
    """POST /api/usage — log claim usage for analytics.

    Body: {"claim_path": "...", "agent": "rio", "context": "telegram-response"}
    Fire-and-forget — returns 200 immediately.
    """
    try:
        body = await request.json()
    except Exception:
        return web.json_response({"error": "invalid JSON"}, status=400)

    claim_path = body.get("claim_path", "").strip()
    if not claim_path:
        return web.json_response({"error": "claim_path required"}, status=400)

    agent = body.get("agent", "unknown")
    context = body.get("context", "")

    # Fire-and-forget write — don't block the response
    try:
        write_conn = _get_write_db()
        if write_conn:
            write_conn.execute(
                "INSERT INTO claim_usage (claim_path, agent, context) VALUES (?, ?, ?)",
                (claim_path, agent, context),
            )
            write_conn.commit()
            write_conn.close()
    except Exception as e:
        logger.warning("Usage log failed (non-fatal): %s", e)

    return web.json_response({"status": "ok"})


# ─── Dashboard HTML ──────────────────────────────────────────────────────────


def _render_error(message: str) -> str:
    """Render a minimal error page when DB is unavailable."""
    return f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Argus — Error</title>
<style>body {{ font-family: -apple-system, system-ui, sans-serif; background: #0d1117; color: #c9d1d9; display: flex; align-items: center; justify-content: center; min-height: 100vh; }}
.err {{ text-align: center; }} h1 {{ color: #f85149; }} p {{ color: #8b949e; }}</style>
</head><body><div class="err"><h1>Argus</h1><p>{message}</p><p>Check if <code>teleo-pipeline.service</code> is running and pipeline.db exists.</p></div></body></html>"""


def _render_audit_page() -> str:
    """Render the response audit browser page."""
    return """<!DOCTYPE html>
<html><head>
<meta charset="utf-8">
<title>Argus — Response Audit</title>
<style>
  * { box-sizing: border-box; margin: 0; padding: 0; }
  body { font-family: -apple-system, system-ui, 'Segoe UI', sans-serif; background: #0d1117; color: #c9d1d9; padding: 24px; }
  h1 { color: #58a6ff; margin-bottom: 8px; font-size: 22px; }
  .subtitle { color: #8b949e; margin-bottom: 20px; font-size: 13px; }
  .filters { display: flex; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; align-items: center; }
  .filters input, .filters select { background: #161b22; border: 1px solid #30363d; color: #c9d1d9; padding: 8px 12px; border-radius: 6px; font-size: 13px; }
  .filters input:focus, .filters select:focus { border-color: #58a6ff; outline: none; }
  .filters button { background: #238636; color: #fff; border: none; padding: 8px 16px; border-radius: 6px; cursor: pointer; font-size: 13px; }
  .filters button:hover { background: #2ea043; }
  .stats { color: #8b949e; font-size: 12px; margin-bottom: 12px; }
  .audit-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; margin-bottom: 12px; }
  .audit-card:hover { border-color: #484f58; }
  .card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
  .card-meta { font-size: 12px; color: #8b949e; }
  .card-agent { display: inline-block; background: #1f6feb33; color: #58a6ff; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 600; }
  .card-confidence { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 600; }
  .conf-high { background: #23863633; color: #3fb950; }
  .conf-mid { background: #d2992233; color: #d29922; }
  .conf-low { background: #f8514933; color: #f85149; }
  .card-query { font-size: 14px; margin-bottom: 8px; word-break: break-word; }
  .card-query strong { color: #e6edf3; }
  .card-reformulated { font-size: 13px; color: #8b949e; margin-bottom: 8px; font-style: italic; }
  .card-claims { margin-top: 8px; }
  .card-claims summary { cursor: pointer; color: #58a6ff; font-size: 13px; }
  .claim-list { margin-top: 6px; padding-left: 16px; font-size: 12px; color: #8b949e; }
  .claim-list li { margin-bottom: 4px; }
  .score-badge { display: inline-block; background: #30363d; padding: 1px 6px; border-radius: 3px; font-size: 11px; margin-left: 4px; }
  .card-tools { margin-top: 8px; }
  .card-tools summary { cursor: pointer; color: #58a6ff; font-size: 13px; }
  .tool-json { margin-top: 6px; font-size: 11px; color: #8b949e; white-space: pre-wrap; word-break: break-all; max-height: 200px; overflow-y: auto; background: #0d1117; padding: 8px; border-radius: 4px; }
  .pagination { display: flex; gap: 8px; margin-top: 16px; justify-content: center; }
  .pagination button { background: #21262d; color: #c9d1d9; border: 1px solid #30363d; padding: 6px 14px; border-radius: 6px; cursor: pointer; }
  .pagination button:hover { background: #30363d; }
  .pagination button:disabled { opacity: 0.4; cursor: not-allowed; }
  .empty { text-align: center; color: #484f58; padding: 40px; font-size: 14px; }
  .nav { margin-bottom: 20px; font-size: 13px; }
  .nav a { color: #58a6ff; text-decoration: none; }
  .nav a:hover { text-decoration: underline; }
  .pass-badge { display: inline-block; background: #30363d; padding: 1px 6px; border-radius: 3px; font-size: 11px; margin-left: 4px; }
  .status-ok { display: inline-block; background: #23863633; color: #3fb950; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 600; margin-left: 6px; }
  .status-degraded { display: inline-block; background: #f8514933; color: #f85149; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 600; margin-left: 6px; }
  .status-none { display: inline-block; background: #484f5833; color: #8b949e; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 600; margin-left: 6px; }
</style>
</head>
<body>
<div class="nav"><a href="/">&larr; Dashboard</a></div>
<h1>Response Audit</h1>
<p class="subtitle">Browse agent responses, retrieved claims, and search quality metrics</p>

<div class="filters">
  <input type="text" id="query-filter" placeholder="Search queries..." style="width:250px">
  <select id="agent-filter">
    <option value="">All agents</option>
    <option value="rio">rio</option>
    <option value="leo">leo</option>
    <option value="theseus">theseus</option>
  </select>
  <select id="status-filter">
    <option value="">All retrieval</option>
    <option value="keyword_only">keyword_only (degraded)</option>
    <option value="vector+keyword">vector+keyword (healthy)</option>
    <option value="none">no retrieval</option>
  </select>
  <select id="days-filter">
    <option value="1">Last 24h</option>
    <option value="7" selected>Last 7 days</option>
    <option value="30">Last 30 days</option>
  </select>
  <button onclick="loadAudit()">Search</button>
</div>

<div class="stats" id="stats"></div>
<div id="results"></div>
<div class="pagination" id="pagination"></div>

<script>
let currentOffset = 0;
const PAGE_SIZE = 25;

async function loadAudit(offset = 0) {
  currentOffset = offset;
  const query = document.getElementById('query-filter').value;
  const agent = document.getElementById('agent-filter').value;
  const status = document.getElementById('status-filter').value;
  const days = document.getElementById('days-filter').value;

  const params = new URLSearchParams({limit: PAGE_SIZE, offset, days});
  if (query) params.set('query', query);
  if (agent) params.set('agent', agent);
  if (status) params.set('status', status);

  const resp = await fetch('/api/audit?' + params);
  const data = await resp.json();

  if (data.error) {
    document.getElementById('results').innerHTML = '<div class="empty">' + data.error + '</div>';
    return;
  }

  document.getElementById('stats').textContent =
    `${data.total} response${data.total !== 1 ? 's' : ''} found — showing ${offset + 1}–${Math.min(offset + PAGE_SIZE, data.total)}`;

  if (data.results.length === 0) {
    document.getElementById('results').innerHTML = '<div class="empty">No audit entries found. The bot logs responses as they happen.</div>';
    document.getElementById('pagination').innerHTML = '';
    return;
  }

  let html = '';
  for (const r of data.results) {
    const confClass = r.confidence_score >= 0.7 ? 'conf-high' : r.confidence_score >= 0.4 ? 'conf-mid' : 'conf-low';
    const confLabel = r.confidence_score !== null ? (r.confidence_score * 100).toFixed(0) + '%' : '—';

    // Claims matched — each has {path, title, score, rank, source}
    let claimsHtml = '';
    if (r.claims_matched && Array.isArray(r.claims_matched) && r.claims_matched.length > 0) {
      const items = r.claims_matched.map(c => {
        if (typeof c === 'string') return '<li>' + esc(c) + '</li>';
        const title = c.title || c.path || '?';
        const score = c.score != null ? ' <span class="score-badge">' + Number(c.score).toFixed(3) + '</span>' : '';
        const src = c.source ? ' <span class="score-badge">' + esc(c.source) + '</span>' : '';
        return '<li>#' + (c.rank || '?') + ' ' + esc(title) + score + src + '</li>';
      }).join('');
      claimsHtml = '<details class="card-claims"><summary>' + r.claims_matched.length + ' claims retrieved</summary><ul class="claim-list">' + items + '</ul></details>';
    } else {
      claimsHtml = '<div style="font-size:12px;color:#484f58">No claims matched</div>';
    }

    // Retrieval layers
    let layersHtml = '';
    if (r.retrieval_layers_hit && Array.isArray(r.retrieval_layers_hit)) {
      layersHtml = r.retrieval_layers_hit.map(l => ' <span class="pass-badge">' + esc(l) + '</span>').join('');
    }

    // Retrieval gap warning
    let gapHtml = '';
    if (r.retrieval_gap) {
      gapHtml = '<div style="font-size:12px;color:#f85149;margin-top:4px">Gap: ' + esc(r.retrieval_gap) + '</div>';
    }

    // Tool calls
    let toolsHtml = '';
    if (r.tool_calls && Array.isArray(r.tool_calls) && r.tool_calls.length > 0) {
      toolsHtml = '<details class="card-tools"><summary>' + r.tool_calls.length + ' retrieval steps</summary><div class="tool-json">' + esc(JSON.stringify(r.tool_calls, null, 2)) + '</div></details>';
    }

    // Display response preview
    let responseHtml = '';
    if (r.display_response) {
      const preview = r.display_response.length > 300 ? r.display_response.substring(0, 300) + '...' : r.display_response;
      responseHtml = '<details class="card-tools"><summary>Response preview</summary><div class="tool-json">' + esc(preview) + '</div></details>';
    }

    // Response time
    let timeHtml = r.response_time_ms ? ' <span class="pass-badge">' + (r.response_time_ms / 1000).toFixed(1) + 's</span>' : '';

    // Retrieval status — prominent color-coded badge
    let statusHtml = '';
    if (r.retrieval_status) {
      const s = r.retrieval_status;
      if (s.startsWith('vector+')) {
        statusHtml = '<span class="status-ok">' + esc(s) + '</span>';
      } else if (s.startsWith('keyword_only')) {
        statusHtml = '<span class="status-degraded">' + esc(s) + '</span>';
      } else if (s === 'none') {
        statusHtml = '<span class="status-none">no retrieval</span>';
      } else {
        statusHtml = '<span class="status-none">' + esc(s) + '</span>';
      }
    }

    html += '<div class="audit-card">' +
      '<div class="card-header">' +
        '<div><span class="card-agent">' + esc(r.agent || '?') + '</span>' + statusHtml + layersHtml + timeHtml + '</div>' +
        '<div class="card-meta">' + esc(r.user || '') + ' &middot; ' + esc(r.timestamp) + ' <span class="card-confidence ' + confClass + '">' + confLabel + '</span></div>' +
      '</div>' +
      '<div class="card-query"><strong>Q:</strong> ' + esc(r.query || '') + '</div>' +
      gapHtml +
      claimsHtml +
      toolsHtml +
      responseHtml +
    '</div>';
  }

  document.getElementById('results').innerHTML = html;

  // Pagination
  const totalPages = Math.ceil(data.total / PAGE_SIZE);
  const currentPage = Math.floor(offset / PAGE_SIZE);
  let pagHtml = '';
  if (currentPage > 0) pagHtml += '<button onclick="loadAudit(' + ((currentPage - 1) * PAGE_SIZE) + ')">&larr; Prev</button>';
  pagHtml += '<span style="color:#8b949e;padding:6px">Page ' + (currentPage + 1) + ' / ' + totalPages + '</span>';
  if (currentPage < totalPages - 1) pagHtml += '<button onclick="loadAudit(' + ((currentPage + 1) * PAGE_SIZE) + ')">Next &rarr;</button>';
  document.getElementById('pagination').innerHTML = pagHtml;
}

function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }

// Load on page open
loadAudit();
</script>
<!-- Tier 1b: Compute Profile (Max Telemetry) -->
<div style="margin-top:2rem; border-top:2px solid #10b981; padding-top:1rem;">
  <h2 style="color:#10b981; margin-bottom:1rem;">
    Compute Profile <span style="font-size:0.7em; color:#888;">(Claude Max Telemetry)</span>
  </h2>
  <div style="display:grid; grid-template-columns:repeat(4,1fr); gap:1rem; margin-bottom:1.5rem;">
    <div style="background:#1a1a2e; padding:1rem; border-radius:8px; text-align:center;">
      <div style="font-size:0.8rem; color:#888;">Cache Hit Rate</div>
      <div id="t1-cache-rate" style="font-size:1.8rem; font-weight:bold; color:#10b981;">—</div>
      <div style="font-size:0.7rem; color:#666;">prompt tokens from cache</div>
    </div>
    <div style="background:#1a1a2e; padding:1rem; border-radius:8px; text-align:center;">
      <div style="font-size:0.8rem; color:#888;">Avg Latency</div>
      <div id="t1-avg-latency" style="font-size:1.8rem; font-weight:bold; color:#f59e0b;">—</div>
      <div style="font-size:0.7rem; color:#666;">ms per Max call</div>
    </div>
    <div style="background:#1a1a2e; padding:1rem; border-radius:8px; text-align:center;">
      <div style="font-size:0.8rem; color:#888;">Subscription Calls</div>
      <div id="t1-sub-calls" style="font-size:1.8rem; font-weight:bold; color:#8b5cf6;">—</div>
      <div style="font-size:0.7rem; color:#666;">vs <span id="t1-api-calls">—</span> API calls</div>
    </div>
    <div style="background:#1a1a2e; padding:1rem; border-radius:8px; text-align:center;">
      <div style="font-size:0.8rem; color:#888;">API-Equivalent Cost</div>
      <div id="t1-sub-estimate" style="font-size:1.8rem; font-weight:bold; color:#ec4899;">—</div>
      <div style="font-size:0.7rem; color:#666;">saved by Max subscription</div>
    </div>
  </div>
  <div style="display:grid; grid-template-columns:1fr 1fr; gap:1rem;">
    <div style="background:#1a1a2e; padding:1rem; border-radius:8px;">
      <h3 style="margin:0 0 0.5rem; font-size:0.9rem; color:#aaa;">Tokens by Stage & Billing</h3>
      <canvas id="computeStageChart" height="220"></canvas>
    </div>
    <div style="background:#1a1a2e; padding:1rem; border-radius:8px;">
      <h3 style="margin:0 0 0.5rem; font-size:0.9rem; color:#aaa;">Cache Breakdown (Max Calls)</h3>
      <canvas id="cacheChart" height="220"></canvas>
    </div>
  </div>
  <div id="t1-compute-detail" style="color:#888; font-size:0.8rem; margin-top:0.5rem;"></div>
</div>

<script>

// --- Tier 1b: Compute Profile ---
fetch('/api/compute-profile?days=30')
.then(r => r.json())
.then(cp => {{
  const sys = cp.system;
  const cache = cp.cache;
  const lat = cp.latency;

  // Hero cards
  document.getElementById('t1-cache-rate').textContent =
    cache.hit_rate > 0 ? (cache.hit_rate * 100).toFixed(1) + '%' : 'N/A';
  document.getElementById('t1-avg-latency').textContent =
    lat.avg_ms_per_call > 0 ? (lat.avg_ms_per_call / 1000).toFixed(1) + 's' : 'N/A';
  document.getElementById('t1-sub-calls').textContent =
    sys.subscription_calls.toLocaleString();
  document.getElementById('t1-api-calls').textContent =
    sys.api_calls.toLocaleString();
  document.getElementById('t1-sub-estimate').textContent =
    sys.subscription_estimate > 0 ? '$' + sys.subscription_estimate.toFixed(2) : 'N/A';

  // Compute by stage chart — horizontal bar, colored by billing type
  const stages = cp.by_stage;
  new Chart(document.getElementById('computeStageChart'), {{
    type: 'bar',
    data: {{
      labels: stages.map(s => s.stage.replace('eval_', '').replace(':max', ' \u2295').replace(':openrouter', ' $')),
      datasets: [{{
        label: 'Input Tokens',
        data: stages.map(s => s.input_tokens),
        backgroundColor: stages.map(s => s.billing === 'subscription' ? '#8b5cf6' : '#3b82f6'),
      }}, {{
        label: 'Output Tokens',
        data: stages.map(s => s.output_tokens),
        backgroundColor: stages.map(s => s.billing === 'subscription' ? '#a78bfa' : '#60a5fa'),
      }}, {{
        label: 'Cache Read',
        data: stages.map(s => s.cache_read_tokens),
        backgroundColor: '#10b981',
      }}]
    }},
    options: {{
      indexAxis: 'y',
      responsive: true,
      plugins: {{
        legend: {{ labels: {{ color: '#aaa' }} }},
        tooltip: {{
          callbacks: {{
            afterBody: function(ctx) {{
              const s = stages[ctx[0].dataIndex];
              const lines = ['Calls: ' + s.calls];
              if (s.avg_latency_ms > 0) lines.push('Avg latency: ' + (s.avg_latency_ms / 1000).toFixed(1) + 's');
              if (s.cache_hit_rate > 0) lines.push('Cache hit: ' + (s.cache_hit_rate * 100).toFixed(1) + '%');
              lines.push(s.billing === 'subscription' ? '\u2295 Subscription (flat-rate)' : '$ API (' + s.api_cost.toFixed(4) + ')');
              return lines;
            }}
          }}
        }}
      }},
      scales: {{
        x: {{ stacked: true, ticks: {{ color: '#aaa', callback: v => (v/1000).toFixed(0)+'K' }}, grid: {{ color: '#333' }},
             title: {{ display: true, text: 'Tokens', color: '#888' }} }},
        y: {{ stacked: true, ticks: {{ color: '#aaa' }}, grid: {{ color: '#333' }} }}
      }}
    }}
  }});

  // Cache breakdown doughnut — only for stages with cache data
  const cacheStages = stages.filter(s => s.cache_read_tokens > 0 || s.cache_write_tokens > 0);
  if (cacheStages.length > 0) {{
    new Chart(document.getElementById('cacheChart'), {{
      type: 'doughnut',
      data: {{
        labels: ['Cache Hits (free)', 'Cache Writes', 'Uncached Input'],
        datasets: [{{
          data: [cache.read_tokens, cache.write_tokens,
                 sys.total_tokens - cache.read_tokens - cache.write_tokens],
          backgroundColor: ['#10b981', '#f59e0b', '#6b7280'],
        }}]
      }},
      options: {{
        responsive: true,
        plugins: {{
          legend: {{ labels: {{ color: '#aaa' }} }},
          tooltip: {{
            callbacks: {{
              label: function(ctx) {{
                const v = ctx.raw;
                const pct = ((v / sys.total_tokens) * 100).toFixed(1);
                return ctx.label + ': ' + (v/1000).toFixed(1) + 'K tokens (' + pct + '%)';
              }}
            }}
          }}
        }}
      }}
    }});
  }} else {{
    document.getElementById('cacheChart').parentElement.innerHTML +=
      '<p style="color:#666; text-align:center; margin-top:2rem;">No cache data yet — populates when Opus re-enabled</p>';
  }}

  document.getElementById('t1-compute-detail').textContent =
    'Total: ' + (sys.total_tokens/1000).toFixed(0) + 'K tokens across ' +
    sys.total_calls + ' calls. API spend: $' + sys.api_spend.toFixed(2) +
    '. Subscription savings: $' + sys.subscription_estimate.toFixed(2) +
    ' (what Max calls would cost at API rates).';
}})
.catch(err => {{
  document.getElementById('t1-compute-detail').textContent = 'Failed to load compute profile: ' + err.message;
}});

</script>
</body></html>"""


def _render_dashboard(metrics, snapshots, changes, vital_signs, contributors_principal, contributors_agent, domain_breakdown, now) -> str:
    """Render the full operational dashboard as HTML with Chart.js."""

    # Prepare chart data
    timestamps = [s["ts"] for s in snapshots]
    throughput_data = [s.get("throughput_1h", 0) for s in snapshots]
    approval_data = [(s.get("approval_rate") or 0) * 100 for s in snapshots]
    open_prs_data = [s.get("open_prs", 0) for s in snapshots]
    merged_data = [s.get("merged_total", 0) for s in snapshots]

    # Rejection breakdown
    rej_wiki = [s.get("rejection_broken_wiki_links", 0) for s in snapshots]
    rej_schema = [s.get("rejection_frontmatter_schema", 0) for s in snapshots]
    rej_dup = [s.get("rejection_near_duplicate", 0) for s in snapshots]
    rej_conf = [s.get("rejection_confidence", 0) for s in snapshots]
    rej_other = [s.get("rejection_other", 0) for s in snapshots]

    # Source origins
    origin_agent = [s.get("source_origin_agent", 0) for s in snapshots]
    origin_human = [s.get("source_origin_human", 0) for s in snapshots]

    # Version annotations
    annotations_js = json.dumps([
        {
            "type": "line",
            "xMin": c["ts"],
            "xMax": c["ts"],
            "borderColor": "#d29922" if c["type"] == "prompt" else "#58a6ff",
            "borderWidth": 1,
            "borderDash": [4, 4],
            "label": {
                "display": True,
                "content": f"{c['type']}: {c.get('to', '?')}",
                "position": "start",
                "backgroundColor": "#161b22",
                "color": "#8b949e",
                "font": {"size": 10},
            },
        }
        for c in changes
    ])

    # Status color helper
    sm = metrics["status_map"]
    ar = metrics["approval_rate"]
    ar_color = "green" if ar > 0.5 else ("yellow" if ar > 0.2 else "red")
    fr_color = "green" if metrics["fix_rate"] > 0.3 else ("yellow" if metrics["fix_rate"] > 0.1 else "red")

    # Vital signs
    vs_review = vital_signs["review_throughput"]
    vs_status_color = {"healthy": "green", "warning": "yellow", "critical": "red"}.get(vs_review["status"], "yellow")

    # Orphan ratio
    vs_orphan = vital_signs.get("orphan_ratio", {})
    orphan_ratio_val = vs_orphan.get("ratio")
    orphan_color = {"healthy": "green", "warning": "yellow", "critical": "red"}.get(vs_orphan.get("status", ""), "")
    orphan_display = f"{orphan_ratio_val:.1%}" if orphan_ratio_val is not None else "—"

    # Linkage density
    vs_linkage = vital_signs.get("linkage_density") or {}
    linkage_display = f'{vs_linkage.get("avg_outgoing_links", "—")}'
    cross_domain_ratio = vs_linkage.get("cross_domain_ratio")
    cross_domain_color = "green" if cross_domain_ratio and cross_domain_ratio >= 0.15 else ("yellow" if cross_domain_ratio and cross_domain_ratio >= 0.05 else "red") if cross_domain_ratio is not None else ""

    # Evidence freshness
    vs_fresh = vital_signs.get("evidence_freshness") or {}
    fresh_display = f'{vs_fresh.get("median_age_days", "—")}' if vs_fresh.get("median_age_days") else "—"
    fresh_pct = vs_fresh.get("fresh_30d_pct", 0)

    # Confidence distribution
    vs_conf = vital_signs.get("confidence_distribution", {})

    # Rejection reasons table — show unique PRs alongside event count
    reason_rows = "".join(
        f'<tr><td><code>{r["tag"]}</code></td><td>{r["unique_prs"]}</td><td style="color:#8b949e">{r["count"]}</td></tr>'
        for r in metrics["rejection_reasons"]
    )

    # Domain table
    domain_rows = ""
    for domain, statuses in sorted(metrics["domains"].items()):
        m = statuses.get("merged", 0)
        c = statuses.get("closed", 0)
        o = statuses.get("open", 0)
        total = sum(statuses.values())
        domain_rows += f"<tr><td>{domain}</td><td>{total}</td><td class='green'>{m}</td><td class='red'>{c}</td><td>{o}</td></tr>"

    # Contributor rows — principal view (default)
    principal_rows = "".join(
        f'<tr><td>{c["handle"]}'
        + (f'<span style="color:#8b949e;font-size:11px"> ({", ".join(c["agents"])})</span>' if c.get("agents") else "")
        + f'</td><td>{c["tier"]}</td>'
        f'<td>{c["claims_merged"]}</td><td>{c["ci"]}</td>'
        f'<td>{", ".join(c["domains"][:3]) if c["domains"] else "-"}</td></tr>'
        for c in contributors_principal[:10]
    )
    # Contributor rows — agent view
    agent_rows = "".join(
        f'<tr><td>{c["handle"]}'
        + (f'<span style="color:#8b949e;font-size:11px"> → {c["principal"]}</span>' if c.get("principal") else "")
        + f'</td><td>{c["tier"]}</td>'
        f'<td>{c["claims_merged"]}</td><td>{c["ci"]}</td>'
        f'<td>{", ".join(c["domains"][:3]) if c["domains"] else "-"}</td></tr>'
        for c in contributors_agent[:10]
    )

    # Breaker status
    breaker_rows = ""
    for name, info in metrics["breakers"].items():
        state = info["state"]
        color = "green" if state == "closed" else ("red" if state == "open" else "yellow")
        age = f'{info.get("age_s", "?")}s ago' if "age_s" in info else "-"
        breaker_rows += f'<tr><td>{name}</td><td class="{color}">{state}</td><td>{info["failures"]}</td><td>{age}</td></tr>'

    # Funnel numbers
    funnel = vital_signs["funnel"]

    return f"""<!DOCTYPE html>
<html lang="en"><head>
<meta charset="utf-8">
<title>Argus — Teleo Diagnostics</title>
<meta http-equiv="refresh" content="60">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.6"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation@3.1.0"></script>
<style>
  * {{ box-sizing: border-box; margin: 0; padding: 0; }}
  body {{ font-family: -apple-system, system-ui, 'Segoe UI', sans-serif; background: #0d1117; color: #c9d1d9; padding: 24px; }}
  .header {{ display: flex; align-items: baseline; gap: 12px; margin-bottom: 8px; }}
  h1 {{ color: #58a6ff; font-size: 24px; }}
  .subtitle {{ color: #8b949e; font-size: 13px; }}
  .grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin: 20px 0; }}
  .card {{ background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; }}
  .card .label {{ color: #8b949e; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; }}
  .card .value {{ font-size: 28px; font-weight: 700; margin-top: 2px; }}
  .card .detail {{ color: #8b949e; font-size: 11px; margin-top: 2px; }}
  .green {{ color: #3fb950; }}
  .yellow {{ color: #d29922; }}
  .red {{ color: #f85149; }}
  .blue {{ color: #58a6ff; }}
  .chart-container {{ background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; margin: 16px 0; }}
  .chart-container h2 {{ color: #c9d1d9; font-size: 14px; margin-bottom: 12px; }}
  canvas {{ max-height: 260px; }}
  .row {{ display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }}
  @media (max-width: 800px) {{ .row {{ grid-template-columns: 1fr; }} }}
  table {{ width: 100%; border-collapse: collapse; font-size: 13px; }}
  th {{ color: #8b949e; font-size: 11px; text-transform: uppercase; text-align: left; padding: 6px 10px; border-bottom: 1px solid #30363d; }}
  td {{ padding: 6px 10px; border-bottom: 1px solid #21262d; }}
  code {{ background: #21262d; padding: 2px 6px; border-radius: 3px; font-size: 12px; }}
  .section {{ margin-top: 28px; }}
  .section-title {{ color: #58a6ff; font-size: 15px; font-weight: 600; margin-bottom: 12px; padding-bottom: 6px; border-bottom: 1px solid #21262d; }}
  .funnel {{ display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }}
  .funnel-step {{ text-align: center; flex: 1; min-width: 100px; }}
  .funnel-step .num {{ font-size: 24px; font-weight: 700; }}
  .funnel-step .lbl {{ font-size: 11px; color: #8b949e; text-transform: uppercase; }}
  .funnel-arrow {{ color: #30363d; font-size: 20px; }}
  .footer {{ margin-top: 40px; padding-top: 16px; border-top: 1px solid #21262d; color: #484f58; font-size: 11px; }}
  .footer a {{ color: #484f58; }}
</style>
</head><body>

<div class="header">
  <h1>Argus</h1>
  <span class="subtitle">Teleo Pipeline Diagnostics &middot; {now.strftime("%Y-%m-%d %H:%M UTC")} &middot; auto-refresh 60s</span>
</div>

<!-- Hero Cards -->
<div class="grid">
  <div class="card">
    <div class="label">Throughput</div>
    <div class="value">{metrics["throughput_1h"]}<span style="font-size:14px;color:#8b949e">/hr</span></div>
    <div class="detail">merged last hour</div>
  </div>
  <div class="card">
    <div class="label">Approval Rate (24h)</div>
    <div class="value {ar_color}">{ar:.1%}</div>
    <div class="detail">{metrics["approved_24h"]}/{metrics["evaluated_24h"]} evaluated</div>
  </div>
  <div class="card">
    <div class="label">Review Backlog</div>
    <div class="value {vs_status_color}">{vs_review["backlog"]}</div>
    <div class="detail">{vs_review["open_prs"]} open + {vs_review["reviewing_prs"]} reviewing + {vs_review["approved_waiting"]} approved + {vs_review["conflict_prs"]} conflicts</div>
  </div>
  <div class="card">
    <div class="label">Merged Total</div>
    <div class="value green">{sm.get("merged", 0)}</div>
    <div class="detail">{sm.get("closed", 0)} closed</div>
  </div>
  <div class="card">
    <div class="label">Fix Success</div>
    <div class="value {fr_color}">{metrics["fix_rate"]:.1%}</div>
    <div class="detail">{metrics["fix_succeeded"]}/{metrics["fix_attempted"]} fixed</div>
  </div>
  <div class="card">
    <div class="label">Time to Merge</div>
    <div class="value">{f"{metrics['median_ttm_minutes']:.0f}" if metrics["median_ttm_minutes"] else "—"}<span style="font-size:14px;color:#8b949e">min</span></div>
    <div class="detail">median (24h)</div>
  </div>
</div>

<!-- Pipeline Funnel -->
<div class="section">
  <div class="section-title">Pipeline Funnel</div>
  <div class="funnel">
    <div class="funnel-step"><div class="num">{funnel["sources_total"]}</div><div class="lbl">Sources</div></div>
    <div class="funnel-arrow">→</div>
    <div class="funnel-step"><div class="num" style="color: #f0883e">{funnel["sources_queued"]}</div><div class="lbl">In Queue</div></div>
    <div class="funnel-arrow">&rarr;</div>
    <div class="funnel-step"><div class="num">{funnel["sources_extracted"]}</div><div class="lbl">Extracted</div></div>
    <div class="funnel-arrow">&rarr;</div>
    <div class="funnel-step"><div class="num">{funnel["prs_total"]}</div><div class="lbl">PRs Created</div></div>
    <div class="funnel-arrow">&rarr;</div>
    <div class="funnel-step"><div class="num green">{funnel["prs_merged"]}</div><div class="lbl">Merged</div></div>
    <div class="funnel-arrow">&rarr;</div>
    <div class="funnel-step"><div class="num blue">{funnel["conversion_rate"]:.1%}</div><div class="lbl">Conversion</div></div>
  </div>
</div>

<!-- Vital Signs (Vida's Five) -->
{f'''<div class="section">
  <div class="section-title">Knowledge Health (Vida&rsquo;s Vital Signs)</div>
  <div class="grid">
    <div class="card">
      <div class="label">Orphan Ratio</div>
      <div class="value {orphan_color}">{orphan_display}</div>
      <div class="detail">{vs_orphan.get("count", "?")} / {vs_orphan.get("total", "?")} claims &middot; target &lt;15%</div>
    </div>
    <div class="card">
      <div class="label">Avg Links/Claim</div>
      <div class="value">{linkage_display}</div>
      <div class="detail">cross-domain: <span class="{cross_domain_color}">{f"{cross_domain_ratio:.1%}" if cross_domain_ratio is not None else "—"}</span> &middot; target 15-30%</div>
    </div>
    <div class="card">
      <div class="label">Evidence Freshness</div>
      <div class="value">{fresh_display}<span style="font-size:14px;color:#8b949e">d median</span></div>
      <div class="detail">{vs_fresh.get("fresh_30d_count", "?")} claims &lt;30d old &middot; {fresh_pct:.0f}% fresh</div>
    </div>
    <div class="card">
      <div class="label">Confidence Spread</div>
      <div class="value" style="font-size:16px">{" / ".join(f"{vs_conf.get(k, 0)}" for k in ["proven", "likely", "experimental", "speculative"])}</div>
      <div class="detail">proven / likely / experimental / speculative</div>
    </div>
  </div>
</div>''' if vital_signs.get("claim_index_status") == "live" else ""}

<!-- Charts -->
<div id="no-chart-data" class="card" style="text-align:center;padding:40px;margin:16px 0;display:none">
  <p style="color:#8b949e">No time-series data yet. Charts will appear once Epimetheus wires <code>record_snapshot()</code> into the pipeline daemon.</p>
</div>
<div id="chart-section">
<div class="row">
  <div class="chart-container">
    <h2>Throughput &amp; Approval Rate</h2>
    <canvas id="throughputChart"></canvas>
  </div>
  <div class="chart-container">
    <h2>Rejection Reasons Over Time</h2>
    <canvas id="rejectionChart"></canvas>
  </div>
</div>
<div class="row">
  <div class="chart-container">
    <h2>PR Backlog</h2>
    <canvas id="backlogChart"></canvas>
  </div>
  <div class="chart-container">
    <h2>Source Origins (24h snapshots)</h2>
    <canvas id="originChart"></canvas>
  </div>
</div>
</div>

<!-- Tables -->
<div class="row">
  <div class="section">
    <div class="section-title">Top Rejection Reasons (24h)</div>
    <div class="card">
      <table>
        <tr><th>Issue</th><th>PRs</th><th style="color:#8b949e">Events</th></tr>
        {reason_rows if reason_rows else "<tr><td colspan='2' style='color:#8b949e'>No rejections in 24h</td></tr>"}
      </table>
    </div>
  </div>
  <div class="section">
    <div class="section-title">Circuit Breakers</div>
    <div class="card">
      <table>
        <tr><th>Stage</th><th>State</th><th>Failures</th><th>Last Success</th></tr>
        {breaker_rows if breaker_rows else "<tr><td colspan='4' style='color:#8b949e'>No breaker data</td></tr>"}
      </table>
    </div>
  </div>
</div>

<div class="row">
  <div class="section">
    <div class="section-title">Domain Breakdown</div>
    <div class="card">
      <table>
        <tr><th>Domain</th><th>Total</th><th>Merged</th><th>Closed</th><th>Open</th></tr>
        {domain_rows}
      </table>
    </div>
  </div>
  <div class="section">
    <div class="section-title" style="display:flex;align-items:center;gap:12px">
      Top Contributors (by CI)
      <span style="font-size:11px;font-weight:400">
        <button id="btn-principal" onclick="toggleContribView('principal')" style="background:#21262d;color:#58a6ff;border:1px solid #30363d;border-radius:4px;padding:2px 8px;cursor:pointer;font-size:11px">By Human</button>
        <button id="btn-agent" onclick="toggleContribView('agent')" style="background:transparent;color:#8b949e;border:1px solid #30363d;border-radius:4px;padding:2px 8px;cursor:pointer;font-size:11px">By Agent</button>
      </span>
    </div>
    <div class="card">
      <table id="contrib-principal">
        <tr><th>Contributor</th><th>Tier</th><th>Claims</th><th>CI</th><th>Domains</th></tr>
        {principal_rows if principal_rows else "<tr><td colspan='5' style='color:#8b949e'>No contributors yet</td></tr>"}
      </table>
      <table id="contrib-agent" style="display:none">
        <tr><th>Agent</th><th>Tier</th><th>Claims</th><th>CI</th><th>Domains</th></tr>
        {agent_rows if agent_rows else "<tr><td colspan='5' style='color:#8b949e'>No contributors yet</td></tr>"}
      </table>
    </div>
  </div>
</div>

<!-- Domain Breakdown -->
<div class="section">
  <div class="section-title">Contributions by Domain</div>
  <div class="card">
    <table>
      <tr><th>Domain</th><th>Knowledge PRs</th><th>Top Contributors</th></tr>
      {"".join(f'''<tr>
        <td style="color:#58a6ff">{domain}</td>
        <td>{stats["knowledge_prs"]}</td>
        <td style="font-size:12px;color:#8b949e">{", ".join(f'{c["handle"]} ({c["claims"]})' for c in stats["contributors"][:3])}</td>
      </tr>''' for domain, stats in sorted(domain_breakdown.items(), key=lambda x: x[1]["knowledge_prs"], reverse=True) if stats["knowledge_prs"] > 0)}
    </table>
  </div>
</div>

<!-- Stagnation Alerts -->
{"" if not vital_signs["domain_activity"]["stagnant"] else f'''
<div class="section">
  <div class="section-title" style="color:#d29922">Stagnation Alerts</div>
  <div class="card">
    <p style="color:#d29922">Domains with no PR activity in 7 days: <strong>{", ".join(vital_signs["domain_activity"]["stagnant"])}</strong></p>
  </div>
</div>
'''}

<div class="footer">
  Argus &middot; Teleo Pipeline Diagnostics &middot;
  <a href="/api/metrics">API: Metrics</a> &middot;
  <a href="/api/snapshots">Snapshots</a> &middot;
  <a href="/api/vital-signs">Vital Signs</a> &middot;
  <a href="/api/contributors">Contributors</a> &middot;
  <a href="/api/domains">Domains</a> &middot;
  <a href="/audit">Response Audit</a>
</div>

<script>
const timestamps = {json.dumps(timestamps)};

if (timestamps.length === 0) {{
  document.getElementById('chart-section').style.display = 'none';
  document.getElementById('no-chart-data').style.display = 'block';
}} else {{

const throughputData = {json.dumps(throughput_data)};
const approvalData = {json.dumps(approval_data)};
const openPrsData = {json.dumps(open_prs_data)};
const mergedData = {json.dumps(merged_data)};
const rejWiki = {json.dumps(rej_wiki)};
const rejSchema = {json.dumps(rej_schema)};
const rejDup = {json.dumps(rej_dup)};
const rejConf = {json.dumps(rej_conf)};
const rejOther = {json.dumps(rej_other)};
const originAgent = {json.dumps(origin_agent)};
const originHuman = {json.dumps(origin_human)};
const annotations = {annotations_js};

const chartDefaults = {{
  color: '#8b949e',
  borderColor: '#30363d',
  font: {{ family: '-apple-system, system-ui, sans-serif' }},
}};
Chart.defaults.color = '#8b949e';
Chart.defaults.borderColor = '#21262d';
Chart.defaults.font.family = '-apple-system, system-ui, sans-serif';
Chart.defaults.font.size = 11;

// Throughput + Approval Rate (dual axis)
new Chart(document.getElementById('throughputChart'), {{
  type: 'line',
  data: {{
    labels: timestamps,
    datasets: [
      {{
        label: 'Throughput/hr',
        data: throughputData,
        borderColor: '#58a6ff',
        backgroundColor: 'rgba(88,166,255,0.1)',
        fill: true,
        tension: 0.3,
        yAxisID: 'y',
        pointRadius: 1,
      }},
      {{
        label: 'Approval %',
        data: approvalData,
        borderColor: '#3fb950',
        borderDash: [4, 2],
        tension: 0.3,
        yAxisID: 'y1',
        pointRadius: 1,
      }},
    ],
  }},
  options: {{
    responsive: true,
    interaction: {{ mode: 'index', intersect: false }},
    scales: {{
      x: {{ type: 'time', time: {{ unit: 'hour', displayFormats: {{ hour: 'MMM d HH:mm' }} }}, grid: {{ display: false }} }},
      y: {{ position: 'left', title: {{ display: true, text: 'PRs/hr' }}, min: 0 }},
      y1: {{ position: 'right', title: {{ display: true, text: 'Approval %' }}, min: 0, max: 100, grid: {{ drawOnChartArea: false }} }},
    }},
    plugins: {{
      annotation: {{ annotations: annotations }},
      legend: {{ labels: {{ boxWidth: 12 }} }},
    }},
  }},
}});

// Rejection reasons (stacked area)
new Chart(document.getElementById('rejectionChart'), {{
  type: 'line',
  data: {{
    labels: timestamps,
    datasets: [
      {{ label: 'Wiki Links', data: rejWiki, borderColor: '#f85149', backgroundColor: 'rgba(248,81,73,0.2)', fill: true, tension: 0.3, pointRadius: 0 }},
      {{ label: 'Schema', data: rejSchema, borderColor: '#d29922', backgroundColor: 'rgba(210,153,34,0.2)', fill: true, tension: 0.3, pointRadius: 0 }},
      {{ label: 'Duplicate', data: rejDup, borderColor: '#8b949e', backgroundColor: 'rgba(139,148,158,0.2)', fill: true, tension: 0.3, pointRadius: 0 }},
      {{ label: 'Confidence', data: rejConf, borderColor: '#bc8cff', backgroundColor: 'rgba(188,140,255,0.2)', fill: true, tension: 0.3, pointRadius: 0 }},
      {{ label: 'Other', data: rejOther, borderColor: '#6e7681', backgroundColor: 'rgba(110,118,129,0.15)', fill: true, tension: 0.3, pointRadius: 0 }},
    ],
  }},
  options: {{
    responsive: true,
    scales: {{
      x: {{ type: 'time', time: {{ unit: 'hour', displayFormats: {{ hour: 'MMM d HH:mm' }} }}, grid: {{ display: false }} }},
      y: {{ stacked: true, min: 0, title: {{ display: true, text: 'Count (24h)' }} }},
    }},
    plugins: {{
      annotation: {{ annotations: annotations }},
      legend: {{ labels: {{ boxWidth: 12 }} }},
    }},
  }},
}});

// PR Backlog
new Chart(document.getElementById('backlogChart'), {{
  type: 'line',
  data: {{
    labels: timestamps,
    datasets: [
      {{ label: 'Open PRs', data: openPrsData, borderColor: '#d29922', backgroundColor: 'rgba(210,153,34,0.15)', fill: true, tension: 0.3, pointRadius: 1 }},
      {{ label: 'Merged (total)', data: mergedData, borderColor: '#3fb950', tension: 0.3, pointRadius: 1 }},
    ],
  }},
  options: {{
    responsive: true,
    scales: {{
      x: {{ type: 'time', time: {{ unit: 'hour', displayFormats: {{ hour: 'MMM d HH:mm' }} }}, grid: {{ display: false }} }},
      y: {{ min: 0, title: {{ display: true, text: 'PRs' }} }},
    }},
    plugins: {{ legend: {{ labels: {{ boxWidth: 12 }} }} }},
  }},
}});

// Source Origins
new Chart(document.getElementById('originChart'), {{
  type: 'bar',
  data: {{
    labels: timestamps,
    datasets: [
      {{ label: 'Agent', data: originAgent, backgroundColor: '#58a6ff' }},
      {{ label: 'Human', data: originHuman, backgroundColor: '#3fb950' }},
    ],
  }},
  options: {{
    responsive: true,
    scales: {{
      x: {{ type: 'time', stacked: true, time: {{ unit: 'hour', displayFormats: {{ hour: 'MMM d HH:mm' }} }}, grid: {{ display: false }} }},
      y: {{ stacked: true, min: 0, title: {{ display: true, text: 'Sources (24h)' }} }},
    }},
    plugins: {{ legend: {{ labels: {{ boxWidth: 12 }} }} }},
  }},
}});

}} // end if (timestamps.length > 0)

function toggleContribView(view) {{
  const principal = document.getElementById('contrib-principal');
  const agent = document.getElementById('contrib-agent');
  const btnP = document.getElementById('btn-principal');
  const btnA = document.getElementById('btn-agent');
  if (view === 'agent') {{
    principal.style.display = 'none';
    agent.style.display = '';
    btnA.style.background = '#21262d';
    btnA.style.color = '#58a6ff';
    btnP.style.background = 'transparent';
    btnP.style.color = '#8b949e';
  }} else {{
    principal.style.display = '';
    agent.style.display = 'none';
    btnP.style.background = '#21262d';
    btnP.style.color = '#58a6ff';
    btnA.style.background = 'transparent';
    btnA.style.color = '#8b949e';
  }}
}}
</script>

<!-- Tier 1: Knowledge Production Metrics -->
<div class="section">
  <div class="section-title" style="display:flex;align-items:center;gap:12px">
    Knowledge Production
    <span style="font-size:11px;font-weight:400;color:#8b949e">
      The three numbers that matter &middot; <a href="/api/yield" class="nav-link" style="border:none;padding:0">yield</a> &middot;
      <a href="/api/cost-per-claim" class="nav-link" style="border:none;padding:0">cost</a> &middot;
      <a href="/api/fix-rates" class="nav-link" style="border:none;padding:0">fix rates</a>
    </span>
  </div>

  <!-- Tier 1 Hero Cards -->
  <div class="grid" id="tier1-cards">
    <div class="card">
      <div class="label">Extraction Yield</div>
      <div class="value" id="t1-yield">&mdash;</div>
      <div class="detail" id="t1-yield-detail">loading...</div>
    </div>
    <div class="card">
      <div class="label">Cost / Merged Claim</div>
      <div class="value" id="t1-cost">&mdash;</div>
      <div class="detail" id="t1-cost-detail">loading...</div>
    </div>
    <div class="card">
      <div class="label">Fix Success Rate</div>
      <div class="value" id="t1-fix">&mdash;</div>
      <div class="detail" id="t1-fix-detail">loading...</div>
    </div>
  </div>

  <!-- Yield + Cost charts side by side -->
  <div class="row">
    <div class="chart-container">
      <h2>Extraction Yield by Agent (daily)</h2>
      <canvas id="yieldChart"></canvas>
    </div>
    <div class="chart-container">
      <h2>Cost per Merged Claim (daily)</h2>
      <canvas id="costChart"></canvas>
    </div>
  </div>

  <!-- Fix rates chart + cost breakdown -->
  <div class="row">
    <div class="chart-container">
      <h2>Fix Success by Rejection Reason</h2>
      <canvas id="fixRateChart"></canvas>
    </div>
    <div class="chart-container">
      <h2>Cost by Stage</h2>
      <canvas id="costStageChart"></canvas>
    </div>
  </div>
</div>

<script>

// --- Tier 1: Knowledge Production Charts ---
const AGENT_COLORS = {{
  'rio': '#58a6ff',
  'clay': '#3fb950',
  'astra': '#bc8cff',
  'leo': '#d29922',
  'vida': '#f0883e',
  'theseus': '#f85149',
  'epimetheus': '#79c0ff',
  'unknown': '#8b949e',
}};
function agentColor(name) {{
  return AGENT_COLORS[name?.toLowerCase()] || '#' + ((name || '').split('').reduce((a, c) => (a * 31 + c.charCodeAt(0)) & 0xFFFFFF, 0x556677)).toString(16).padStart(6, '0');
}}

// Fetch all three Tier 1 endpoints
Promise.all([
  fetch('/api/yield?days=30').then(r => r.json()),
  fetch('/api/cost-per-claim?days=30').then(r => r.json()),
  fetch('/api/fix-rates?days=30').then(r => r.json()),
]).then(([yieldData, costData, fixData]) => {{

  // --- Hero cards ---
  const yieldEl = document.getElementById('t1-yield');
  const yieldDetail = document.getElementById('t1-yield-detail');
  if (yieldData.system) {{
    const y = yieldData.system.yield;
    yieldEl.textContent = (y * 100).toFixed(1) + '%';
    yieldEl.className = 'value ' + (y >= 0.3 ? 'green' : y >= 0.15 ? 'yellow' : 'red');
    yieldDetail.textContent = yieldData.system.merged + ' / ' + yieldData.system.evaluated + ' evaluated (30d)';
  }}

  const costEl = document.getElementById('t1-cost');
  const costDetail = document.getElementById('t1-cost-detail');
  if (costData.system) {{
    const estCost = costData.system.estimated_cost || 0;
    const actualSpend = costData.system.actual_spend || 0;
    const merged = costData.system.merged || 0;
    const cpc = merged > 0 ? estCost / merged : 0;
    if (estCost > 0 || cpc > 0) {{
      costEl.textContent = '$' + cpc.toFixed(3) + '/claim';
      costEl.className = 'value ' + (cpc <= 0.05 ? 'green' : cpc <= 0.20 ? 'yellow' : 'red');
    }} else {{
      costEl.textContent = '—';
    }}
    const parts = [];
    parts.push('$' + estCost.toFixed(2) + ' est');
    if (actualSpend > 0 && actualSpend !== estCost) parts.push('$' + actualSpend.toFixed(2) + ' actual');
    const tt = costData.system.total_tokens;
    if (tt) parts.push((tt/1000000).toFixed(2) + 'M tokens');
    parts.push(merged + ' merged (30d)');
    costDetail.textContent = parts.join(' · ');
  }}

  const fixEl = document.getElementById('t1-fix');
  const fixDetail = document.getElementById('t1-fix-detail');
  if (fixData.tags && fixData.tags.length > 0) {{
    const totalFixed = fixData.tags.reduce((s, t) => s + t.fixed, 0);
    const totalResolved = fixData.tags.reduce((s, t) => s + t.fixed + t.terminal, 0);
    const overallRate = totalResolved > 0 ? totalFixed / totalResolved : 0;
    fixEl.textContent = (overallRate * 100).toFixed(1) + '%';
    fixEl.className = 'value ' + (overallRate >= 0.5 ? 'green' : overallRate >= 0.25 ? 'yellow' : 'red');
    fixDetail.textContent = totalFixed + ' fixed / ' + totalResolved + ' resolved (30d)';
  }}

  // --- Yield chart: line per agent over weeks ---
  if (yieldData.daily && yieldData.daily.length > 0) {{
    const days = [...new Set(yieldData.daily.map(r => r.day))].sort();
    const agents = [...new Set(yieldData.daily.map(r => r.agent))];
    const yieldMap = {{}};
    yieldData.daily.forEach(r => {{ yieldMap[r.day + '|' + r.agent] = r.yield; }});

    new Chart(document.getElementById('yieldChart'), {{
      type: 'line',
      data: {{
        labels: days,
        datasets: agents.map(agent => ({{
          label: agent,
          data: days.map(d => ((yieldMap[d + '|' + agent] || 0) * 100)),
          borderColor: agentColor(agent),
          backgroundColor: agentColor(agent) + '20',
          tension: 0.3,
          pointRadius: 3,
          fill: false,
        }})),
      }},
      options: {{
        responsive: true,
        interaction: {{ mode: 'index', intersect: false }},
        scales: {{
          y: {{ min: 0, max: 100, title: {{ display: true, text: 'Yield %' }} }},
          x: {{ grid: {{ display: false }} }},
        }},
        plugins: {{ legend: {{ labels: {{ boxWidth: 12 }} }} }},
      }},
    }});
  }}

  // --- Compute per claim chart: dual axis (bar=tokens, line=tokens/claim) ---
  if (costData.daily && costData.daily.length > 0) {{
    const cDays = costData.daily.map(r => r.day).reverse();
    const apiCosts = costData.daily.map(r => r.actual_spend || 0).reverse();
    const estCosts = costData.daily.map(r => r.estimated_cost || 0).reverse();
    const cpcWeekly = costData.daily.map(r => r.cost_per_claim || 0).reverse();
    const totalTokensK = costData.daily.map(r => (r.total_tokens || 0) / 1000).reverse();

    new Chart(document.getElementById('costChart'), {{
      type: 'bar',
      data: {{
        labels: cDays,
        datasets: [
          {{
            label: 'Estimated Cost ($)',
            data: estCosts,
            backgroundColor: 'rgba(63,185,80,0.5)',
            borderColor: '#3fb950',
            borderWidth: 1,
            yAxisID: 'y',
            order: 3,
          }},
          {{
            label: '$/Claim',
            data: cpcWeekly,
            type: 'line',
            borderColor: '#f0883e',
            backgroundColor: 'rgba(240,136,62,0.1)',
            tension: 0.3,
            pointRadius: 4,
            pointBackgroundColor: '#f0883e',
            yAxisID: 'y1',
            order: 1,
          }},
          {{
            label: 'Tokens (K)',
            data: totalTokensK,
            type: 'line',
            borderColor: '#58a6ff',
            borderDash: [4, 4],
            tension: 0.3,
            pointRadius: 3,
            pointBackgroundColor: '#58a6ff',
            yAxisID: 'y2',
            order: 2,
          }},
        ],
      }},
      options: {{
        responsive: true,
        interaction: {{ mode: 'index', intersect: false }},
        scales: {{
          y: {{ position: 'left', title: {{ display: true, text: 'Estimated Cost ($)' }}, min: 0 }},
          y1: {{ position: 'right', title: {{ display: true, text: '$/Claim' }}, min: 0, grid: {{ drawOnChartArea: false }} }},
          y2: {{ display: false, min: 0 }},
          x: {{ grid: {{ display: false }} }},
        }},
        plugins: {{
          legend: {{ labels: {{ boxWidth: 12 }} }},
          tooltip: {{
            callbacks: {{
              afterBody: function(items) {{
                const idx = items[0].dataIndex;
                return 'Tokens: ' + totalTokensK[idx].toFixed(0) + 'K';
              }}
            }}
          }},
        }},
      }},
    }});
  }}

  // --- Compute breakdown by stage: doughnut (tokens, not dollars) ---
  if (costData.by_stage && costData.by_stage.length > 0) {{
    const stageColors = ['#58a6ff', '#3fb950', '#d29922', '#f0883e', '#bc8cff', '#f85149', '#8b949e'];
    // Filter out stages with zero tokens
    costData.by_stage = costData.by_stage.filter(s => (s.input_tokens || 0) + (s.output_tokens || 0) > 0);
    const stageCosts = costData.by_stage.map(s => s.estimated_cost || 0);
    const stageTokens = costData.by_stage.map(s => (s.input_tokens || 0) + (s.output_tokens || 0));
    const totalTok = stageTokens.reduce((a, b) => a + b, 0);
    new Chart(document.getElementById('costStageChart'), {{
      type: 'doughnut',
      data: {{
        labels: costData.by_stage.map(s => {{
          let name = s.stage.replace('eval_', '').replace(':openrouter', '').replace(':max', '');
          name = name.charAt(0).toUpperCase() + name.slice(1);
          const billing = s.billing === 'subscription' ? ' (Max)' : ' (API)';
          return name + billing;
        }}),
        datasets: [{{
          data: stageCosts.some(c => c > 0) ? stageCosts : stageTokens,
          backgroundColor: costData.by_stage.map((_, i) => stageColors[i % stageColors.length]),
          borderColor: '#161b22',
          borderWidth: 2,
        }}],
      }},
      options: {{
        responsive: true,
        plugins: {{
          legend: {{ position: 'right', labels: {{ boxWidth: 12, font: {{ size: 11 }} }} }},
          tooltip: {{
            callbacks: {{
              label: function(ctx) {{
                const idx = ctx.dataIndex;
                const tok = stageTokens[idx];
                const pct = totalTok > 0 ? ((tok / totalTok) * 100).toFixed(1) : '0';
                const s = costData.by_stage[idx];
                const billing = s.billing === 'subscription' ? '⊕ sub' : '$' + (s.estimated_cost || 0).toFixed(4);
                return ctx.label.replace(/ [⊕$]$/, '') + ': ' + (tok/1000).toFixed(0) + 'K tok (' + pct + '%) — ' + billing;
              }}
            }}
          }}
        }},
      }},
    }});
  }}

}}).catch(err => {{
  console.error('Tier 1 metrics error:', err);
  ['t1-yield', 't1-cost', 't1-fix'].forEach(id => {{
    const el = document.getElementById(id);
    if (el) {{ el.textContent = 'err'; el.className = 'value red'; }}
  }});
  ['t1-yield-detail', 't1-cost-detail', 't1-fix-detail'].forEach(id => {{
    const el = document.getElementById(id);
    if (el) el.textContent = 'Failed to load: ' + err.message;
  }});
}});

</script>
<!-- Tier 1b: Compute Profile (Max Telemetry) -->
<div style="margin-top:2rem; border-top:2px solid #10b981; padding-top:1rem;">
  <h2 style="color:#10b981; margin-bottom:1rem;">
    Compute Profile <span style="font-size:0.7em; color:#888;">(Claude Max Telemetry)</span>
  </h2>
  <div style="display:grid; grid-template-columns:repeat(4,1fr); gap:1rem; margin-bottom:1.5rem;">
    <div style="background:#1a1a2e; padding:1rem; border-radius:8px; text-align:center;">
      <div style="font-size:0.8rem; color:#888;">Cache Hit Rate</div>
      <div id="t1-cache-rate" style="font-size:1.8rem; font-weight:bold; color:#10b981;">—</div>
      <div style="font-size:0.7rem; color:#666;">prompt tokens from cache</div>
    </div>
    <div style="background:#1a1a2e; padding:1rem; border-radius:8px; text-align:center;">
      <div style="font-size:0.8rem; color:#888;">Avg Latency</div>
      <div id="t1-avg-latency" style="font-size:1.8rem; font-weight:bold; color:#f59e0b;">—</div>
      <div style="font-size:0.7rem; color:#666;">ms per Max call</div>
    </div>
    <div style="background:#1a1a2e; padding:1rem; border-radius:8px; text-align:center;">
      <div style="font-size:0.8rem; color:#888;">Subscription Calls</div>
      <div id="t1-sub-calls" style="font-size:1.8rem; font-weight:bold; color:#8b5cf6;">—</div>
      <div style="font-size:0.7rem; color:#666;">vs <span id="t1-api-calls">—</span> API calls</div>
    </div>
    <div style="background:#1a1a2e; padding:1rem; border-radius:8px; text-align:center;">
      <div style="font-size:0.8rem; color:#888;">API-Equivalent Cost</div>
      <div id="t1-sub-estimate" style="font-size:1.8rem; font-weight:bold; color:#ec4899;">—</div>
      <div style="font-size:0.7rem; color:#666;">saved by Max subscription</div>
    </div>
  </div>
  <div style="display:grid; grid-template-columns:1fr 1fr; gap:1rem;">
    <div style="background:#1a1a2e; padding:1rem; border-radius:8px;">
      <h3 style="margin:0 0 0.5rem; font-size:0.9rem; color:#aaa;">Tokens by Stage & Billing</h3>
      <canvas id="computeStageChart" height="220"></canvas>
    </div>
    <div style="background:#1a1a2e; padding:1rem; border-radius:8px;">
      <h3 style="margin:0 0 0.5rem; font-size:0.9rem; color:#aaa;">Cache Breakdown (Max Calls)</h3>
      <canvas id="cacheChart" height="220"></canvas>
    </div>
  </div>
  <div id="t1-compute-detail" style="color:#888; font-size:0.8rem; margin-top:0.5rem;"></div>
</div>

<script>

// --- Tier 1b: Compute Profile ---
fetch('/api/compute-profile?days=30')
.then(r => r.json())
.then(cp => {{
  const sys = cp.system;
  const cache = cp.cache;
  const lat = cp.latency;

  // Hero cards
  document.getElementById('t1-cache-rate').textContent =
    cache.hit_rate > 0 ? (cache.hit_rate * 100).toFixed(1) + '%' : 'N/A';
  document.getElementById('t1-avg-latency').textContent =
    lat.avg_ms_per_call > 0 ? (lat.avg_ms_per_call / 1000).toFixed(1) + 's' : 'N/A';
  document.getElementById('t1-sub-calls').textContent =
    sys.subscription_calls.toLocaleString();
  document.getElementById('t1-api-calls').textContent =
    sys.api_calls.toLocaleString();
  document.getElementById('t1-sub-estimate').textContent =
    sys.subscription_estimate > 0 ? '$' + sys.subscription_estimate.toFixed(2) : 'N/A';

  // Compute by stage chart — horizontal bar, colored by billing type
  const stages = cp.by_stage;
  new Chart(document.getElementById('computeStageChart'), {{
    type: 'bar',
    data: {{
      labels: stages.map(s => s.stage.replace('eval_', '').replace(':max', ' \u2295').replace(':openrouter', ' $')),
      datasets: [{{
        label: 'Input Tokens',
        data: stages.map(s => s.input_tokens),
        backgroundColor: stages.map(s => s.billing === 'subscription' ? '#8b5cf6' : '#3b82f6'),
      }}, {{
        label: 'Output Tokens',
        data: stages.map(s => s.output_tokens),
        backgroundColor: stages.map(s => s.billing === 'subscription' ? '#a78bfa' : '#60a5fa'),
      }}, {{
        label: 'Cache Read',
        data: stages.map(s => s.cache_read_tokens),
        backgroundColor: '#10b981',
      }}]
    }},
    options: {{
      indexAxis: 'y',
      responsive: true,
      plugins: {{
        legend: {{ labels: {{ color: '#aaa' }} }},
        tooltip: {{
          callbacks: {{
            afterBody: function(ctx) {{
              const s = stages[ctx[0].dataIndex];
              const lines = ['Calls: ' + s.calls];
              if (s.avg_latency_ms > 0) lines.push('Avg latency: ' + (s.avg_latency_ms / 1000).toFixed(1) + 's');
              if (s.cache_hit_rate > 0) lines.push('Cache hit: ' + (s.cache_hit_rate * 100).toFixed(1) + '%');
              lines.push(s.billing === 'subscription' ? '\u2295 Subscription (flat-rate)' : '$ API (' + s.api_cost.toFixed(4) + ')');
              return lines;
            }}
          }}
        }}
      }},
      scales: {{
        x: {{ stacked: true, ticks: {{ color: '#aaa', callback: v => (v/1000).toFixed(0)+'K' }}, grid: {{ color: '#333' }},
             title: {{ display: true, text: 'Tokens', color: '#888' }} }},
        y: {{ stacked: true, ticks: {{ color: '#aaa' }}, grid: {{ color: '#333' }} }}
      }}
    }}
  }});

  // Cache breakdown doughnut — only for stages with cache data
  const cacheStages = stages.filter(s => s.cache_read_tokens > 0 || s.cache_write_tokens > 0);
  if (cacheStages.length > 0) {{
    new Chart(document.getElementById('cacheChart'), {{
      type: 'doughnut',
      data: {{
        labels: ['Cache Hits (free)', 'Cache Writes', 'Uncached Input'],
        datasets: [{{
          data: [cache.read_tokens, cache.write_tokens,
                 sys.total_tokens - cache.read_tokens - cache.write_tokens],
          backgroundColor: ['#10b981', '#f59e0b', '#6b7280'],
        }}]
      }},
      options: {{
        responsive: true,
        plugins: {{
          legend: {{ labels: {{ color: '#aaa' }} }},
          tooltip: {{
            callbacks: {{
              label: function(ctx) {{
                const v = ctx.raw;
                const pct = ((v / sys.total_tokens) * 100).toFixed(1);
                return ctx.label + ': ' + (v/1000).toFixed(1) + 'K tokens (' + pct + '%)';
              }}
            }}
          }}
        }}
      }}
    }});
  }} else {{
    document.getElementById('cacheChart').parentElement.innerHTML +=
      '<p style="color:#666; text-align:center; margin-top:2rem;">No cache data yet — populates when Opus re-enabled</p>';
  }}

  document.getElementById('t1-compute-detail').textContent =
    'Total: ' + (sys.total_tokens/1000).toFixed(0) + 'K tokens across ' +
    sys.total_calls + ' calls. API spend: $' + sys.api_spend.toFixed(2) +
    '. Subscription savings: $' + sys.subscription_estimate.toFixed(2) +
    ' (what Max calls would cost at API rates).';
}})
.catch(err => {{
  document.getElementById('t1-compute-detail').textContent = 'Failed to load compute profile: ' + err.message;
}});

</script>
</body></html>"""


# ─── App factory ─────────────────────────────────────────────────────────────

from alerting_routes import register_alerting_routes
from tier1_routes import register_tier1_routes

# 4-page dashboard imports
from dashboard_ops import render_ops_page
from dashboard_health import render_health_page
from dashboard_agents import render_agents_page
from dashboard_epistemic import render_epistemic_page
from dashboard_prs import render_prs_page
from dashboard_routes import register_dashboard_routes
  # requires CWD = deploy dir

def _conn_from_app(app):
    import sqlite3
    conn = app["db"]
    try:
        conn.execute("SELECT 1")
    except sqlite3.Error:
        conn = _get_db()
        app["db"] = conn
    return conn





# ─── 4-page dashboard route handlers ───────────────────────────────────────

async def handle_ops_page(request):
    """GET /ops — Pipeline Operations page."""
    try:
        conn = _conn(request)
        metrics = _current_metrics(conn)
        snapshots = _snapshot_history(conn, days=7)
        changes = _version_changes(conn, days=30)
        vital_signs = _compute_vital_signs(conn)
    except Exception as e:
        return web.Response(text=_render_error(f"Database error: {e}"), content_type="text/html", status=503)
    now = datetime.now(timezone.utc)
    return web.Response(text=render_ops_page(metrics, snapshots, changes, vital_signs, now), content_type="text/html")


async def handle_health_page(request):
    """GET /health — Knowledge Health page."""
    try:
        conn = _conn(request)
        vital_signs = _compute_vital_signs(conn)
        domain_breakdown = _domain_breakdown(conn)
    except Exception as e:
        return web.Response(text=_render_error(f"Database error: {e}"), content_type="text/html", status=503)
    now = datetime.now(timezone.utc)
    return web.Response(text=render_health_page(vital_signs, domain_breakdown, now), content_type="text/html")


async def handle_agents_page(request):
    """GET /agents — Agent Performance page."""
    try:
        conn = _conn(request)
        contributors_principal = _contributor_leaderboard(conn, limit=10, view="principal")
        contributors_agent = _contributor_leaderboard(conn, limit=10, view="agent")
    except Exception as e:
        return web.Response(text=_render_error(f"Database error: {e}"), content_type="text/html", status=503)
    now = datetime.now(timezone.utc)
    return web.Response(text=render_agents_page(contributors_principal, contributors_agent, now), content_type="text/html")


async def handle_epistemic_page(request):
    """GET /epistemic — Epistemic Integrity page."""
    try:
        conn = _conn(request)
        vital_signs = _compute_vital_signs(conn)
    except Exception as e:
        return web.Response(text=_render_error(f"Database error: {e}"), content_type="text/html", status=503)
    now = datetime.now(timezone.utc)
    return web.Response(text=render_epistemic_page(vital_signs, now), content_type="text/html")




async def handle_prs_page(request):
    """GET /prs — PR Lifecycle page."""
    from datetime import datetime, timezone
    now = datetime.now(timezone.utc)
    return web.Response(text=render_prs_page(now), content_type="text/html")

async def handle_root_redirect(request):
    """GET / — redirect to /ops."""
    raise web.HTTPFound("/ops")


def create_app() -> web.Application:
    app = web.Application(middlewares=[auth_middleware])
    app["db"] = _get_db()
    app["api_key"] = _load_secret(API_KEY_FILE)
    canonical_claims_api_key = load_claim_list_api_key()
    if canonical_claims_api_key:
        app[KB_CLAIM_LIST_API_KEY] = canonical_claims_api_key
        logger.info("Canonical claims API auth enabled")
    else:
        logger.warning("Canonical claims API auth is not configured; list endpoint fails closed")
    if app["api_key"]:
        logger.info("API key auth enabled (protected endpoints require X-Api-Key)")
    else:
        logger.info("No global API key configured — canonical claim routes still fail closed")
    # Root redirects to /ops (legacy dashboard still at /legacy)
    app.router.add_get("/", handle_root_redirect)
    app.router.add_get("/prs", handle_prs_page)
    app.router.add_get("/ops", handle_ops_page)
    app.router.add_get("/health", handle_health_page)
    app.router.add_get("/agents", handle_agents_page)
    app.router.add_get("/epistemic", handle_epistemic_page)
    app.router.add_get("/legacy", handle_dashboard)  # keep old dashboard for rollback
    app.router.add_get("/api/metrics", handle_api_metrics)
    app.router.add_get("/api/snapshots", handle_api_snapshots)
    app.router.add_get("/api/vital-signs", handle_api_vital_signs)
    app.router.add_get("/api/contributors", handle_api_contributors)
    app.router.add_get("/api/domains", handle_api_domains)
    app.router.add_get("/api/search", handle_api_search)
    app.router.add_post("/api/search", handle_api_search)
    app.router.add_get("/api/audit", handle_api_audit)
    app.router.add_get("/audit", handle_audit_page)
    app.router.add_post("/api/usage", handle_api_usage)
    # Alerting - active monitoring endpoints
    register_alerting_routes(app, lambda: _conn_from_app(app))
    register_tier1_routes(app, lambda: _conn_from_app(app))
    register_dashboard_routes(app, lambda: _conn_from_app(app))
    register_review_queue_routes(app)
    register_daily_digest_routes(app, db_path=str(DB_PATH))
    register_kb_claim_routes(app)
    register_kb_proposal_routes(app)
    # Portfolio
    from dashboard_portfolio import register_portfolio_routes
    register_portfolio_routes(app, lambda: _conn_from_app(app))
    # Response audit - cost tracking + reasoning traces
    app["db_path"] = str(DB_PATH)
    register_response_audit_routes(app)
    # Event-sourced leaderboard (Phase B — reads contribution_events directly)
    register_leaderboard_routes(app)
    # Timeline activity feed (per-PR + audit_log events for dashboard v2)
    from activity_endpoint import handle_activity
    app.router.add_get("/api/activity", handle_activity)
    # Gamification activity feed (hot/recent/important sort)
    from activity_feed_api import register as register_activity_feed
    register_activity_feed(app)
    # Claims browser + detail
    from claims_api import register_claims_routes
    register_claims_routes(app)
    # Contributor profile (handle lookup, leaderboard with action CI)
    from contributor_profile_api import register_contributor_routes
    register_contributor_routes(app)
    app.on_cleanup.append(_cleanup)
    return app


async def _cleanup(app):
    app["db"].close()


def main():
    logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
    logger.info("Argus diagnostics starting on port %d, DB: %s", PORT, DB_PATH)
    app = create_app()
    web.run_app(app, host="0.0.0.0", port=PORT)


if __name__ == "__main__":
    main()
