"""MyBurn Cloud — server-side routes for auth, push, and public profiles."""
from __future__ import annotations

import hashlib
import json
import logging
import os
import secrets
import sqlite3
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timedelta, timezone
from html import escape as _esc
from pathlib import Path
from typing import Optional

from fastapi import Header, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse

log = logging.getLogger("myburn.cloud")

_TOKEN_EXPIRY_DAYS = 90

# ---------------------------------------------------------------------------
# Cloud DB (separate from usage.db)
# ---------------------------------------------------------------------------
CLOUD_DB_PATH = Path(
    os.environ.get("MYBURN_CLOUD_DB", str(Path.home() / ".myburn" / "data" / "cloud.db"))
)


def _get_cloud_db() -> sqlite3.Connection:
    CLOUD_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
    conn = sqlite3.connect(str(CLOUD_DB_PATH), timeout=10)
    result = conn.execute("PRAGMA journal_mode=WAL").fetchone()
    if result and result[0] != "wal":
        logging.getLogger("myburn").error("WAL mode failed for cloud.db (got '%s')", result[0])
    conn.execute("PRAGMA busy_timeout=5000")
    conn.execute("PRAGMA foreign_keys=ON")
    conn.row_factory = sqlite3.Row
    return conn


def _init_cloud_db():
    conn = _get_cloud_db()
    conn.executescript("""
        CREATE TABLE IF NOT EXISTS users (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            github_id INTEGER UNIQUE NOT NULL,
            username TEXT UNIQUE NOT NULL,
            display_name TEXT,
            avatar_url TEXT,
            api_token TEXT UNIQUE NOT NULL,
            created_at TEXT NOT NULL,
            last_push_at TEXT,
            push_count INTEGER DEFAULT 0
        );

        CREATE TABLE IF NOT EXISTS pushes (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            user_id INTEGER NOT NULL REFERENCES users(id),
            month TEXT NOT NULL,
            payload TEXT NOT NULL,
            cost REAL NOT NULL,
            total_tokens INTEGER NOT NULL,
            sessions INTEGER NOT NULL,
            active_days INTEGER NOT NULL,
            streak INTEGER NOT NULL,
            cli_version TEXT,
            pushed_at TEXT NOT NULL,
            UNIQUE(user_id, month)
        );

        CREATE INDEX IF NOT EXISTS idx_pushes_month ON pushes(month);
    """)

    # Trust infrastructure columns (P2.5 migration)
    for col, coltype in [
        ("payload_hash", "TEXT"),
        ("prev_hash", "TEXT"),
        ("trust_score", "REAL"),
        ("trust_flags", "TEXT DEFAULT '[]'"),
    ]:
        try:
            conn.execute(f"ALTER TABLE pushes ADD COLUMN {col} {coltype}")
        except sqlite3.OperationalError:
            pass  # already exists

    # Token expiration column (FIX-08)
    try:
        conn.execute("ALTER TABLE users ADD COLUMN token_expires_at TEXT")
    except sqlite3.OperationalError:
        pass  # already exists

    # Privacy column on users (Layer 2 loose ends)
    try:
        conn.execute("ALTER TABLE users ADD COLUMN privacy TEXT DEFAULT '{}'")
    except sqlite3.OperationalError:
        pass  # already exists

    # Backfill expiry for legacy users (idempotent — only touches NULL rows)
    default_expiry = (datetime.now(timezone.utc) + timedelta(days=_TOKEN_EXPIRY_DAYS)).strftime("%Y-%m-%dT%H:%M:%SZ")
    conn.execute("UPDATE users SET token_expires_at = ? WHERE token_expires_at IS NULL", (default_expiry,))

    conn.commit()
    conn.close()


# ---------------------------------------------------------------------------
# Token hashing (FIX-02)
# ---------------------------------------------------------------------------
def _hash_token(token: str) -> str:
    """SHA-256 hash of an API token for storage. Never store plaintext."""
    return hashlib.sha256(token.encode()).hexdigest()


# ---------------------------------------------------------------------------
# Auth middleware
# ---------------------------------------------------------------------------
def _get_current_user(authorization: Optional[str] = None) -> dict:
    """Look up Bearer token → return user dict or raise 401."""
    if not authorization or not authorization.startswith("Bearer "):
        raise HTTPException(status_code=401, detail="Missing or invalid Authorization header")

    token = authorization[7:]
    token_hash = _hash_token(token)
    conn = _get_cloud_db()
    try:
        row = conn.execute("SELECT * FROM users WHERE api_token = ?", (token_hash,)).fetchone()
    finally:
        conn.close()

    if not row:
        raise HTTPException(status_code=401, detail="Invalid token")

    # Check token expiry (FIX-08)
    expires_str = row["token_expires_at"]
    if expires_str:
        try:
            expires = datetime.strptime(expires_str, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
            if datetime.now(timezone.utc) > expires:
                raise HTTPException(status_code=401, detail="Token expired — please re-login with 'myburn login'")
        except ValueError:
            raise HTTPException(status_code=401, detail="Malformed token expiry — please re-login with 'myburn login'")

    return dict(row)


# ---------------------------------------------------------------------------
# GitHub token → user profile
# ---------------------------------------------------------------------------
def _fetch_github_user(gh_token: str) -> dict:
    """Call GitHub API to get user profile."""
    req = urllib.request.Request(
        "https://api.github.com/user",
        headers={
            "Authorization": f"Bearer {gh_token}",
            "Accept": "application/vnd.github+json",
            "User-Agent": "MyBurn/1.0",
        },
    )
    try:
        with urllib.request.urlopen(req, timeout=15) as resp:
            return json.loads(resp.read())
    except urllib.error.HTTPError as e:
        log.warning("GitHub API error: %s", e)
        raise HTTPException(status_code=401, detail="Invalid GitHub token")


# ---------------------------------------------------------------------------
# Payload validation (Tier 1)
# ---------------------------------------------------------------------------
_REQUIRED_FIELDS = {"version", "month", "cost", "tokens", "sessions", "activeDays", "streak"}


def _validate_payload(payload: dict) -> str | None:
    """Return error message if invalid, None if OK."""
    # Required fields
    missing = _REQUIRED_FIELDS - set(payload.keys())
    if missing:
        return f"Missing fields: {', '.join(sorted(missing))}"

    # Month format
    month = payload.get("month", "")
    if not isinstance(month, str) or len(month) != 7:
        return "Invalid month format (expected YYYY-MM)"
    try:
        datetime.strptime(month, "%Y-%m")
    except ValueError:
        return "Invalid month format (expected YYYY-MM)"

    # Cost
    cost = payload.get("cost")
    if not isinstance(cost, (int, float)) or cost < 0:
        return "cost must be >= 0"

    # Sanity limits
    if cost > 15_000:
        return "Monthly cost exceeds $15,000 sanity limit"

    # Check daily costs if present
    for day in payload.get("days", []):
        if isinstance(day, dict) and day.get("cost", 0) > 500:
            return f"Daily cost ${day['cost']} exceeds $500 sanity limit"

    # Tokens
    tokens = payload.get("tokens", {})
    if isinstance(tokens, dict):
        total = tokens.get("total", 0)
        if not isinstance(total, (int, float)) or total < 0:
            return "tokens.total must be >= 0"
    elif isinstance(tokens, (int, float)):
        if tokens < 0:
            return "tokens must be >= 0"

    # Array size limits (FIX-26)
    models = payload.get("models")
    if isinstance(models, list) and len(models) > 100:
        return f"models array too large ({len(models)} > 100)"
    days = payload.get("days")
    if isinstance(days, list) and len(days) > 366:
        return f"days array too large ({len(days)} > 366)"
    tools = payload.get("tools")
    if isinstance(tools, dict) and len(tools) > 500:
        return f"tools dict too large ({len(tools)} > 500)"

    return None


# ---------------------------------------------------------------------------
# Leaderboard helpers
# ---------------------------------------------------------------------------
_METRIC_COL = {
    "cost": "cost",
    "tokens": "total_tokens",
    "sessions": "sessions",
    "active_days": "active_days",
    "streak": "streak",
}
_METRIC_AGG = {"streak": "MAX"}  # default is SUM

_METRIC_LABELS = {
    "cost": "Burn",
    "tokens": "Tokens",
    "sessions": "Sessions",
    "active_days": "Active Days",
    "streak": "Streak",
}


def _resolve_month(period: str) -> str | None:
    """Return YYYY-MM string for period, or None for alltime."""
    now = datetime.now(timezone.utc)
    if period == "current":
        return now.strftime("%Y-%m")
    elif period == "last":
        if now.month == 1:
            return f"{now.year - 1}-12"
        return f"{now.year}-{now.month - 1:02d}"
    return None  # alltime


def _query_leaderboard(conn, metric: str, period: str, limit: int = 50) -> tuple:
    """Shared leaderboard query — returns (entries, total_users, period_label).
    Extracts ranking SQL used by both API and HTML endpoints (FIX-21)."""
    col = _METRIC_COL[metric]
    agg = _METRIC_AGG.get(metric, "SUM")
    month = _resolve_month(period)

    if month:
        rows = conn.execute(
            f"SELECT u.username, u.display_name, u.avatar_url, p.{col} AS value "
            "FROM pushes p JOIN users u ON u.id = p.user_id "
            "WHERE p.month = ? "
            f"ORDER BY p.{col} DESC, u.username ASC "
            "LIMIT ?",
            (month, limit),
        ).fetchall()
        total_users = conn.execute(
            "SELECT COUNT(DISTINCT user_id) FROM pushes WHERE month = ?",
            (month,),
        ).fetchone()[0]
    else:
        rows = conn.execute(
            f"SELECT u.username, u.display_name, u.avatar_url, {agg}(p.{col}) AS value "
            "FROM pushes p JOIN users u ON u.id = p.user_id "
            "GROUP BY p.user_id "
            "ORDER BY value DESC, u.username ASC "
            "LIMIT ?",
            (limit,),
        ).fetchall()
        total_users = conn.execute(
            "SELECT COUNT(DISTINCT user_id) FROM pushes",
        ).fetchone()[0]

    entries = []
    for i, row in enumerate(rows, 1):
        pct = round((1 - (i - 1) / total_users) * 100, 1) if total_users > 0 else 0
        entries.append({
            "rank": i,
            "username": row["username"],
            "display_name": row["display_name"],
            "avatar_url": row["avatar_url"],
            "value": row["value"],
            "percentile": pct,
        })

    if month:
        try:
            dt = datetime.strptime(month, "%Y-%m")
            period_label = dt.strftime("%B %Y")
        except ValueError:
            period_label = month
    else:
        period_label = "All Time"

    return entries, total_users, period_label


def _fmt_leaderboard_value(value, metric: str) -> str:
    """Format a leaderboard value for display."""
    if metric == "cost":
        return f"${value:,.0f}" if value >= 1 else f"${value:,.2f}"
    if metric == "tokens":
        return _fmt_tokens_html(int(value))
    if metric == "streak":
        return f"{int(value)}d"
    return str(int(value))


# ---------------------------------------------------------------------------
# Leaderboard HTML
# ---------------------------------------------------------------------------
def _render_leaderboard_html(
    entries: list[dict],
    total_users: int,
    metric: str,
    period: str,
    period_label: str,
    you: dict | None = None,
) -> str:
    """Render leaderboard page — inline HTML, zero JS, tab navigation via <a> links."""

    # --- Period tabs ---
    period_tabs = ""
    for p_key, p_label in [("current", "This Month"), ("last", "Last Month"), ("alltime", "All Time")]:
        active = ' class="tab active"' if p_key == period else ' class="tab"'
        period_tabs += f'<a href="/leaderboard?metric={metric}&period={p_key}"{active}>{p_label}</a>\n'

    # --- Metric tabs ---
    metric_icons = {"cost": "&#128293;", "tokens": "&#128202;", "sessions": "&#9889;", "active_days": "&#128197;", "streak": "&#128293;"}
    metric_tabs = ""
    for m_key, m_label in _METRIC_LABELS.items():
        active = ' class="tab active"' if m_key == metric else ' class="tab"'
        icon = metric_icons.get(m_key, "")
        metric_tabs += f'<a href="/leaderboard?metric={m_key}&period={period}"{active}>{icon} {m_label}</a>\n'

    # --- Podium (top 3) ---
    podium_html = ""
    if entries:
        podium_colors = ["#FFD700", "#C0C0C0", "#CD7F32"]
        podium_sizes = [64, 48, 48]
        podium_html = '<div class="podium">\n'
        for i, entry in enumerate(entries[:3]):
            color = podium_colors[i]
            size = podium_sizes[i]
            e_user = _esc(entry["username"])
            e_avatar = _esc(entry.get("avatar_url") or "", quote=True)
            e_name = _esc(entry["display_name"] or entry["username"])
            avatar = f'<img class="podium-avatar" src="{e_avatar}" width="{size}" height="{size}" alt="{e_user}">' if entry.get("avatar_url") else ""
            val = _fmt_leaderboard_value(entry["value"], metric)
            podium_html += f"""
            <div class="podium-entry podium-{i + 1}">
                <div class="podium-rank" style="color: {color}">#{entry['rank']}</div>
                {avatar}
                <div class="podium-name">{e_name}</div>
                <div class="podium-handle">@{e_user}</div>
                <div class="podium-value" style="color: {color}">{val}</div>
                <div class="podium-pct">Top {entry['percentile']}%</div>
            </div>"""
        podium_html += "\n</div>"

    # --- Table (ranks 4+) ---
    table_html = ""
    rest = entries[3:]
    if rest:
        table_html = """
        <table>
            <thead><tr>
                <th>#</th><th></th><th>User</th><th>Value</th><th>Percentile</th>
            </tr></thead>
            <tbody>"""
        for entry in rest:
            e_user = _esc(entry["username"])
            e_avatar = _esc(entry.get("avatar_url") or "", quote=True)
            avatar = f'<img class="table-avatar" src="{e_avatar}" width="32" height="32" alt="">' if entry.get("avatar_url") else ""
            val = _fmt_leaderboard_value(entry["value"], metric)
            table_html += f"""
                <tr>
                    <td class="rank-col">{entry['rank']}</td>
                    <td class="avatar-col">{avatar}</td>
                    <td>@{e_user}</td>
                    <td class="value-col">{val}</td>
                    <td class="pct-col">Top {entry['percentile']}%</td>
                </tr>"""
        table_html += "\n</tbody></table>"

    # --- Your rank ---
    you_html = ""
    if you:
        you_val = _fmt_leaderboard_value(you["value"], metric)
        you_html = f"""
        <div class="your-rank">
            You're <strong>#{you['rank']}</strong> &mdash; Top {you['percentile']}% ({you_val})
        </div>"""

    # --- Empty state ---
    empty_html = ""
    if not entries:
        empty_html = '<div class="empty-state">No pushes yet. Be the first &mdash; <code>myburn push</code></div>'

    # --- OG description ---
    if entries:
        top = entries[0]
        top_val = _fmt_leaderboard_value(top["value"], metric)
        og_desc = _esc(f"{total_users} devs ranked. #1 burned {top_val}. Where do you rank?", quote=True)
    else:
        og_desc = "No pushes yet. Be the first!"

    # --- Share text ---
    lb_share_lines = [f"MyBurn Leaderboard — {period_label} 🏆"]
    medals = ["🥇", "🥈", "🥉"]
    for i, entry in enumerate(entries[:3]):
        val = _fmt_leaderboard_value(entry["value"], metric)
        lb_share_lines.append(f"{medals[i]} @{entry['username']} — {val}")
    lb_share_lines.append("Where do you rank? myburn.cc/leaderboard")
    lb_share_lines.append("#ClaudeCode #MyBurn")
    lb_share_text = _esc("\n".join(lb_share_lines), quote=True)

    e_period_label = _esc(period_label, quote=True)

    return f"""<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>MyBurn Leaderboard &mdash; Top Burners {e_period_label}</title>
    <meta property="og:title" content="MyBurn Leaderboard &mdash; Top Burners {e_period_label}">
    <meta property="og:description" content="{og_desc}">
    <meta property="og:url" content="https://myburn.cc/leaderboard?metric={metric}&period={period}">
    <meta name="twitter:card" content="summary">
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
    <style>
        * {{ margin: 0; padding: 0; box-sizing: border-box; }}
        :root {{
            --bg-primary: #191613;
            --bg-secondary: #211e1a;
            --bg-tertiary: #2a2621;
            --bg-hover: #332e28;
            --border: rgba(255, 255, 255, 0.07);
            --text-primary: #ede8e3;
            --text-secondary: #a09689;
            --text-tertiary: #6d6358;
            --accent: #e8632b;
            --accent-dim: rgba(232, 99, 43, 0.15);
            --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
            --font-mono: 'JetBrains Mono', 'Fira Code', monospace;
            --radius: 12px;
        }}
        body {{
            font-family: var(--font-sans);
            background: var(--bg-primary);
            color: var(--text-primary);
            min-height: 100vh;
        }}
        .container {{
            max-width: 800px;
            margin: 0 auto;
            padding: 40px 24px;
        }}
        h1 {{
            font-size: 28px;
            font-weight: 800;
            text-align: center;
            margin-bottom: 4px;
        }}
        .subtitle {{
            text-align: center;
            color: var(--text-secondary);
            font-size: 16px;
            margin-bottom: 24px;
        }}
        .tabs {{
            display: flex;
            gap: 8px;
            justify-content: center;
            margin-bottom: 12px;
            flex-wrap: wrap;
        }}
        .tab {{
            display: inline-block;
            padding: 8px 16px;
            border-radius: 8px;
            text-decoration: none;
            color: var(--text-secondary);
            background: var(--bg-secondary);
            border: 1px solid var(--border);
            font-size: 14px;
            font-weight: 500;
            transition: background 0.15s;
        }}
        .tab:hover {{
            background: var(--bg-hover);
            color: var(--text-primary);
        }}
        .tab.active {{
            background: var(--accent-dim);
            color: var(--accent);
            border-color: var(--accent);
        }}
        .podium {{
            display: flex;
            justify-content: center;
            align-items: flex-end;
            gap: 16px;
            margin: 32px 0;
        }}
        .podium-entry {{
            text-align: center;
            background: var(--bg-secondary);
            border: 1px solid var(--border);
            border-radius: var(--radius);
            padding: 20px 16px;
            min-width: 160px;
        }}
        .podium-1 {{
            order: 2;
            transform: scale(1.05);
        }}
        .podium-2 {{
            order: 1;
        }}
        .podium-3 {{
            order: 3;
        }}
        .podium-rank {{
            font-size: 24px;
            font-weight: 800;
            margin-bottom: 8px;
        }}
        .podium-avatar {{
            border-radius: 50%;
            border: 2px solid var(--border);
            margin-bottom: 8px;
        }}
        .podium-name {{
            font-size: 16px;
            font-weight: 600;
        }}
        .podium-handle {{
            font-size: 13px;
            color: var(--text-secondary);
            margin-bottom: 8px;
        }}
        .podium-value {{
            font-size: 24px;
            font-weight: 700;
            font-family: var(--font-mono);
        }}
        .podium-pct {{
            font-size: 12px;
            color: var(--text-tertiary);
            margin-top: 4px;
        }}
        table {{
            width: 100%;
            border-collapse: collapse;
            margin-top: 24px;
        }}
        th {{
            text-align: left;
            padding: 10px 12px;
            font-size: 11px;
            text-transform: uppercase;
            letter-spacing: 0.05em;
            color: var(--text-tertiary);
            border-bottom: 1px solid var(--border);
        }}
        td {{
            padding: 10px 12px;
            font-size: 14px;
            border-bottom: 1px solid var(--border);
        }}
        .rank-col {{
            font-weight: 700;
            font-family: var(--font-mono);
            color: var(--text-secondary);
            width: 40px;
        }}
        .avatar-col {{
            width: 40px;
        }}
        .table-avatar {{
            border-radius: 50%;
            vertical-align: middle;
        }}
        .value-col {{
            font-family: var(--font-mono);
            font-weight: 600;
            color: var(--accent);
        }}
        .pct-col {{
            color: var(--text-secondary);
            font-size: 13px;
        }}
        .your-rank {{
            text-align: center;
            margin-top: 24px;
            padding: 16px;
            background: var(--accent-dim);
            border: 1px solid var(--accent);
            border-radius: var(--radius);
            font-size: 16px;
        }}
        .empty-state {{
            text-align: center;
            color: var(--text-tertiary);
            padding: 60px 0;
            font-size: 18px;
        }}
        .empty-state code {{
            background: var(--bg-tertiary);
            padding: 4px 8px;
            border-radius: 4px;
            font-family: var(--font-mono);
        }}
        .actions {{
            text-align: center;
            margin: 32px 0;
            display: flex;
            justify-content: center;
            gap: 12px;
            flex-wrap: wrap;
        }}
        .share-btn {{
            display: inline-flex;
            align-items: center;
            gap: 6px;
            padding: 10px 20px;
            background: var(--bg-secondary);
            border: 1px solid var(--border);
            border-radius: var(--radius);
            color: var(--text-primary);
            font-family: var(--font-sans);
            font-size: 14px;
            font-weight: 500;
            cursor: pointer;
            text-decoration: none;
            transition: background 0.2s;
        }}
        .share-btn:hover {{
            background: var(--bg-hover);
        }}
        .footer {{
            margin-top: 48px;
            text-align: center;
            color: var(--text-tertiary);
            font-size: 13px;
        }}
        .footer a {{
            color: var(--accent);
            text-decoration: none;
        }}
        @media (max-width: 600px) {{
            .podium {{
                flex-direction: column;
                align-items: center;
            }}
            .podium-1, .podium-2, .podium-3 {{
                order: unset;
                transform: none;
            }}
            .podium-entry {{
                min-width: unset;
                width: 100%;
                max-width: 280px;
            }}
        }}
    </style>
</head>
<body>
    <div class="container">
        <h1>&#127942; MyBurn Leaderboard</h1>
        <div class="subtitle">Top Burners &mdash; {period_label}</div>

        <div class="tabs">
            {period_tabs}
        </div>
        <div class="tabs">
            {metric_tabs}
        </div>

        {podium_html}
        {table_html}
        {you_html}
        {empty_html}

        <div class="actions">
            <a class="share-btn" href="https://x.com/intent/tweet?text={lb_share_text}" target="_blank" rel="noopener">&#120143; Share Leaderboard</a>
        </div>

        <div class="footer">
            {total_users} burner{'s' if total_users != 1 else ''} ranked &bull;
            <a href="https://myburn.cc">myburn.cc</a>
        </div>
    </div>
</body>
</html>"""


# ---------------------------------------------------------------------------
# Profile HTML
# ---------------------------------------------------------------------------
_VALID_PRIVACY_KEYS = {"hideCost", "hideTokens", "hideModels", "hideDays"}


def _parse_privacy(user: dict) -> dict:
    """Parse the privacy JSON from a user dict. Returns validated dict."""
    raw = user.get("privacy") or "{}"
    try:
        priv = json.loads(raw) if isinstance(raw, str) else raw
    except (json.JSONDecodeError, TypeError):
        priv = {}
    return {k: bool(v) for k, v in priv.items() if k in _VALID_PRIVACY_KEYS}


def _apply_privacy_to_push(push: dict, privacy: dict) -> dict:
    """Redact fields from a push dict based on privacy settings. Returns new dict."""
    p = dict(push)
    if privacy.get("hideCost"):
        p["cost"] = None
    if privacy.get("hideTokens"):
        p["total_tokens"] = None
    if privacy.get("hideDays"):
        p["active_days"] = None
        p["streak"] = None
    return p


def _render_profile_html(user: dict, pushes: list[dict], privacy: dict | None = None) -> str:
    """Render a public profile page — inline HTML, no template engine."""
    if privacy is None:
        privacy = {}
    username = _esc(user["username"])
    display_name = _esc(user["display_name"] or user["username"])
    avatar_url = _esc(user["avatar_url"] or "", quote=True)

    _hidden = '<span style="color:var(--text-tertiary)">Hidden</span>'

    # Aggregate stats across all pushed months (handling redacted None values)
    total_cost = sum(p["cost"] for p in pushes if p.get("cost") is not None)
    total_tokens = sum(p["total_tokens"] for p in pushes if p.get("total_tokens") is not None)
    total_sessions = sum(p["sessions"] for p in pushes)
    total_active_days = sum(p["active_days"] for p in pushes if p.get("active_days") is not None)
    best_streak = max((p["streak"] for p in pushes if p.get("streak") is not None), default=0)
    months_count = len(pushes)

    # Monthly breakdown rows
    month_rows = ""
    for p in sorted(pushes, key=lambda x: x["month"], reverse=True):
        payload_raw = p.get("payload")
        payload = {}
        if payload_raw:
            payload = json.loads(payload_raw) if isinstance(payload_raw, str) else payload_raw

        models_html = ""
        if not privacy.get("hideModels"):
            for m in payload.get("models", [])[:3]:
                name = _esc(m["name"].replace("claude-", "").split("-2025")[0].split("-2026")[0])
                models_html += f'<span class="model-tag">{name}</span>'
        else:
            models_html = _hidden

        cost_html = f"${p['cost']:,.2f}" if p.get("cost") is not None else _hidden
        tokens_html = _fmt_tokens_html(p["total_tokens"]) if p.get("total_tokens") is not None else _hidden
        days_html = f"{p['active_days']}d / {p['streak']}d streak" if p.get("active_days") is not None else _hidden

        month_rows += f"""
        <tr>
            <td>{p['month']}</td>
            <td>{cost_html}</td>
            <td>{tokens_html}</td>
            <td>{p['sessions']}</td>
            <td>{days_html}</td>
            <td>{models_html}</td>
        </tr>"""

    # OG description
    og_desc = _esc(f"${total_cost:,.0f} burned, {_fmt_tokens_html(total_tokens)} tokens across {months_count} month{'s' if months_count != 1 else ''}", quote=True)

    # Share text
    _share_parts = [f"@{username} on MyBurn 🔥"]
    if not privacy.get("hideCost"):
        _share_parts.append(f"🔥 ${total_cost:,.0f} burned")
    if not privacy.get("hideTokens"):
        _share_parts.append(f"✴ {_fmt_tokens_html(total_tokens)} tokens")
    _share_parts.append(f"⚡ {total_sessions} sessions")
    _share_parts.append(f"https://myburn.cc/u/{username}")
    _share_parts.append("#ClaudeCode #MyBurn")
    profile_share_text = _esc("\n".join(_share_parts), quote=True)

    return f"""<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>@{username} — MyBurn</title>
    <meta property="og:title" content="@{username} — MyBurn Profile">
    <meta property="og:description" content="{og_desc}">
    <meta property="og:image" content="{avatar_url}">
    <meta property="og:url" content="https://myburn.cc/u/{username}">
    <meta name="twitter:card" content="summary">
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
    <style>
        * {{ margin: 0; padding: 0; box-sizing: border-box; }}
        :root {{
            --bg-primary: #191613;
            --bg-secondary: #211e1a;
            --bg-tertiary: #2a2621;
            --bg-hover: #332e28;
            --border: rgba(255, 255, 255, 0.07);
            --text-primary: #ede8e3;
            --text-secondary: #a09689;
            --text-tertiary: #6d6358;
            --accent: #e8632b;
            --accent-dim: rgba(232, 99, 43, 0.15);
            --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
            --font-mono: 'JetBrains Mono', 'Fira Code', monospace;
            --radius: 12px;
        }}
        body {{
            font-family: var(--font-sans);
            background: var(--bg-primary);
            color: var(--text-primary);
            min-height: 100vh;
        }}
        .container {{
            max-width: 800px;
            margin: 0 auto;
            padding: 40px 24px;
        }}
        .profile-header {{
            display: flex;
            align-items: center;
            gap: 20px;
            margin-bottom: 32px;
        }}
        .avatar {{
            width: 80px;
            height: 80px;
            border-radius: 50%;
            border: 2px solid var(--border);
        }}
        .profile-info h1 {{
            font-size: 24px;
            font-weight: 700;
        }}
        .profile-info .handle {{
            color: var(--text-secondary);
            font-size: 16px;
        }}
        .stats-grid {{
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
            gap: 12px;
            margin-bottom: 32px;
        }}
        .stat-card {{
            background: var(--bg-secondary);
            border: 1px solid var(--border);
            border-radius: var(--radius);
            padding: 16px;
            text-align: center;
        }}
        .stat-value {{
            font-size: 28px;
            font-weight: 700;
            font-family: var(--font-mono);
            color: var(--accent);
        }}
        .stat-label {{
            font-size: 12px;
            color: var(--text-secondary);
            text-transform: uppercase;
            letter-spacing: 0.05em;
            margin-top: 4px;
        }}
        table {{
            width: 100%;
            border-collapse: collapse;
            margin-top: 8px;
        }}
        th {{
            text-align: left;
            padding: 10px 12px;
            font-size: 11px;
            text-transform: uppercase;
            letter-spacing: 0.05em;
            color: var(--text-tertiary);
            border-bottom: 1px solid var(--border);
        }}
        td {{
            padding: 10px 12px;
            font-size: 14px;
            border-bottom: 1px solid var(--border);
            font-family: var(--font-mono);
            font-size: 13px;
        }}
        .model-tag {{
            display: inline-block;
            background: var(--accent-dim);
            color: var(--accent);
            padding: 2px 8px;
            border-radius: 4px;
            font-size: 11px;
            font-family: var(--font-mono);
            margin-right: 4px;
        }}
        .section-title {{
            font-size: 14px;
            font-weight: 600;
            color: var(--text-secondary);
            text-transform: uppercase;
            letter-spacing: 0.05em;
            margin-bottom: 12px;
        }}
        .footer {{
            margin-top: 48px;
            text-align: center;
            color: var(--text-tertiary);
            font-size: 13px;
        }}
        .footer a {{
            color: var(--accent);
            text-decoration: none;
        }}
        .no-data {{
            text-align: center;
            color: var(--text-tertiary);
            padding: 40px 0;
        }}
        .actions {{
            text-align: center;
            margin: 32px 0;
            display: flex;
            justify-content: center;
            gap: 12px;
            flex-wrap: wrap;
        }}
        .share-btn {{
            display: inline-flex;
            align-items: center;
            gap: 6px;
            padding: 10px 20px;
            background: var(--bg-secondary);
            border: 1px solid var(--border);
            border-radius: var(--radius);
            color: var(--text-primary);
            font-family: var(--font-sans);
            font-size: 14px;
            font-weight: 500;
            cursor: pointer;
            text-decoration: none;
            transition: background 0.2s;
        }}
        .share-btn:hover {{
            background: var(--bg-hover);
        }}
    </style>
</head>
<body>
    <div class="container">
        <div class="profile-header">
            {'<img class="avatar" src="' + avatar_url + '" alt="' + username + '">' if avatar_url else ''}
            <div class="profile-info">
                <h1>{display_name}</h1>
                <div class="handle">@{username}</div>
            </div>
        </div>

        <div class="stats-grid">
            <div class="stat-card">
                <div class="stat-value">{"Hidden" if privacy.get("hideCost") else f"${total_cost:,.0f}"}</div>
                <div class="stat-label">Total Burn</div>
            </div>
            <div class="stat-card">
                <div class="stat-value">{"Hidden" if privacy.get("hideTokens") else _fmt_tokens_html(total_tokens)}</div>
                <div class="stat-label">Tokens</div>
            </div>
            <div class="stat-card">
                <div class="stat-value">{total_sessions}</div>
                <div class="stat-label">Sessions</div>
            </div>
            <div class="stat-card">
                <div class="stat-value">{"Hidden" if privacy.get("hideDays") else total_active_days}</div>
                <div class="stat-label">Active Days</div>
            </div>
            <div class="stat-card">
                <div class="stat-value">{"Hidden" if privacy.get("hideDays") else f"{best_streak}d"}</div>
                <div class="stat-label">Best Streak</div>
            </div>
            <div class="stat-card">
                <div class="stat-value">{months_count}</div>
                <div class="stat-label">Months</div>
            </div>
        </div>

        <div class="section-title">Monthly Breakdown</div>
        {"<table><thead><tr><th>Month</th><th>Burn</th><th>Tokens</th><th>Sessions</th><th>Activity</th><th>Models</th></tr></thead><tbody>" + month_rows + "</tbody></table>" if pushes else '<div class="no-data">No stats published yet.</div>'}

        <div class="actions">
            <a class="share-btn" href="https://x.com/intent/tweet?text={profile_share_text}" target="_blank" rel="noopener">&#120143; Share Profile</a>
            <a class="share-btn" href="/leaderboard">&#127942; Leaderboard</a>
        </div>

        <div class="footer">
            &#10036; <a href="https://myburn.cc">myburn.cc</a> &mdash; Claude Code Usage Dashboard
        </div>
    </div>
</body>
</html>"""


def _fmt_tokens_html(n: int) -> str:
    if n >= 1_000_000_000:
        return f"{n / 1_000_000_000:.2f}B"
    if n >= 1_000_000:
        return f"{n / 1_000_000:.2f}M"
    if n >= 1_000:
        return f"{n / 1_000:.2f}K"
    return str(n)


# ---------------------------------------------------------------------------
# Compare HTML
# ---------------------------------------------------------------------------
def _aggregate_user_stats(pushes: list[dict], privacy: dict) -> dict:
    """Aggregate push data into summary stats, respecting privacy."""
    cost = sum(p["cost"] for p in pushes if p.get("cost") is not None)
    tokens = sum(p["total_tokens"] for p in pushes if p.get("total_tokens") is not None)
    sessions = sum(p["sessions"] for p in pushes)
    active_days = sum(p["active_days"] for p in pushes if p.get("active_days") is not None)
    streak = max((p["streak"] for p in pushes if p.get("streak") is not None), default=0)
    months = len(pushes)
    return {
        "cost": None if privacy.get("hideCost") else cost,
        "tokens": None if privacy.get("hideTokens") else tokens,
        "sessions": sessions,
        "active_days": None if privacy.get("hideDays") else active_days,
        "streak": None if privacy.get("hideDays") else streak,
        "months": months,
    }


def _compare_verdict(u1_name: str, u2_name: str, s1: dict, s2: dict) -> str:
    """Generate humorous verdict text. Returns empty string if cost hidden."""
    c1, c2 = s1["cost"], s2["cost"]
    if c1 is None or c2 is None:
        return ""
    if c1 == 0 and c2 == 0:
        return "Neither has burned a cent. Rookies."
    if c2 == 0:
        return f"{u1_name} burns while {u2_name} watches."
    if c1 == 0:
        return f"{u2_name} burns while {u1_name} watches."
    ratio = c1 / c2 if c1 >= c2 else c2 / c1
    winner = u1_name if c1 >= c2 else u2_name
    if ratio >= 5:
        return f"{winner} is on a completely different level."
    if ratio >= 2:
        return f"{winner} burns {ratio:.1f}x harder."
    if ratio >= 1.2:
        return f"{winner} edges ahead — {ratio:.1f}x."
    return "Dead heat. Respect."


def _render_compare_html(
    user1: dict, pushes1: list[dict], privacy1: dict,
    user2: dict, pushes2: list[dict], privacy2: dict,
) -> str:
    """Render side-by-side comparison page — inline HTML, no template engine."""
    u1 = _esc(user1["username"])
    u2 = _esc(user2["username"])
    dn1 = _esc(user1["display_name"] or user1["username"])
    dn2 = _esc(user2["display_name"] or user2["username"])
    av1 = _esc(user1["avatar_url"] or "", quote=True)
    av2 = _esc(user2["avatar_url"] or "", quote=True)

    s1 = _aggregate_user_stats(pushes1, privacy1)
    s2 = _aggregate_user_stats(pushes2, privacy2)

    verdict = _compare_verdict(u1, u2, s1, s2)
    verdict_html = f'<div class="verdict">{_esc(verdict)}</div>' if verdict else ""

    # OG tags
    og_desc_parts = []
    if s1["cost"] is not None and s2["cost"] is not None:
        og_desc_parts.append(f"${s1['cost']:,.0f} vs ${s2['cost']:,.0f}")
        if verdict:
            og_desc_parts.append(verdict)
    else:
        og_desc_parts.append("Head-to-head burn comparison")
    og_desc = _esc(" — ".join(og_desc_parts), quote=True)

    # Share text
    share_parts = [f"@{u1} vs @{u2} — Who Burned More? 🔥"]
    if s1["cost"] is not None and s2["cost"] is not None:
        share_parts.append(f"${s1['cost']:,.0f} vs ${s2['cost']:,.0f}")
    if verdict:
        share_parts.append(verdict)
    share_parts.append(f"https://myburn.cc/compare/{u1}/{u2}")
    share_parts.append("#ClaudeCode #MyBurn")
    share_text = _esc("\n".join(share_parts), quote=True)

    def _metric_row(label: str, v1, v2, fmt=str, hidden_if_none=True):
        """Build a single comparison row. Handles None (privacy-hidden) values."""
        hid = '<span class="hidden-val">Hidden</span>'
        if v1 is None or v2 is None:
            f1 = hid if v1 is None else fmt(v1)
            f2 = hid if v2 is None else fmt(v2)
            arrow = ""
        else:
            f1, f2 = fmt(v1), fmt(v2)
            if v1 > v2:
                arrow = '<span class="arrow win">&#9650;</span>'
                f1 = f'<span class="winner">{f1}</span>'
            elif v2 > v1:
                arrow = '<span class="arrow win">&#9660;</span>'
                f2 = f'<span class="winner">{f2}</span>'
            else:
                arrow = '<span class="arrow tie">=</span>'
        return f"""
        <tr>
            <td class="val-cell">{f1}</td>
            <td class="label-cell">{label} {arrow}</td>
            <td class="val-cell">{f2}</td>
        </tr>"""

    fmt_cost = lambda v: f"${v:,.0f}"
    fmt_tok = lambda v: _fmt_tokens_html(v)
    fmt_int = lambda v: str(int(v))
    fmt_streak = lambda v: f"{int(v)}d"

    rows = "".join([
        _metric_row("Total Burn", s1["cost"], s2["cost"], fmt_cost),
        _metric_row("Tokens", s1["tokens"], s2["tokens"], fmt_tok),
        _metric_row("Sessions", s1["sessions"], s2["sessions"], fmt_int),
        _metric_row("Active Days", s1["active_days"], s2["active_days"], fmt_int),
        _metric_row("Best Streak", s1["streak"], s2["streak"], fmt_streak),
        _metric_row("Months Active", s1["months"], s2["months"], fmt_int),
    ])

    return f"""<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>@{u1} vs @{u2} — MyBurn</title>
    <meta property="og:title" content="@{u1} vs @{u2} — Who Burned More?">
    <meta property="og:description" content="{og_desc}">
    <meta property="og:url" content="https://myburn.cc/compare/{u1}/{u2}">
    <meta name="twitter:card" content="summary">
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
    <style>
        * {{ margin: 0; padding: 0; box-sizing: border-box; }}
        :root {{
            --bg-primary: #191613;
            --bg-secondary: #211e1a;
            --bg-tertiary: #2a2621;
            --bg-hover: #332e28;
            --border: rgba(255, 255, 255, 0.07);
            --text-primary: #ede8e3;
            --text-secondary: #a09689;
            --text-tertiary: #6d6358;
            --accent: #e8632b;
            --accent-dim: rgba(232, 99, 43, 0.15);
            --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
            --font-mono: 'JetBrains Mono', 'Fira Code', monospace;
            --radius: 12px;
        }}
        body {{
            font-family: var(--font-sans);
            background: var(--bg-primary);
            color: var(--text-primary);
            min-height: 100vh;
        }}
        .container {{
            max-width: 800px;
            margin: 0 auto;
            padding: 40px 24px;
        }}
        h1 {{
            text-align: center;
            font-size: 32px;
            font-weight: 800;
            margin-bottom: 8px;
        }}
        .verdict {{
            text-align: center;
            color: var(--accent);
            font-size: 18px;
            font-weight: 600;
            margin-bottom: 32px;
        }}
        .players {{
            display: flex;
            justify-content: space-around;
            align-items: center;
            margin-bottom: 32px;
            gap: 16px;
        }}
        .player {{
            display: flex;
            flex-direction: column;
            align-items: center;
            gap: 8px;
        }}
        .player-avatar {{
            width: 64px;
            height: 64px;
            border-radius: 50%;
            border: 2px solid var(--border);
        }}
        .player-name {{
            font-weight: 700;
            font-size: 18px;
        }}
        .player-handle {{
            color: var(--text-secondary);
            font-size: 14px;
        }}
        .vs {{
            font-size: 24px;
            font-weight: 800;
            color: var(--text-tertiary);
        }}
        table {{
            width: 100%;
            border-collapse: collapse;
            margin-bottom: 32px;
        }}
        tr {{
            border-bottom: 1px solid var(--border);
        }}
        td {{
            padding: 14px 8px;
            font-size: 15px;
        }}
        .val-cell {{
            font-family: var(--font-mono);
            font-size: 18px;
            font-weight: 600;
            width: 35%;
        }}
        tr td:first-child {{
            text-align: right;
        }}
        tr td:last-child {{
            text-align: left;
        }}
        .label-cell {{
            text-align: center;
            color: var(--text-secondary);
            font-size: 12px;
            text-transform: uppercase;
            letter-spacing: 0.05em;
            width: 30%;
        }}
        .winner {{
            color: var(--accent);
        }}
        .arrow {{
            font-size: 10px;
            vertical-align: middle;
        }}
        .arrow.win {{
            color: var(--accent);
        }}
        .arrow.tie {{
            color: var(--text-tertiary);
        }}
        .hidden-val {{
            color: var(--text-tertiary);
        }}
        .share-btn {{
            display: inline-flex;
            align-items: center;
            gap: 6px;
            padding: 10px 20px;
            background: var(--bg-secondary);
            border: 1px solid var(--border);
            border-radius: var(--radius);
            color: var(--text-primary);
            font-family: var(--font-sans);
            font-size: 14px;
            font-weight: 500;
            cursor: pointer;
            text-decoration: none;
            transition: background 0.2s;
        }}
        .share-btn:hover {{
            background: var(--bg-hover);
        }}
        .actions {{
            text-align: center;
            margin-bottom: 32px;
            display: flex;
            justify-content: center;
            gap: 12px;
            flex-wrap: wrap;
        }}
        .footer {{
            margin-top: 48px;
            text-align: center;
            color: var(--text-tertiary);
            font-size: 13px;
        }}
        .footer a {{
            color: var(--accent);
            text-decoration: none;
        }}
        @media (max-width: 600px) {{
            .players {{
                flex-direction: column;
            }}
            .val-cell {{
                font-size: 15px;
            }}
            h1 {{
                font-size: 24px;
            }}
        }}
    </style>
</head>
<body>
    <div class="container">
        <h1>&#128293; {dn1} vs {dn2}</h1>
        {verdict_html}

        <div class="players">
            <div class="player">
                <img class="player-avatar" src="{av1}" alt="{u1}">
                <div class="player-name">{dn1}</div>
                <div class="player-handle"><a href="/u/{u1}" style="color:var(--text-secondary);text-decoration:none">@{u1}</a></div>
            </div>
            <div class="vs">VS</div>
            <div class="player">
                <img class="player-avatar" src="{av2}" alt="{u2}">
                <div class="player-name">{dn2}</div>
                <div class="player-handle"><a href="/u/{u2}" style="color:var(--text-secondary);text-decoration:none">@{u2}</a></div>
            </div>
        </div>

        <table>
            <tbody>
                {rows}
            </tbody>
        </table>

        <div class="actions">
            <a class="share-btn" href="https://x.com/intent/tweet?text={share_text}" target="_blank" rel="noopener">&#120143; Share on X</a>
            <a class="share-btn" href="/u/{u1}">View @{u1}</a>
            <a class="share-btn" href="/u/{u2}">View @{u2}</a>
        </div>

        <div class="footer">
            &#128293; <a href="https://myburn.cc">myburn.cc</a> &mdash; Claude Code Usage Dashboard
        </div>
    </div>
</body>
</html>"""


def _render_challenge_html(user: dict, pushes: list[dict], privacy: dict) -> str:
    """Render challenge page — user1 exists, user2 doesn't. CTA to install."""
    username = _esc(user["username"])
    display_name = _esc(user["display_name"] or user["username"])
    avatar_url = _esc(user["avatar_url"] or "", quote=True)

    stats = _aggregate_user_stats(pushes, privacy)
    cost_str = f"${stats['cost']:,.0f}" if stats["cost"] is not None else "a mystery amount"
    tokens_str = _fmt_tokens_html(stats["tokens"]) if stats["tokens"] is not None else "???"

    og_desc = _esc(f"@{username} challenged you! {cost_str} burned. How much did you burn?", quote=True)

    share_text = _esc(
        f"@{username} challenged me on MyBurn! 🔥\n"
        f"{cost_str} burned — can you beat that?\n"
        f"pip install myburn && myburn push\n"
        f"https://myburn.cc/u/{username}\n"
        f"#ClaudeCode #MyBurn",
        quote=True,
    )

    return f"""<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>@{username} challenged you! — MyBurn</title>
    <meta property="og:title" content="@{username} challenged you!">
    <meta property="og:description" content="{og_desc}">
    <meta property="og:image" content="{avatar_url}">
    <meta property="og:url" content="https://myburn.cc/u/{username}">
    <meta name="twitter:card" content="summary">
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
    <style>
        * {{ margin: 0; padding: 0; box-sizing: border-box; }}
        :root {{
            --bg-primary: #191613;
            --bg-secondary: #211e1a;
            --bg-tertiary: #2a2621;
            --bg-hover: #332e28;
            --border: rgba(255, 255, 255, 0.07);
            --text-primary: #ede8e3;
            --text-secondary: #a09689;
            --text-tertiary: #6d6358;
            --accent: #e8632b;
            --accent-dim: rgba(232, 99, 43, 0.15);
            --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
            --font-mono: 'JetBrains Mono', 'Fira Code', monospace;
            --radius: 12px;
        }}
        body {{
            font-family: var(--font-sans);
            background: var(--bg-primary);
            color: var(--text-primary);
            min-height: 100vh;
        }}
        .container {{
            max-width: 600px;
            margin: 0 auto;
            padding: 60px 24px;
            text-align: center;
        }}
        .challenge-badge {{
            display: inline-block;
            background: var(--accent-dim);
            color: var(--accent);
            padding: 6px 16px;
            border-radius: 20px;
            font-size: 13px;
            font-weight: 600;
            text-transform: uppercase;
            letter-spacing: 0.05em;
            margin-bottom: 24px;
        }}
        .challenger {{
            margin-bottom: 32px;
        }}
        .challenger-avatar {{
            width: 80px;
            height: 80px;
            border-radius: 50%;
            border: 3px solid var(--accent);
            margin-bottom: 12px;
        }}
        .challenger-name {{
            font-size: 24px;
            font-weight: 700;
        }}
        .challenger-handle {{
            color: var(--text-secondary);
            font-size: 14px;
        }}
        .challenge-stat {{
            font-family: var(--font-mono);
            font-size: 48px;
            font-weight: 700;
            color: var(--accent);
            margin: 24px 0 8px;
        }}
        .challenge-stat-label {{
            color: var(--text-secondary);
            font-size: 14px;
            margin-bottom: 32px;
        }}
        .challenge-cta {{
            font-size: 20px;
            font-weight: 700;
            margin-bottom: 24px;
        }}
        .install-box {{
            background: var(--bg-secondary);
            border: 1px solid var(--border);
            border-radius: var(--radius);
            padding: 20px;
            margin-bottom: 24px;
        }}
        .install-box code {{
            font-family: var(--font-mono);
            font-size: 15px;
            color: var(--accent);
            display: block;
            margin: 8px 0;
        }}
        .install-box .step {{
            color: var(--text-tertiary);
            font-size: 12px;
            text-transform: uppercase;
            letter-spacing: 0.05em;
            margin-top: 12px;
        }}
        .install-box .step:first-child {{
            margin-top: 0;
        }}
        .share-btn {{
            display: inline-flex;
            align-items: center;
            gap: 6px;
            padding: 10px 20px;
            background: var(--bg-secondary);
            border: 1px solid var(--border);
            border-radius: var(--radius);
            color: var(--text-primary);
            font-family: var(--font-sans);
            font-size: 14px;
            font-weight: 500;
            cursor: pointer;
            text-decoration: none;
            transition: background 0.2s;
        }}
        .share-btn:hover {{
            background: var(--bg-hover);
        }}
        .actions {{
            display: flex;
            justify-content: center;
            gap: 12px;
            flex-wrap: wrap;
            margin-bottom: 32px;
        }}
        .footer {{
            margin-top: 48px;
            color: var(--text-tertiary);
            font-size: 13px;
        }}
        .footer a {{
            color: var(--accent);
            text-decoration: none;
        }}
    </style>
</head>
<body>
    <div class="container">
        <div class="challenge-badge">&#9876;&#65039; Challenge</div>

        <div class="challenger">
            <img class="challenger-avatar" src="{avatar_url}" alt="{username}">
            <div class="challenger-name">{display_name}</div>
            <div class="challenger-handle">@{username}</div>
        </div>

        <div class="challenge-stat">{_esc(cost_str)}</div>
        <div class="challenge-stat-label">burned this month &bull; {_esc(tokens_str)} tokens &bull; {stats['sessions']} sessions</div>

        <div class="challenge-cta">How much did you burn? &#128293;</div>

        <div class="install-box">
            <div class="step">Step 1 — Install</div>
            <code>pip install myburn</code>
            <div class="step">Step 2 — Push your stats</div>
            <code>myburn push</code>
            <div class="step">Step 3 — Compare</div>
            <code>myburn.cc/compare/you/{username}</code>
        </div>

        <div class="actions">
            <a class="share-btn" href="https://x.com/intent/tweet?text={share_text}" target="_blank" rel="noopener">&#120143; Share Challenge</a>
            <a class="share-btn" href="/u/{username}">View @{username}'s Profile</a>
        </div>

        <div class="footer">
            &#128293; <a href="https://myburn.cc">myburn.cc</a> &mdash; Claude Code Usage Dashboard
        </div>
    </div>
</body>
</html>"""


# ---------------------------------------------------------------------------
# Route registration
# ---------------------------------------------------------------------------
def register_cloud_routes(app) -> None:
    """Mount cloud routes on the FastAPI app. Called only when GITHUB_CLIENT_ID is set."""
    _init_cloud_db()
    log.info("Cloud routes enabled (cloud.db: %s)", CLOUD_DB_PATH)

    # --- POST /api/auth/github ---
    @app.post("/api/auth/github")
    async def auth_github(request: Request):
        body = await request.json()
        gh_token = body.get("github_token")
        if not gh_token:
            raise HTTPException(status_code=400, detail="Missing github_token")

        # Fetch GitHub profile
        gh_user = _fetch_github_user(gh_token)
        github_id = gh_user["id"]
        username = gh_user["login"]
        display_name = gh_user.get("name") or username
        avatar_url = gh_user.get("avatar_url", "")

        conn = _get_cloud_db()
        try:
            # Upsert user
            existing = conn.execute(
                "SELECT * FROM users WHERE github_id = ?", (github_id,)
            ).fetchone()

            # Generate token — store hash, return plaintext once
            api_token = secrets.token_hex(32)
            token_hash = _hash_token(api_token)
            expires_at = (datetime.now(timezone.utc) + timedelta(days=_TOKEN_EXPIRY_DAYS)).strftime("%Y-%m-%dT%H:%M:%SZ")

            if existing:
                # Update profile fields + rotate token + set expiry
                conn.execute(
                    "UPDATE users SET username=?, display_name=?, avatar_url=?, api_token=?, token_expires_at=? "
                    "WHERE github_id=?",
                    (username, display_name, avatar_url, token_hash, expires_at, github_id),
                )
            else:
                # Create new user
                now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
                conn.execute(
                    "INSERT INTO users (github_id, username, display_name, avatar_url, api_token, created_at, token_expires_at) "
                    "VALUES (?, ?, ?, ?, ?, ?, ?)",
                    (github_id, username, display_name, avatar_url, token_hash, now, expires_at),
                )
            conn.commit()
        finally:
            conn.close()

        return {"token": api_token, "username": username, "avatar_url": avatar_url, "expires_at": expires_at}

    # --- POST /api/auth/device/start (browser Device Flow) ---
    @app.post("/api/auth/device/start")
    async def auth_device_start():
        """Start GitHub Device Flow — proxy to avoid browser CORS."""
        client_id = os.environ.get("GITHUB_CLIENT_ID", "")
        if not client_id:
            raise HTTPException(status_code=500, detail="OAuth not configured")
        body = urllib.parse.urlencode({
            "client_id": client_id,
            "scope": "read:user",
        }).encode()
        req = urllib.request.Request(
            "https://github.com/login/device/code",
            data=body,
            headers={"Accept": "application/json"},
            method="POST",
        )
        try:
            with urllib.request.urlopen(req, timeout=15) as resp:
                data = json.loads(resp.read())
        except Exception as e:
            raise HTTPException(status_code=502, detail=f"GitHub error: {e}")
        return {
            "device_code": data["device_code"],
            "user_code": data["user_code"],
            "verification_uri": data["verification_uri"],
            "interval": data.get("interval", 5),
            "expires_in": data.get("expires_in", 900),
        }

    # --- POST /api/auth/device/poll (browser Device Flow) ---
    @app.post("/api/auth/device/poll")
    async def auth_device_poll(request: Request):
        """Poll GitHub Device Flow, exchange token on success."""
        body = await request.json()
        device_code = body.get("device_code")
        if not device_code:
            raise HTTPException(status_code=400, detail="Missing device_code")

        client_id = os.environ.get("GITHUB_CLIENT_ID", "")
        form = urllib.parse.urlencode({
            "client_id": client_id,
            "device_code": device_code,
            "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
        }).encode()
        req = urllib.request.Request(
            "https://github.com/login/oauth/access_token",
            data=form,
            headers={"Accept": "application/json"},
            method="POST",
        )
        try:
            with urllib.request.urlopen(req, timeout=15) as resp:
                gh_resp = json.loads(resp.read())
        except Exception as e:
            raise HTTPException(status_code=502, detail=f"GitHub error: {e}")

        # Still waiting
        if "error" in gh_resp:
            error = gh_resp["error"]
            if error in ("authorization_pending", "slow_down"):
                return {"status": "pending", "interval": gh_resp.get("interval", 5)}
            elif error == "expired_token":
                return {"status": "expired"}
            elif error == "access_denied":
                return {"status": "denied"}
            else:
                return {"status": "error", "detail": error}

        # Got GitHub token — exchange for MyBurn token
        gh_token = gh_resp.get("access_token")
        if not gh_token:
            raise HTTPException(status_code=502, detail="No access_token from GitHub")

        gh_user = _fetch_github_user(gh_token)
        github_id = gh_user["id"]
        username = gh_user["login"]
        display_name = gh_user.get("name") or username
        avatar_url = gh_user.get("avatar_url", "")

        conn = _get_cloud_db()
        try:
            existing = conn.execute(
                "SELECT * FROM users WHERE github_id = ?", (github_id,)
            ).fetchone()

            api_token = secrets.token_hex(32)
            token_hash = _hash_token(api_token)
            expires_at = (datetime.now(timezone.utc) + timedelta(days=_TOKEN_EXPIRY_DAYS)).strftime("%Y-%m-%dT%H:%M:%SZ")

            if existing:
                conn.execute(
                    "UPDATE users SET username=?, display_name=?, avatar_url=?, api_token=?, token_expires_at=? "
                    "WHERE github_id=?",
                    (username, display_name, avatar_url, token_hash, expires_at, github_id),
                )
            else:
                now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
                conn.execute(
                    "INSERT INTO users (github_id, username, display_name, avatar_url, api_token, created_at, token_expires_at) "
                    "VALUES (?, ?, ?, ?, ?, ?, ?)",
                    (github_id, username, display_name, avatar_url, token_hash, now, expires_at),
                )
            conn.commit()
        finally:
            conn.close()

        return {
            "status": "ok",
            "token": api_token,
            "username": username,
            "display_name": display_name,
            "avatar_url": avatar_url,
            "expires_at": expires_at,
        }

    # --- POST /api/auth/revoke (FIX-08) ---
    @app.post("/api/auth/revoke")
    async def auth_revoke(authorization: Optional[str] = Header(None)):
        user = _get_current_user(authorization)
        conn = _get_cloud_db()
        try:
            # Expire token immediately (keeps hash in DB, avoids UNIQUE constraint issues)
            conn.execute(
                "UPDATE users SET token_expires_at = '2000-01-01T00:00:00Z' WHERE id = ?",
                (user["id"],),
            )
            conn.commit()
        finally:
            conn.close()
        return {"ok": True}

    # --- POST /api/push ---
    @app.post("/api/push")
    async def cloud_push(request: Request, authorization: Optional[str] = Header(None)):
        from myburn.trust import validate_trust

        # Body size limit — 1MB (FIX-28)
        content_length = request.headers.get("content-length")
        if content_length and int(content_length) > 1_048_576:
            raise HTTPException(status_code=413, detail="Request body too large (max 1MB)")

        user = _get_current_user(authorization)
        payload = await request.json()

        # Tier 1: basic validation
        err = _validate_payload(payload)
        if err:
            raise HTTPException(status_code=422, detail=err)

        month = payload["month"]
        cost = payload.get("cost", 0)
        tokens = payload.get("tokens", {})
        total_tokens = tokens.get("total", 0) if isinstance(tokens, dict) else int(tokens)
        sessions = payload.get("sessions", 0)
        active_days = payload.get("activeDays", 0)
        streak = payload.get("streak", 0)
        cli_version = payload.get("cliVersion", "")
        now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")

        conn = _get_cloud_db()
        try:
            # Look up previous hash for chain verification
            prev_row = conn.execute(
                "SELECT payload_hash FROM pushes WHERE user_id = ? ORDER BY month DESC LIMIT 1",
                (user["id"],),
            ).fetchone()
            prev_hash = prev_row["payload_hash"] if prev_row and prev_row["payload_hash"] else None

            # Trust validation (Tier 1 price/model + Tier 2 heuristics)
            payload_hash, flag_dicts, trust_score, raw_flags = validate_trust(payload, prev_hash)

            # Hard flags → reject
            hard_flags = [f for f in raw_flags if f.severity == "hard"]
            if hard_flags:
                details = "; ".join(f.message for f in hard_flags)
                raise HTTPException(status_code=422, detail=f"Trust check failed: {details}")

            # Check if this is a new push or re-push (FIX-27)
            existing = conn.execute(
                "SELECT 1 FROM pushes WHERE user_id = ? AND month = ?",
                (user["id"], month),
            ).fetchone()

            # Upsert push (UNIQUE on user_id + month)
            # Store server-computed prev_hash, not client-declared _prevHash (FIX-25)
            conn.execute(
                "INSERT INTO pushes (user_id, month, payload, cost, total_tokens, sessions, "
                "active_days, streak, cli_version, pushed_at, "
                "payload_hash, prev_hash, trust_score, trust_flags) "
                "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
                "ON CONFLICT(user_id, month) DO UPDATE SET "
                "payload=excluded.payload, cost=excluded.cost, total_tokens=excluded.total_tokens, "
                "sessions=excluded.sessions, active_days=excluded.active_days, streak=excluded.streak, "
                "cli_version=excluded.cli_version, pushed_at=excluded.pushed_at, "
                "payload_hash=excluded.payload_hash, prev_hash=excluded.prev_hash, "
                "trust_score=excluded.trust_score, trust_flags=excluded.trust_flags",
                (user["id"], month, json.dumps(payload), cost, total_tokens, sessions,
                 active_days, streak, cli_version, now,
                 payload_hash, prev_hash, trust_score, json.dumps(flag_dicts)),
            )
            # Update user stats — only increment push_count on new month (FIX-27)
            if existing:
                conn.execute(
                    "UPDATE users SET last_push_at=? WHERE id=?",
                    (now, user["id"]),
                )
            else:
                conn.execute(
                    "UPDATE users SET last_push_at=?, push_count=push_count+1 WHERE id=?",
                    (now, user["id"]),
                )
            conn.commit()
        finally:
            conn.close()

        return {
            "ok": True,
            "profile_url": f"https://myburn.cc/u/{user['username']}",
            "trust_score": trust_score,
        }

    # --- GET /api/privacy ---
    @app.get("/api/privacy")
    async def get_privacy(authorization: Optional[str] = Header(None)):
        user = _get_current_user(authorization)
        return _parse_privacy(user)

    # --- POST /api/privacy ---
    @app.post("/api/privacy")
    async def set_privacy(request: Request, authorization: Optional[str] = Header(None)):
        user = _get_current_user(authorization)
        body = await request.json()

        # Validate: only accept known keys with bool values
        privacy = {}
        for k in _VALID_PRIVACY_KEYS:
            if k in body:
                privacy[k] = bool(body[k])

        conn = _get_cloud_db()
        try:
            conn.execute(
                "UPDATE users SET privacy = ? WHERE id = ?",
                (json.dumps(privacy), user["id"]),
            )
            conn.commit()
        finally:
            conn.close()

        return {"ok": True, "privacy": privacy}

    # --- GET /api/u/{username} (JSON profile) ---
    @app.get("/api/u/{username}")
    async def api_profile(username: str):
        conn = _get_cloud_db()
        try:
            user = conn.execute(
                "SELECT id, username, display_name, avatar_url, created_at, last_push_at, push_count, privacy "
                "FROM users WHERE username = ?",
                (username,),
            ).fetchone()
            if not user:
                raise HTTPException(status_code=404, detail="User not found")

            pushes = conn.execute(
                "SELECT month, cost, total_tokens, sessions, active_days, streak, pushed_at, payload_hash "
                "FROM pushes WHERE user_id = ? ORDER BY month DESC",
                (user["id"],),
            ).fetchall()
        finally:
            conn.close()

        privacy = _parse_privacy(dict(user))
        months = [_apply_privacy_to_push(dict(p), privacy) for p in pushes]

        return {
            "username": user["username"],
            "display_name": user["display_name"],
            "avatar_url": user["avatar_url"],
            "created_at": user["created_at"],
            "last_push_at": user["last_push_at"],
            "push_count": user["push_count"],
            "months": months,
        }

    # --- GET /u/{username} (HTML profile page) ---
    @app.get("/u/{username}", response_class=HTMLResponse)
    async def html_profile(username: str):
        conn = _get_cloud_db()
        try:
            user = conn.execute(
                "SELECT * FROM users WHERE username = ?", (username,)
            ).fetchone()
            if not user:
                raise HTTPException(status_code=404, detail="User not found")

            pushes = conn.execute(
                "SELECT * FROM pushes WHERE user_id = ? ORDER BY month DESC",
                (user["id"],),
            ).fetchall()
        finally:
            conn.close()

        user_dict = dict(user)
        privacy = _parse_privacy(user_dict)
        redacted_pushes = [_apply_privacy_to_push(dict(p), privacy) for p in pushes]

        return _render_profile_html(user_dict, redacted_pushes, privacy)

    # --- GET /api/leaderboard ---
    @app.get("/api/leaderboard")
    async def api_leaderboard(
        metric: str = "cost",
        period: str = "current",
        limit: int = 50,
        authorization: Optional[str] = Header(None),
    ):
        if metric not in _METRIC_COL:
            raise HTTPException(status_code=400, detail=f"Invalid metric: {metric}. Valid: {', '.join(_METRIC_COL)}")
        if period not in ("current", "last", "alltime"):
            raise HTTPException(status_code=400, detail=f"Invalid period: {period}. Valid: current, last, alltime")
        limit = max(1, min(200, limit))

        conn = _get_cloud_db()
        try:
            entries, total_users, period_label = _query_leaderboard(conn, metric, period, limit)

            # --- Caller rank (optional) ---
            you = None
            if authorization and authorization.startswith("Bearer "):
                token = authorization[7:]
                col = _METRIC_COL[metric]
                agg = _METRIC_AGG.get(metric, "SUM")
                month = _resolve_month(period)
                caller = conn.execute("SELECT id, username FROM users WHERE api_token = ?", (_hash_token(token),)).fetchone()
                if caller:
                    if month:
                        caller_row = conn.execute(
                            f"SELECT {col} AS value FROM pushes WHERE user_id = ? AND month = ?",
                            (caller["id"], month),
                        ).fetchone()
                        if caller_row:
                            caller_value = caller_row["value"]
                            rank = conn.execute(
                                f"SELECT COUNT(*) FROM pushes WHERE month = ? AND {col} > ?",
                                (month, caller_value),
                            ).fetchone()[0] + 1
                        else:
                            caller_value = 0
                            rank = total_users + 1
                    else:
                        caller_row = conn.execute(
                            f"SELECT {agg}(p.{col}) AS value FROM pushes p WHERE p.user_id = ?",
                            (caller["id"],),
                        ).fetchone()
                        if caller_row and caller_row["value"] is not None:
                            caller_value = caller_row["value"]
                            rank = conn.execute(
                                f"SELECT COUNT(*) FROM ("
                                f"  SELECT {agg}(p.{col}) AS val FROM pushes p GROUP BY p.user_id HAVING val > ?"
                                ")",
                                (caller_value,),
                            ).fetchone()[0] + 1
                        else:
                            caller_value = 0
                            rank = total_users + 1

                    pct = round((1 - (rank - 1) / total_users) * 100, 1) if total_users > 0 else 0
                    you = {
                        "rank": rank,
                        "value": caller_value,
                        "percentile": pct,
                        "username": caller["username"],
                    }
        finally:
            conn.close()

        return {
            "entries": entries,
            "total_users": total_users,
            "metric": metric,
            "period": period,
            "period_label": period_label,
            "you": you,
        }

    # --- GET /leaderboard (HTML) ---
    @app.get("/leaderboard", response_class=HTMLResponse)
    async def html_leaderboard(
        metric: str = "cost",
        period: str = "current",
    ):
        if metric not in _METRIC_COL:
            metric = "cost"
        if period not in ("current", "last", "alltime"):
            period = "current"

        conn = _get_cloud_db()
        try:
            entries, total_users, period_label = _query_leaderboard(conn, metric, period)
        finally:
            conn.close()

        return _render_leaderboard_html(entries, total_users, metric, period, period_label)

    # --- GET /api/compare/{user1}/{user2} (JSON) ---
    @app.get("/api/compare/{user1}/{user2}")
    async def api_compare(user1: str, user2: str):
        # URL normalization: alphabetical order
        if user1.lower() > user2.lower():
            return JSONResponse(
                status_code=307,
                headers={"Location": f"/api/compare/{user2}/{user1}"},
                content=None,
            )

        conn = _get_cloud_db()
        try:
            u1 = conn.execute("SELECT * FROM users WHERE username = ?", (user1,)).fetchone()
            if not u1:
                raise HTTPException(status_code=404, detail=f"User not found: {user1}")

            u2 = conn.execute("SELECT * FROM users WHERE username = ?", (user2,)).fetchone()
            if not u2:
                return {"challenge": True, "user1": user1, "user2": user2, "message": f"{user2} has not pushed yet"}

            p1 = conn.execute("SELECT * FROM pushes WHERE user_id = ? ORDER BY month DESC", (u1["id"],)).fetchall()
            p2 = conn.execute("SELECT * FROM pushes WHERE user_id = ? ORDER BY month DESC", (u2["id"],)).fetchall()
        finally:
            conn.close()

        priv1 = _parse_privacy(dict(u1))
        priv2 = _parse_privacy(dict(u2))
        s1 = _aggregate_user_stats([_apply_privacy_to_push(dict(p), priv1) for p in p1], priv1)
        s2 = _aggregate_user_stats([_apply_privacy_to_push(dict(p), priv2) for p in p2], priv2)

        return {
            "user1": {"username": u1["username"], "display_name": u1["display_name"], "stats": s1},
            "user2": {"username": u2["username"], "display_name": u2["display_name"], "stats": s2},
            "verdict": _compare_verdict(u1["username"], u2["username"], s1, s2),
        }

    # --- GET /compare/{user1}/{user2} (HTML) ---
    @app.get("/compare/{user1}/{user2}", response_class=HTMLResponse)
    async def html_compare(user1: str, user2: str):
        # URL normalization: alphabetical order
        if user1.lower() > user2.lower():
            return HTMLResponse(
                status_code=307,
                headers={"Location": f"/compare/{user2}/{user1}"},
                content="",
            )

        conn = _get_cloud_db()
        try:
            u1 = conn.execute("SELECT * FROM users WHERE username = ?", (user1,)).fetchone()
            if not u1:
                raise HTTPException(status_code=404, detail=f"User not found: {user1}")

            u2 = conn.execute("SELECT * FROM users WHERE username = ?", (user2,)).fetchone()

            p1 = conn.execute("SELECT * FROM pushes WHERE user_id = ? ORDER BY month DESC", (u1["id"],)).fetchall()

            if u2:
                p2 = conn.execute("SELECT * FROM pushes WHERE user_id = ? ORDER BY month DESC", (u2["id"],)).fetchall()
        finally:
            conn.close()

        u1_dict = dict(u1)
        priv1 = _parse_privacy(u1_dict)
        redacted1 = [_apply_privacy_to_push(dict(p), priv1) for p in p1]

        # Challenge mode: user2 not found
        if not u2:
            return _render_challenge_html(u1_dict, redacted1, priv1)

        u2_dict = dict(u2)
        priv2 = _parse_privacy(u2_dict)
        redacted2 = [_apply_privacy_to_push(dict(p), priv2) for p in p2]

        return _render_compare_html(u1_dict, redacted1, priv1, u2_dict, redacted2, priv2)
