"""
Claude/Codex/Gemini Usage Dashboard Backend
Reads local credential files and fetches usage from provider APIs.
"""
import asyncio
import hashlib
import hmac
import json
import os
import secrets
import time
from contextlib import asynccontextmanager
from datetime import datetime
from pathlib import Path

# ---------- Provider usage cache ----------
_USAGE_CACHE_FILE = Path(os.environ.get("USAGE_CACHE_FILE", "/cache/usage.json"))
_USAGE_CACHE_TTL_SECONDS = float(os.environ.get("USAGE_CACHE_TTL_SECONDS", "900"))


def _load_usage_disk_cache() -> dict:
    try:
        if _USAGE_CACHE_FILE.exists():
            saved = json.loads(_USAGE_CACHE_FILE.read_text())
            if isinstance(saved, dict) and isinstance(saved.get("providers"), dict):
                return saved
    except Exception as exc:
        print(f"[usage-cache] load failed: {exc}")
    return {"providers": {}}


def _save_usage_disk_cache() -> None:
    try:
        _USAGE_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
        _USAGE_CACHE_FILE.write_text(json.dumps(_usage_cache, indent=2, sort_keys=True))
    except Exception as exc:
        print(f"[usage-cache] save failed: {exc}")


def _get_provider_cache(provider: str) -> dict | None:
    entry = _usage_cache.get("providers", {}).get(provider)
    return entry if isinstance(entry, dict) else None


def _write_provider_cache(provider: str, entry: dict) -> None:
    _usage_cache.setdefault("providers", {})[provider] = entry
    _save_usage_disk_cache()


def _provider_cache_is_fresh(entry: dict | None) -> bool:
    if not entry:
        return False
    updated_at_ms = entry.get("updatedAt")
    if not isinstance(updated_at_ms, int):
        return False
    age_ms = int(time.time() * 1000) - updated_at_ms
    return age_ms < int(_USAGE_CACHE_TTL_SECONDS * 1000)


def _provider_payload_from_cache(entry: dict | None, *, stale: bool = False) -> dict | None:
    if not entry:
        return None
    payload = entry.get("payload")
    if not isinstance(payload, dict):
        return None
    result = dict(payload)
    updated_at_ms = entry.get("updatedAt")
    if isinstance(updated_at_ms, int):
        result["cachedAtMs"] = updated_at_ms
    if stale:
        stale_since_ms = entry.get("staleSinceMs")
        if not isinstance(stale_since_ms, int):
            stale_since_ms = updated_at_ms if isinstance(updated_at_ms, int) else int(time.time() * 1000)
        result["stale"] = True
        result["staleSinceMs"] = stale_since_ms
        if entry.get("lastError"):
            result["lastError"] = entry["lastError"]
        backoff_until = entry.get("backoffUntil")
        if isinstance(backoff_until, (int, float)) and backoff_until > time.time():
            result["retryAfterMs"] = int(backoff_until * 1000)
    return result


def _model_label(model_raw: str) -> str:
    label = model_raw.replace("-", " ").title()
    return label or model_raw


_usage_cache = _load_usage_disk_cache()

import httpx
from fastapi import Cookie, FastAPI, Form, Request, status
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
from fastapi.staticfiles import StaticFiles


async def _refresh_all_providers() -> None:
    try:
        await asyncio.gather(fetch_claude(), fetch_codex(), fetch_gemini())
        print("[bg-refresh] done")
    except Exception as exc:
        print(f"[bg-refresh] error: {exc}")


async def _background_usage_refresh() -> None:
    regular_interval = _USAGE_CACHE_TTL_SECONDS + 5
    while True:
        # Wake up at the soonest window reset time (+20s buffer) or regular interval.
        sleep_sec = regular_interval
        now_ms = int(time.time() * 1000)
        for provider in ("Claude", "Codex", "Gemini"):
            entry = _get_provider_cache(provider)
            if not entry:
                continue
            for w in entry.get("payload", {}).get("windows", []):
                reset_ms = w.get("resetMs")
                if reset_ms and reset_ms > now_ms:
                    secs = (reset_ms - now_ms) / 1000 + 20
                    if secs < sleep_sec:
                        sleep_sec = secs
        sleep_sec = max(60.0, sleep_sec)
        print(f"[bg-refresh] next in {sleep_sec:.0f}s")
        await asyncio.sleep(sleep_sec)
        await _refresh_all_providers()


@asynccontextmanager
async def lifespan(_app):
    # Kick off an initial fetch immediately (non-blocking).
    asyncio.create_task(_refresh_all_providers())
    task = asyncio.create_task(_background_usage_refresh())
    yield
    task.cancel()
    try:
        await task
    except asyncio.CancelledError:
        pass


app = FastAPI(title="LLM Usage Dashboard", docs_url=None, redoc_url=None, lifespan=lifespan)

# ---------- Auth config ----------

DASHBOARD_USER = os.environ.get("DASHBOARD_USER", "admin")
DASHBOARD_PASS = os.environ.get("DASHBOARD_PASS", "changeme")
SESSION_SECRET = os.environ.get("SESSION_SECRET", secrets.token_hex(32))
SESSION_TTL = int(os.environ.get("SESSION_TTL", 86400 * 7))  # 7 days

# In-memory session store: token -> expires_at
_sessions: dict[str, float] = {}


def _make_session_token() -> str:
    return secrets.token_urlsafe(32)


def _is_valid_session(token: str | None) -> bool:
    if not token:
        return False
    exp = _sessions.get(token)
    if exp is None or time.time() > exp:
        _sessions.pop(token, None)
        return False
    return True


def _check_credentials(username: str, password: str) -> bool:
    ok_user = hmac.compare_digest(username.encode(), DASHBOARD_USER.encode())
    ok_pass = hmac.compare_digest(
        hashlib.sha256(password.encode()).digest(),
        hashlib.sha256(DASHBOARD_PASS.encode()).digest(),
    )
    return ok_user and ok_pass


# ---------- Credential paths ----------

CLAUDE_CREDS = Path(os.environ.get("CLAUDE_CREDS", "/creds/claude/.credentials.json"))
CODEX_CREDS = Path(os.environ.get("CODEX_CREDS", "/creds/codex/auth.json"))
GEMINI_CREDS = Path(os.environ.get("GEMINI_CREDS", "/creds/gemini/oauth_creds.json"))

GEMINI_CLIENT_ID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com"
GEMINI_CLIENT_SECRET = os.environ.get("GEMINI_CLIENT_SECRET")

CLAUDE_OAUTH_TOKEN_URL = "https://platform.claude.com/v1/oauth/token"
CLAUDE_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"

CODEX_OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token"
CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"

# In-memory cache for auto-refreshed tokens (survives refresh cycles, not restarts)
_claude_refreshed_token: dict | None = None
_codex_refreshed_token: dict | None = None

# Set to True when Claude refresh_token is confirmed dead (invalid_grant).
# Cleared when new credentials are detected on disk.
_claude_refresh_token_dead: bool = False

TIMEOUT = 10.0


# ---------- Helpers ----------

def clamp(value: float, lo: float = 0.0, hi: float = 100.0) -> float:
    return max(lo, min(hi, value if value == value else 0.0))


def _annotate_reset_projections(payload: dict | None, cache_entry: dict | None) -> dict | None:
    """When stale data is returned, mark windows whose resetMs has passed as projected-zero.

    The window quota resets to 0 at resetMs. If we're past that point and haven't
    fetched fresh data yet, we can confidently display 0% (projected) rather than
    the stale high value that would mislead users.
    """
    if not payload or not isinstance(payload, dict):
        return payload
    if not cache_entry:
        return payload
    stale_since_sec = (cache_entry.get("staleSinceMs") or 0) / 1000
    now = time.time()
    windows = payload.get("windows")
    if not isinstance(windows, list):
        return payload
    new_windows = []
    for w in windows:
        w = dict(w)
        reset_ms = w.get("resetMs")
        if reset_ms:
            reset_sec = reset_ms / 1000
            # Window has reset after we went stale → display projected 0%
            if reset_sec < now and reset_sec > stale_since_sec:
                w["projectedPct"] = 0
                w["projected"] = True
        new_windows.append(w)
    return {**payload, "windows": new_windows}


def fmt_reset(target_ms: float | None) -> str | None:
    if not target_ms:
        return None
    diff = target_ms / 1000 - time.time()
    if diff <= 0:
        return "now"
    diff_min = int(diff // 60)
    if diff_min < 60:
        return f"{diff_min}m"
    h, m = divmod(diff_min, 60)
    if h < 24:
        return f"{h}h {m}m" if m else f"{h}h"
    d = h // 24
    return f"{d}d {h % 24}h"


def parse_reset(s: str | None) -> int | None:
    if not s:
        return None
    try:
        dt = datetime.fromisoformat(s.replace("Z", "+00:00"))
        return int(dt.timestamp() * 1000)
    except Exception:
        return None


# ---------- Claude token refresh ----------

async def get_claude_access_token() -> tuple[str | None, bool]:
    """Return (access_token, needs_relogin). needs_relogin=True means refresh_token is dead."""
    global _claude_refreshed_token, _claude_refresh_token_dead

    if not CLAUDE_CREDS.exists():
        return None, False
    try:
        creds = json.loads(CLAUDE_CREDS.read_text())
        oauth = creds.get("claudeAiOauth", {})
        access_token = oauth.get("accessToken", "")
        refresh_token = oauth.get("refreshToken", "")
        expires_at_ms = oauth.get("expiresAt")

        # If the credentials file has been updated since we marked the token dead, clear the flag.
        if _claude_refresh_token_dead and refresh_token:
            creds_mtime = CLAUDE_CREDS.stat().st_mtime
            stale_marker = (_get_provider_cache("Claude") or {}).get("staleSinceMs", 0) / 1000
            if creds_mtime > stale_marker:
                print("[claude-auth] credentials file updated — clearing invalid_grant flag")
                _claude_refresh_token_dead = False
                _claude_refreshed_token = None

        # Credentials file token still valid (> 5 min buffer) — use it directly
        if access_token and isinstance(expires_at_ms, (int, float)):
            if expires_at_ms / 1000 - time.time() > 300:
                _claude_refreshed_token = None  # clear stale in-memory cache
                _claude_refresh_token_dead = False
                return access_token, False

        # In-memory refreshed token still valid?
        if _claude_refreshed_token:
            cached_exp = _claude_refreshed_token.get("expiresAt", 0)
            if cached_exp / 1000 - time.time() > 300:
                return _claude_refreshed_token["accessToken"], False
            # In-memory token also expired; its refresh_token may have rotated
            refresh_token = _claude_refreshed_token.get("refreshToken") or refresh_token

        if not refresh_token:
            return access_token or None, False

        # Don't retry if we already know the refresh_token is dead
        if _claude_refresh_token_dead:
            return None, True

        print("[claude-auth] access token expired — auto-refreshing via refresh_token")
        async with httpx.AsyncClient() as client:
            resp = await client.post(
                CLAUDE_OAUTH_TOKEN_URL,
                headers={"Content-Type": "application/x-www-form-urlencoded"},
                data={
                    "grant_type": "refresh_token",
                    "refresh_token": refresh_token,
                    "client_id": CLAUDE_CLIENT_ID,
                },
                timeout=TIMEOUT,
            )

        if resp.status_code == 200:
            data = resp.json()
            new_token = data.get("access_token", "")
            expires_in = data.get("expires_in", 3600)
            new_refresh = data.get("refresh_token", refresh_token)
            _claude_refreshed_token = {
                "accessToken": new_token,
                "expiresAt": int((time.time() + expires_in) * 1000),
                "refreshToken": new_refresh,
            }
            _claude_refresh_token_dead = False
            print(f"[claude-auth] token refreshed, valid for {expires_in}s")
            return new_token, False

        body = resp.text[:300]
        if resp.status_code in (400, 401) and "invalid_grant" in body:
            print(f"[claude-auth] refresh_token is dead (invalid_grant) — need re-login")
            _claude_refresh_token_dead = True
            _claude_refreshed_token = None
            return None, True

        print(f"[claude-auth] refresh failed: HTTP {resp.status_code} — {body}")
        return access_token or None, False
    except Exception as exc:
        print(f"[claude-auth] {exc}")
        return None, False


# ---------- Codex token refresh ----------

async def get_codex_access_token() -> tuple[str | None, bool]:
    """Return (access_token, needs_relogin). Tries refresh_token if current token seems stale."""
    global _codex_refreshed_token

    if not CODEX_CREDS.exists():
        return None, False
    try:
        creds = json.loads(CODEX_CREDS.read_text())
        tokens = creds.get("tokens", {})
        access_token = tokens.get("access_token", "")
        refresh_token = tokens.get("refresh_token", "")

        # In-memory refreshed token still valid?
        if _codex_refreshed_token:
            cached_exp = _codex_refreshed_token.get("expiresAt", 0)
            if cached_exp / 1000 - time.time() > 300:
                return _codex_refreshed_token["accessToken"], False
            refresh_token = _codex_refreshed_token.get("refreshToken") or refresh_token

        return access_token or None, False
    except Exception as exc:
        print(f"[codex-auth] read error: {exc}")
        return None, False


async def _try_refresh_codex_token() -> tuple[str | None, bool]:
    """
    Attempt to refresh Codex token. Returns (new_access_token, needs_relogin).
    needs_relogin=True means refresh_token is invalidated and user must re-login.
    """
    global _codex_refreshed_token

    if not CODEX_CREDS.exists():
        return None, False
    try:
        creds = json.loads(CODEX_CREDS.read_text())
        tokens = creds.get("tokens", {})
        refresh_token = tokens.get("refresh_token", "")
        if _codex_refreshed_token:
            refresh_token = _codex_refreshed_token.get("refreshToken") or refresh_token
        if not refresh_token:
            return None, True

        print("[codex-auth] attempting token refresh via refresh_token")
        async with httpx.AsyncClient() as client:
            resp = await client.post(
                CODEX_OAUTH_TOKEN_URL,
                headers={"Content-Type": "application/json"},
                json={
                    "grant_type": "refresh_token",
                    "refresh_token": refresh_token,
                    "client_id": CODEX_CLIENT_ID,
                },
                timeout=TIMEOUT,
            )

        if resp.status_code == 200:
            data = resp.json()
            new_token = data.get("access_token", "")
            expires_in = data.get("expires_in", 3600)
            new_refresh = data.get("refresh_token", refresh_token)
            _codex_refreshed_token = {
                "accessToken": new_token,
                "expiresAt": int((time.time() + expires_in) * 1000),
                "refreshToken": new_refresh,
            }
            print(f"[codex-auth] token refreshed, valid for {expires_in}s")
            return new_token, False

        body = resp.text[:300]
        if "refresh_token_invalidated" in body or resp.status_code == 401:
            print(f"[codex-auth] refresh_token invalidated — re-login required")
            _codex_refreshed_token = None
            return None, True

        print(f"[codex-auth] refresh failed: HTTP {resp.status_code} — {body}")
        return None, False
    except Exception as exc:
        print(f"[codex-auth] {exc}")
        return None, False


# ---------- Gemini token refresh ----------

async def get_gemini_access_token() -> str | None:
    if not GEMINI_CREDS.exists():
        return None
    try:
        creds = json.loads(GEMINI_CREDS.read_text())
        token = creds.get("access_token", "")
        expiry_ms = creds.get("expiry_date", 0)

        if token and time.time() < expiry_ms / 1000 - 60:
            return token

        refresh_token = creds.get("refresh_token", "")
        if not refresh_token:
            return token or None

        async with httpx.AsyncClient() as client:
            resp = await client.post(
                "https://oauth2.googleapis.com/token",
                data={
                    "client_id": GEMINI_CLIENT_ID,
                    "client_secret": GEMINI_CLIENT_SECRET,
                    "refresh_token": refresh_token,
                    "grant_type": "refresh_token",
                },
                timeout=TIMEOUT,
            )
        if resp.status_code == 200:
            data = resp.json()
            creds["access_token"] = data["access_token"]
            creds["expiry_date"] = int((time.time() + data.get("expires_in", 3600)) * 1000)
            try:
                GEMINI_CREDS.write_text(json.dumps(creds, indent=2))
            except OSError:
                pass
            return data["access_token"]
        return token or None
    except Exception as exc:
        print(f"[gemini-auth] {exc}")
        return None


# ---------- Provider fetch functions ----------

async def fetch_claude() -> dict:
    provider = "Claude"
    cache_entry = _get_provider_cache(provider)
    if _provider_cache_is_fresh(cache_entry):
        cached = _provider_payload_from_cache(cache_entry)
        if cached is not None:
            return cached

    try:
        # Check backoff BEFORE attempting token refresh to avoid unnecessary OAuth calls.
        now = time.time()
        backoff_until = float(cache_entry.get("backoffUntil", 0.0)) if cache_entry else 0.0
        if now < backoff_until:
            if not cache_entry:
                return {"provider": provider, "error": "HTTP 429 — rate limited"}
            cleared = False
            stale_since_sec = (cache_entry.get("staleSinceMs") or 0) / 1000
            payload_windows = cache_entry.get("payload", {}).get("windows", [])
            reset_times = [(w.get("resetMs") or 0) / 1000 for w in payload_windows if w.get("resetMs")]
            if reset_times and all(r < now and r > stale_since_sec for r in reset_times):
                # All rate-limit windows have reset since we went stale — safe to retry.
                print("[claude] all windows reset since stale — clearing backoff early to retry")
                cleared = True
            elif CLAUDE_CREDS.exists():
                try:
                    if CLAUDE_CREDS.stat().st_mtime > stale_since_sec:
                        # Credentials file updated after we went stale — user re-authorized.
                        print("[claude] credentials file updated since stale — clearing backoff to retry")
                        cleared = True
                except Exception:
                    pass
            if cleared:
                cache_entry.pop("backoffUntil", None)
                cache_entry.pop("staleSinceMs", None)
                cache_entry.pop("lastError", None)
                _write_provider_cache(provider, cache_entry)
                backoff_until = 0.0
            else:
                if "staleSinceMs" not in cache_entry:
                    cache_entry["staleSinceMs"] = int(now * 1000)
                    _write_provider_cache(provider, cache_entry)
                cached = _provider_payload_from_cache(cache_entry, stale=True)
                cached = _annotate_reset_projections(cached, cache_entry)
                if cached is not None:
                    return cached
                return {"provider": provider, "error": "HTTP 429 — rate limited"}

        token, needs_relogin = await get_claude_access_token()
        if needs_relogin or not token:
            error_msg = "需要重新登录 Claude（运行 claude 重新授权）"
            print(f"[claude] {error_msg}")
            if not cache_entry:
                cache_entry = {}
            cache_entry["backoffUntil"] = now + 14400  # 4h — don't hammer OAuth
            cache_entry["staleSinceMs"] = int(cache_entry.get("staleSinceMs") or now * 1000)
            cache_entry["lastError"] = error_msg
            _write_provider_cache(provider, cache_entry)
            cached = _provider_payload_from_cache(cache_entry, stale=True)
            cached = _annotate_reset_projections(cached, cache_entry)
            if cached is not None:
                return cached
            return {"provider": provider, "error": error_msg}

        async with httpx.AsyncClient() as client:
            resp = await client.get(
                "https://api.anthropic.com/api/oauth/usage",
                headers={
                    "Authorization": f"Bearer {token}",
                    "Accept": "application/json",
                    "anthropic-version": "2023-06-01",
                    "anthropic-beta": "oauth-2025-04-20",
                    "User-Agent": "usage-dashboard",
                },
                timeout=TIMEOUT,
            )

        if resp.status_code == 401:
            # Token rejected — clear in-memory refresh cache so next cycle re-fetches
            global _claude_refreshed_token
            _claude_refreshed_token = None
            print("[claude] 401 — token rejected, cleared refresh cache; backoff 60s")
            if not cache_entry:
                cache_entry = {}
            cache_entry["backoffUntil"] = now + 60
            cache_entry["staleSinceMs"] = int(cache_entry.get("staleSinceMs") or now * 1000)
            cache_entry["lastError"] = "HTTP 401 — token rejected"
            _write_provider_cache(provider, cache_entry)
            cached = _provider_payload_from_cache(cache_entry, stale=True)
            cached = _annotate_reset_projections(cached, cache_entry)
            if cached is not None:
                return cached
            return {"provider": provider, "error": "HTTP 401 — token rejected"}

        if resp.status_code == 429:
            # Parse Retry-After header; Retry-After:0 means Anthropic won't say when — use 30m
            retry_after = 1800  # default: 30 minutes
            raw = resp.headers.get("Retry-After", "")
            if raw:
                try:
                    parsed = int(raw)
                    retry_after = parsed if parsed > 0 else 1800
                except ValueError:
                    try:
                        from email.utils import parsedate_to_datetime
                        parsed = int(parsedate_to_datetime(raw).timestamp() - now)
                        retry_after = max(60, parsed)
                    except Exception:
                        pass
            print(f"[claude] 429 — backoff {retry_after}s (Retry-After: {raw!r})")
            if not cache_entry:
                cache_entry = {}
            cache_entry["backoffUntil"] = now + retry_after
            cache_entry["staleSinceMs"] = int(cache_entry.get("staleSinceMs") or now * 1000)
            cache_entry["lastError"] = "HTTP 429 — rate limited"
            if cache_entry and isinstance(cache_entry.get("payload"), dict):
                _write_provider_cache(provider, cache_entry)
                cached = _provider_payload_from_cache(cache_entry, stale=True)
                cached = _annotate_reset_projections(cached, cache_entry)
                if cached is not None:
                    return cached
            _write_provider_cache(provider, cache_entry)
            return {"provider": provider, "error": "HTTP 429 — rate limited"}

        if not resp.is_success:
            cached = _provider_payload_from_cache(cache_entry, stale=True)
            return cached if cached is not None else {"provider": provider, "error": f"HTTP {resp.status_code}"}

        data = resp.json()
        windows = []

        if (u := (data.get("five_hour") or {}).get("utilization")) is not None:
            reset_ms = parse_reset((data.get("five_hour") or {}).get("resets_at"))
            windows.append({"label": "5h", "usedPct": clamp(u), "resetIn": fmt_reset(reset_ms), "resetMs": reset_ms})

        if (u := (data.get("seven_day") or {}).get("utilization")) is not None:
            reset_ms = parse_reset((data.get("seven_day") or {}).get("resets_at"))
            windows.append({"label": "Week", "usedPct": clamp(u), "resetIn": fmt_reset(reset_ms), "resetMs": reset_ms})

        for key, label in [("seven_day_sonnet", "Sonnet"), ("seven_day_opus", "Opus")]:
            if (u := (data.get(key) or {}).get("utilization")) is not None:
                windows.append({"label": label, "usedPct": clamp(u), "resetIn": None})

        plan = ""
        try:
            plan = json.loads(CLAUDE_CREDS.read_text()).get("claudeAiOauth", {}).get("subscriptionType", "")
        except Exception:
            pass
        updated_at_ms = int(time.time() * 1000)
        result = {"provider": provider, "plan": plan, "windows": windows, "cachedAtMs": updated_at_ms}
        _write_provider_cache(provider, {
            "payload": {"provider": provider, "plan": plan, "windows": windows},
            "updatedAt": updated_at_ms,
            "backoffUntil": 0.0,
        })
        return result
    except Exception as exc:
        cached = _provider_payload_from_cache(cache_entry, stale=True)
        return cached if cached is not None else {"provider": provider, "error": str(exc)}


async def fetch_codex() -> dict:
    provider = "Codex"
    cache_entry = _get_provider_cache(provider)
    if _provider_cache_is_fresh(cache_entry):
        cached = _provider_payload_from_cache(cache_entry)
        if cached is not None:
            return cached

    if not CODEX_CREDS.exists():
        cached = _provider_payload_from_cache(cache_entry, stale=True)
        return cached if cached is not None else {"provider": provider, "error": "credentials not found"}
    try:
        creds = json.loads(CODEX_CREDS.read_text())
        token = creds.get("tokens", {}).get("access_token", "")
        account_id = creds.get("tokens", {}).get("account_id", "")
        if not token:
            cached = _provider_payload_from_cache(cache_entry, stale=True)
            return cached if cached is not None else {"provider": provider, "error": "no access token"}

        now = time.time()
        backoff_until = float(cache_entry.get("backoffUntil", 0.0)) if cache_entry else 0.0
        if now < backoff_until:
            # Auto-clear backoff if user has re-logged in (last_refresh newer than stale marker)
            stale_since_ms = cache_entry.get("staleSinceMs") if cache_entry else None
            last_refresh_str = creds.get("last_refresh", "")
            if stale_since_ms and last_refresh_str:
                try:
                    last_refresh_ts = datetime.fromisoformat(last_refresh_str.replace("Z", "+00:00")).timestamp()
                    if last_refresh_ts * 1000 > stale_since_ms:
                        print("[codex] credentials updated — clearing backoff to retry")
                        _codex_refreshed_token = None
                        backoff_until = 0.0
                except Exception:
                    pass
        if now < backoff_until:
            cached = _provider_payload_from_cache(cache_entry, stale=True)
            if cached is not None:
                return cached
            return {"provider": provider, "error": cache_entry.get("lastError", "rate limited")}

        headers = {
            "Authorization": f"Bearer {token}",
            "Accept": "application/json",
            "User-Agent": "CodexBar",
        }
        if account_id:
            headers["ChatGPT-Account-Id"] = account_id

        async with httpx.AsyncClient() as client:
            resp = await client.get(
                "https://chatgpt.com/backend-api/wham/usage",
                headers=headers,
                timeout=TIMEOUT,
            )

        if resp.status_code == 401:
            # Try auto-refresh before giving up
            new_token, needs_relogin = await _try_refresh_codex_token()
            if new_token:
                # Retry the request with the refreshed token
                headers["Authorization"] = f"Bearer {new_token}"
                async with httpx.AsyncClient() as client:
                    resp = await client.get(
                        "https://chatgpt.com/backend-api/wham/usage",
                        headers=headers,
                        timeout=TIMEOUT,
                    )
                if resp.is_success:
                    # Fall through to success handling below
                    pass
                else:
                    needs_relogin = True

            if not new_token or not resp.is_success:
                if needs_relogin:
                    # refresh_token itself is dead — long backoff, clear message
                    backoff = 14400  # 4 hours
                    error_msg = "需要重新登录 Codex（运行 codex login）"
                else:
                    backoff = 300
                    error_msg = "HTTP 401 — session expired"
                print(f"[codex] 401 — {error_msg}; backoff {backoff}s")
                if not cache_entry:
                    cache_entry = {}
                cache_entry["backoffUntil"] = now + backoff
                cache_entry["staleSinceMs"] = int(cache_entry.get("staleSinceMs") or now * 1000)
                cache_entry["lastError"] = error_msg
                _write_provider_cache(provider, cache_entry)
                cached = _provider_payload_from_cache(cache_entry, stale=True)
                if cached is not None:
                    return cached
                return {"provider": provider, "error": error_msg}

        if resp.status_code == 429:
            retry_after = 1800
            raw = resp.headers.get("Retry-After", "")
            if raw:
                try:
                    parsed = int(raw)
                    retry_after = parsed if parsed > 0 else 1800
                except ValueError:
                    pass
            print(f"[codex] 429 — backoff {retry_after}s")
            if not cache_entry:
                cache_entry = {}
            cache_entry["backoffUntil"] = now + retry_after
            cache_entry["staleSinceMs"] = int(cache_entry.get("staleSinceMs") or now * 1000)
            cache_entry["lastError"] = "HTTP 429 — rate limited"
            _write_provider_cache(provider, cache_entry)
            cached = _provider_payload_from_cache(cache_entry, stale=True)
            if cached is not None:
                return cached
            return {"provider": provider, "error": "HTTP 429 — rate limited"}

        if not resp.is_success:
            cached = _provider_payload_from_cache(cache_entry, stale=True)
            return cached if cached is not None else {"provider": provider, "error": f"HTTP {resp.status_code}"}

        data = resp.json()
        windows = []
        rl = data.get("rate_limit", {})

        if pw := rl.get("primary_window"):
            h = round((pw.get("limit_window_seconds") or 10800) / 3600)
            reset_ms = (pw["reset_at"] * 1000) if pw.get("reset_at") else None
            windows.append({"label": f"{h}h", "usedPct": clamp(pw.get("used_percent") or 0),
                            "resetIn": fmt_reset(reset_ms), "resetMs": reset_ms})

        if sw := rl.get("secondary_window"):
            h = round((sw.get("limit_window_seconds") or 86400) / 3600)
            pw_reset = rl.get("primary_window", {}).get("reset_at")
            sw_reset = sw.get("reset_at")
            if h >= 168:
                label = "Week"
            elif h < 24:
                label = f"{h}h"
            elif sw_reset and pw_reset and (sw_reset - pw_reset) >= 3 * 86400:
                label = "Week"
            else:
                label = "Day"
            reset_ms = (sw_reset * 1000) if sw_reset else None
            windows.append({"label": label, "usedPct": clamp(sw.get("used_percent") or 0),
                            "resetIn": fmt_reset(reset_ms), "resetMs": reset_ms})

        plan = data.get("plan_type", "")
        if bal := data.get("credits", {}).get("balance"):
            bal_f = float(bal) if not isinstance(bal, float) else bal
            plan = f"{plan} (${bal_f:.2f})" if plan else f"${bal_f:.2f}"

        updated_at_ms = int(time.time() * 1000)
        result = {"provider": provider, "plan": plan, "windows": windows, "cachedAtMs": updated_at_ms}
        _write_provider_cache(provider, {
            "payload": {"provider": provider, "plan": plan, "windows": windows},
            "updatedAt": updated_at_ms,
        })
        return result
    except Exception as exc:
        cached = _provider_payload_from_cache(cache_entry, stale=True)
        return cached if cached is not None else {"provider": provider, "error": str(exc)}


async def _fetch_gemini_quota_metadata() -> tuple[str, dict[str, dict]]:
    token = await get_gemini_access_token()
    if not token:
        return "", {}
    try:
        async with httpx.AsyncClient() as client:
            resp = await client.post(
                "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota",
                headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
                content=b"{}",
                timeout=TIMEOUT,
            )

        if not resp.is_success:
            return "", {}

        data = resp.json()
        plan = data.get("tier", "")
        groups = {}
        for bucket in data.get("buckets", []):
            label = _model_label(bucket.get("modelId") or "unknown")
            frac = float(bucket.get("remainingFraction", 1.0))
            reset_ms = parse_reset(bucket.get("resetTime"))
            if label not in groups:
                groups[label] = {"remainingFraction": 1.0, "resetMs": None}
            if frac < groups[label]["remainingFraction"] or groups[label]["resetMs"] is None:
                groups[label] = {"remainingFraction": frac, "resetMs": reset_ms}

        quota_by_model = {}
        for label, info in groups.items():
            reset_ms = info["resetMs"]
            quota_by_model[label] = {
                "quotaUsedPct": clamp((1 - info["remainingFraction"]) * 100),
                "resetMs": reset_ms,
                "resetIn": fmt_reset(reset_ms),
            }
        return plan, quota_by_model
    except Exception as exc:
        print(f"[gemini-quota] {exc}")
        return "", {}


async def fetch_gemini() -> dict:
    provider = "Gemini"
    cache_entry = _get_provider_cache(provider)
    if _provider_cache_is_fresh(cache_entry):
        cached = _provider_payload_from_cache(cache_entry)
        if cached is not None:
            return cached

    try:
        usage_task = asyncio.to_thread(_collect_gemini_model_usage)
        quota_task = _fetch_gemini_quota_metadata()
        usage, (plan, quota_by_model) = await asyncio.gather(usage_task, quota_task)

        # Build local token lookup by label for enrichment
        local_by_label = {w["label"]: w for w in usage["windows"]}

        windows = []

        if quota_by_model:
            # Quota API is the authoritative source. Show its models as primary windows.
            # Model IDs from the API (e.g. "gemini-2.5-flash") often differ from session
            # file model names (e.g. "gemini-3.1-pro-preview"), so don't rely on label
            # matching to gate display — always render quota windows.
            for label in sorted(quota_by_model.keys()):
                quota = quota_by_model[label]
                window = {
                    "label": label,
                    "usedPct": quota["quotaUsedPct"],
                    "resetIn": quota["resetIn"],
                    "resetMs": quota["resetMs"],
                }
                # Opportunistically enrich with local token counts when labels match.
                local = local_by_label.get(label)
                if local:
                    window["usedTokens"] = local["usedTokens"]
                    window["messages"] = local["messages"]
                    window["lastUsedAtMs"] = local["lastUsedAtMs"]
                windows.append(window)
        else:
            # Quota API unavailable — fall back to local session token history.
            for item in usage["windows"]:
                windows.append(dict(item))

        payload = {"provider": provider, "plan": plan, "windows": windows}

        updated_at_ms = int(time.time() * 1000)
        result = dict(payload)
        result["cachedAtMs"] = updated_at_ms
        _write_provider_cache(provider, {"payload": payload, "updatedAt": updated_at_ms})
        return result
    except Exception as exc:
        cached = _provider_payload_from_cache(cache_entry, stale=True)
        return cached if cached is not None else {"provider": provider, "error": str(exc)}


_status_cache = None
_status_cache_until = datetime.min
_agent_status_cache = None
_agent_status_cache_until = datetime.min

AGENT_ACTIVITY_WINDOW_SECONDS = 3600
CLAUDE_PROJECTS_DIR = Path(os.environ.get("CLAUDE_PROJECTS_DIR", "/creds/claude/projects"))
GEMINI_ROOT_DIR = Path(os.environ.get("GEMINI_ROOT_DIR", "/creds/gemini"))
CODEX_ROOT_DIR = Path(os.environ.get("CODEX_ROOT_DIR", "/creds/codex"))


def _iso8601_utc_from_ts(ts: float | None) -> str | None:
    if ts is None:
        return None
    return datetime.utcfromtimestamp(ts).replace(microsecond=0).isoformat() + "Z"


def _get_process_lines() -> list[str]:
    """Read all process cmdlines from /proc (works with pid:host, no ps needed)."""
    lines = []
    try:
        for pid in os.listdir("/proc"):
            if not pid.isdigit():
                continue
            try:
                cmdline = Path(f"/proc/{pid}/cmdline").read_bytes()
                line = cmdline.replace(b"\x00", b" ").decode(errors="ignore").strip()
                if line:
                    lines.append(line)
            except (FileNotFoundError, PermissionError):
                continue
    except Exception as exc:
        print(f"[agent-status] /proc scan failed: {exc}")
    return lines


def _is_claude_process(line: str) -> bool:
    # /proc/cmdline: "claude" or "claude --dangerously-skip-permissions ..."
    # Exclude bun/node plugin processes
    parts = line.split()
    if not parts:
        return False
    cmd = parts[0]
    return cmd == "claude" or cmd.endswith("/bin/claude")


def _is_codex_process(line: str) -> bool:
    # Each app-server-broker.mjs represents one active Codex session/workspace
    return "app-server-broker.mjs" in line


def _is_gemini_process(line: str) -> bool:
    # node /path/to/bin/gemini
    return "/bin/gemini" in line


def _collect_gemini_model_usage() -> dict:
    groups = {}
    last_updated_ms = None

    if not GEMINI_ROOT_DIR.exists():
        return {"windows": [], "lastUpdatedMs": None}

    try:
        session_paths = list(GEMINI_ROOT_DIR.rglob("chats/session-*.json"))
    except Exception:
        session_paths = []

    for path in session_paths:
        try:
            data = json.loads(path.read_text())
        except Exception:
            continue

        session_fallback_ts = parse_reset(data.get("lastUpdated"))
        for message in data.get("messages", []):
            if message.get("type") != "gemini":
                continue

            label = _model_label(message.get("model") or "unknown")
            tokens = message.get("tokens") or {}
            total_tokens = tokens.get("total")
            if total_tokens is None:
                total_tokens = sum(int(tokens.get(key) or 0) for key in ("input", "output", "cached", "thoughts", "tool"))
            try:
                total_tokens = int(total_tokens)
            except Exception:
                total_tokens = 0

            message_ts = parse_reset(message.get("timestamp")) or session_fallback_ts
            group = groups.setdefault(label, {
                "label": label,
                "usedTokens": 0,
                "messages": 0,
                "lastUsedAtMs": None,
            })
            group["usedTokens"] += max(0, total_tokens)
            group["messages"] += 1

            if message_ts is not None and (group["lastUsedAtMs"] is None or message_ts > group["lastUsedAtMs"]):
                group["lastUsedAtMs"] = message_ts
            if message_ts is not None and (last_updated_ms is None or message_ts > last_updated_ms):
                last_updated_ms = message_ts

    windows = sorted(groups.values(), key=lambda item: (-item["usedTokens"], item["label"]))
    return {"windows": windows, "lastUpdatedMs": last_updated_ms}


def _collect_claude_agent_metrics() -> dict:
    total_sessions = 0
    active_sessions = 0
    recent_messages = 0
    last_active_ts = None
    projects = 0
    now = time.time()

    if not CLAUDE_PROJECTS_DIR.exists():
        return {
            "totalSessions": total_sessions,
            "activeSessions": active_sessions,
            "lastActiveAt": None,
            "projects": projects,
            "recentMessages": recent_messages,
        }

    try:
        projects = sum(1 for path in CLAUDE_PROJECTS_DIR.iterdir() if path.is_dir())
    except Exception:
        projects = 0

    try:
        session_paths = list(CLAUDE_PROJECTS_DIR.rglob("*.jsonl"))
    except Exception:
        session_paths = []

    for path in session_paths:
        try:
            mtime = path.stat().st_mtime
        except OSError:
            continue

        total_sessions += 1
        if last_active_ts is None or mtime > last_active_ts:
            last_active_ts = mtime

        if now - mtime <= AGENT_ACTIVITY_WINDOW_SECONDS:
            active_sessions += 1
            try:
                with path.open("r", encoding="utf-8", errors="ignore") as f:
                    recent_messages += sum(1 for _ in f)
            except OSError:
                pass

    return {
        "totalSessions": total_sessions,
        "activeSessions": active_sessions,
        "lastActiveAt": _iso8601_utc_from_ts(last_active_ts),
        "projects": projects,
        "recentMessages": recent_messages,
    }


def _collect_gemini_agent_metrics() -> dict:
    total_sessions = 0
    active_sessions = 0
    last_active_ts = None
    now = time.time()

    if not GEMINI_ROOT_DIR.exists():
        return {
            "totalSessions": total_sessions,
            "activeSessions": active_sessions,
            "lastActiveAt": None,
        }

    try:
        session_paths = list(GEMINI_ROOT_DIR.rglob("session-*.json"))
    except Exception:
        session_paths = []

    for path in session_paths:
        total_sessions += 1
        try:
            data = json.loads(path.read_text())
        except Exception:
            continue

        last_updated = data.get("lastUpdated")
        if not last_updated:
            continue

        try:
            ts = datetime.fromisoformat(last_updated.replace("Z", "+00:00")).timestamp()
        except Exception:
            continue

        if last_active_ts is None or ts > last_active_ts:
            last_active_ts = ts

        if now - ts <= AGENT_ACTIVITY_WINDOW_SECONDS:
            active_sessions += 1

    return {
        "totalSessions": total_sessions,
        "activeSessions": active_sessions,
        "lastActiveAt": _iso8601_utc_from_ts(last_active_ts),
    }


async def fetch_claude_agent_status(process_lines: list[str]) -> dict:
    process_count = sum(1 for line in process_lines if _is_claude_process(line))
    metrics = await asyncio.to_thread(_collect_claude_agent_metrics)
    return {
        "name": "Claude Code",
        "running": process_count > 0,
        "processCount": process_count,
        **metrics,
    }


def _collect_codex_agent_metrics() -> dict:
    total_sessions = 0
    active_sessions = 0
    last_active_ts = None
    now = time.time()

    index_file = CODEX_ROOT_DIR / "session_index.jsonl"
    if not index_file.exists():
        return {"totalSessions": 0, "activeSessions": 0, "lastActiveAt": None}

    try:
        with index_file.open("r", encoding="utf-8", errors="ignore") as f:
            for line in f:
                line = line.strip()
                if not line:
                    continue
                try:
                    entry = json.loads(line)
                except Exception:
                    continue
                total_sessions += 1
                updated_at = entry.get("updated_at", "")
                if not updated_at:
                    continue
                try:
                    ts = datetime.fromisoformat(updated_at.replace("Z", "+00:00")).timestamp()
                except Exception:
                    continue
                if last_active_ts is None or ts > last_active_ts:
                    last_active_ts = ts
                if now - ts <= AGENT_ACTIVITY_WINDOW_SECONDS:
                    active_sessions += 1
    except Exception as exc:
        print(f"[codex-metrics] {exc}")

    return {
        "totalSessions": total_sessions,
        "activeSessions": active_sessions,
        "lastActiveAt": _iso8601_utc_from_ts(last_active_ts),
    }


async def fetch_codex_agent_status(process_lines: list[str]) -> dict:
    broker_lines = [l for l in process_lines if _is_codex_process(l)]
    process_count = len(broker_lines)
    workspaces = []
    for l in broker_lines:
        parts = l.split()
        for i, p in enumerate(parts):
            if p == "--cwd" and i + 1 < len(parts):
                workspaces.append(parts[i + 1])

    metrics = await asyncio.to_thread(_collect_codex_agent_metrics)
    return {
        "name": "Codex",
        "running": process_count > 0 or metrics["activeSessions"] > 0,
        "processCount": process_count,
        "totalSessions": metrics["totalSessions"],
        "activeSessions": metrics["activeSessions"],
        "lastActiveAt": metrics["lastActiveAt"],
        "projects": len(set(workspaces)) or None,
        "recentMessages": None,
    }


async def fetch_gemini_agent_status(process_lines: list[str]) -> dict:
    process_count = sum(1 for line in process_lines if _is_gemini_process(line))
    metrics = await asyncio.to_thread(_collect_gemini_agent_metrics)
    return {
        "name": "Gemini",
        "running": process_count > 0 or metrics["activeSessions"] > 0,
        "processCount": process_count,
        "totalSessions": metrics["totalSessions"],
        "activeSessions": metrics["activeSessions"],
        "lastActiveAt": metrics["lastActiveAt"],
        "projects": None,
        "recentMessages": None,
    }


async def fetch_anthropic_status() -> dict:
    try:
        async with httpx.AsyncClient(follow_redirects=True) as client:
            resp = await client.get(
                "https://status.anthropic.com/api/v2/summary.json",
                timeout=TIMEOUT,
            )

        resp.raise_for_status()
        data = resp.json()
        status_data = data.get("status", {})
        components = [
            {"name": component.get("name", ""), "status": component.get("status", "")}
            for component in data.get("components", [])
        ]
        incidents = []
        for incident in data.get("incidents", []):
            updates = incident.get("incident_updates", [])
            body = updates[0].get("body", "") if updates else ""
            incidents.append({"name": incident.get("name", ""), "body": body})

        return {
            "provider": "Anthropic",
            "indicator": status_data.get("indicator", "unknown"),
            "description": status_data.get("description", ""),
            "components": components,
            "incidents": incidents,
            "error": None,
        }
    except Exception as exc:
        return {
            "provider": "Anthropic",
            "indicator": "unknown",
            "description": "",
            "components": [],
            "incidents": [],
            "error": str(exc),
        }


async def fetch_openai_status() -> dict:
    try:
        async with httpx.AsyncClient() as client:
            resp = await client.get(
                "https://status.openai.com/api/v2/summary.json",
                timeout=TIMEOUT,
            )

        resp.raise_for_status()
        data = resp.json()
        status_data = data.get("status", {})
        components = [
            {"name": component.get("name", ""), "status": component.get("status", "")}
            for component in data.get("components", [])
        ]
        incidents = []
        for incident in data.get("incidents", []):
            updates = incident.get("incident_updates", [])
            body = updates[0].get("body", "") if updates else ""
            incidents.append({"name": incident.get("name", ""), "body": body})

        return {
            "provider": "OpenAI",
            "indicator": status_data.get("indicator", "unknown"),
            "description": status_data.get("description", ""),
            "components": components,
            "incidents": incidents,
            "error": None,
        }
    except Exception as exc:
        return {
            "provider": "OpenAI",
            "indicator": "unknown",
            "description": "",
            "components": [],
            "incidents": [],
            "error": str(exc),
        }


async def fetch_google_status() -> dict:
    try:
        async with httpx.AsyncClient() as client:
            resp = await client.get(
                "https://status.cloud.google.com/incidents.json",
                timeout=TIMEOUT,
            )

        resp.raise_for_status()
        data = resp.json()
        active_incidents = [incident for incident in data if not incident.get("end")]

        keywords = ("gemini", "ai platform", "vertex", "generative")
        relevant_incidents = [
            incident
            for incident in active_incidents
            if any(keyword in (incident.get("external_desc", "")).lower() for keyword in keywords)
        ]
        selected_incidents = relevant_incidents or active_incidents

        indicator = "none"
        if selected_incidents:
            severities = {(incident.get("severity") or "").lower() for incident in selected_incidents}
            if "high" in severities:
                indicator = "major"
            elif "medium" in severities:
                indicator = "minor"
            else:
                indicator = "minor"

        incidents = []
        for incident in selected_incidents:
            updates = incident.get("updates", [])
            body = updates[0].get("text", "") if updates else ""
            incidents.append({"name": incident.get("external_desc", ""), "body": body})

        return {
            "provider": "Google",
            "indicator": indicator,
            "description": "Active incidents detected" if selected_incidents else "All Systems Operational",
            "components": [],
            "incidents": incidents,
            "error": None,
        }
    except Exception as exc:
        return {
            "provider": "Google",
            "indicator": "unknown",
            "description": "",
            "components": [],
            "incidents": [],
            "error": str(exc),
        }


@app.get("/api/status")
async def get_status():
    global _status_cache, _status_cache_until

    now = datetime.utcnow()
    if _status_cache is not None and now < _status_cache_until:
        return _status_cache

    results = await asyncio.gather(
        fetch_anthropic_status(),
        fetch_openai_status(),
        fetch_google_status(),
        return_exceptions=False,
    )
    response = {
        "updatedAt": datetime.utcnow().isoformat() + "Z",
        "providers": list(results),
    }
    _status_cache = response
    _status_cache_until = datetime.utcfromtimestamp(time.time() + 60)
    return response


@app.get("/api/agent-status")
async def get_agent_status():
    global _agent_status_cache, _agent_status_cache_until

    now = datetime.utcnow()
    if _agent_status_cache is not None and now < _agent_status_cache_until:
        return _agent_status_cache

    process_lines = await asyncio.to_thread(_get_process_lines)
    agents = await asyncio.gather(
        fetch_claude_agent_status(process_lines),
        fetch_codex_agent_status(process_lines),
        fetch_gemini_agent_status(process_lines),
        return_exceptions=False,
    )
    response = {
        "updatedAt": datetime.utcnow().isoformat() + "Z",
        "agents": list(agents),
    }
    _agent_status_cache = response
    _agent_status_cache_until = datetime.utcfromtimestamp(time.time() + 30)
    return response


# ---------- Auth routes ----------

LOGIN_HTML = (
    "<!DOCTYPE html>\n"
    '<html lang="zh-CN">\n'
    "<head>\n"
    '<meta charset="UTF-8">\n'
    '<meta name="viewport" content="width=device-width,initial-scale=1">\n'
    "<title>Login \xb7 LLM Dashboard</title>\n"
    "<style>\n"
    "*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}\n"
    "body{\n"
    "  background:#080b14;\n"
    "  color:#e2e8f0;\n"
    "  font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;\n"
    "  display:flex;align-items:center;justify-content:center;min-height:100vh;\n"
    "  overflow:hidden;\n"
    "}\n"
    "body::before,body::after{\n"
    "  content:'';position:fixed;border-radius:50%;\n"
    "  filter:blur(80px);opacity:.18;pointer-events:none;\n"
    "}\n"
    "body::before{\n"
    "  width:500px;height:500px;\n"
    "  background:radial-gradient(circle,#1e40af,transparent 70%);\n"
    "  top:-120px;left:-120px;\n"
    "  animation:drift1 12s ease-in-out infinite alternate;\n"
    "}\n"
    "body::after{\n"
    "  width:400px;height:400px;\n"
    "  background:radial-gradient(circle,#0ea5e9,transparent 70%);\n"
    "  bottom:-100px;right:-100px;\n"
    "  animation:drift2 10s ease-in-out infinite alternate;\n"
    "}\n"
    "@keyframes drift1{to{transform:translate(60px,80px)}}\n"
    "@keyframes drift2{to{transform:translate(-60px,-60px)}}\n"
    ".bg-grid{\n"
    "  position:fixed;inset:0;z-index:0;\n"
    "  background-image:radial-gradient(rgba(255,255,255,.03) 1px,transparent 1px);\n"
    "  background-size:28px 28px;\n"
    "  pointer-events:none;\n"
    "}\n"
    ".wrap{position:relative;z-index:1;width:100%;max-width:380px;padding:0 20px;}\n"
    ".card{\n"
    "  background:rgba(13,17,32,.92);\n"
    "  border:1px solid rgba(255,255,255,.09);\n"
    "  border-radius:18px;\n"
    "  padding:40px 36px;\n"
    "  box-shadow:0 24px 64px rgba(0,0,0,.6);\n"
    "  backdrop-filter:blur(12px);\n"
    "  position:relative;overflow:hidden;\n"
    "}\n"
    ".card::before{\n"
    "  content:'';position:absolute;top:0;left:0;right:0;height:1px;\n"
    "  background:linear-gradient(90deg,transparent,rgba(255,255,255,.15),transparent);\n"
    "}\n"
    ".logo{\n"
    "  width:52px;height:52px;margin:0 auto 20px;\n"
    "  background:linear-gradient(135deg,#1e3a8a,#3b82f6);\n"
    "  border-radius:14px;\n"
    "  display:flex;align-items:center;justify-content:center;\n"
    "  box-shadow:0 0 24px rgba(59,130,246,.3);\n"
    "}\n"
    ".logo svg{width:26px;height:26px;}\n"
    "h1{font-size:1.3rem;font-weight:700;text-align:center;letter-spacing:-.02em;margin-bottom:4px;}\n"
    ".sub{color:#64748b;font-size:0.78rem;text-align:center;margin-bottom:32px;letter-spacing:.02em;}\n"
    ".field{margin-bottom:16px;}\n"
    "label{display:block;font-size:0.75rem;font-weight:500;color:#94a3b8;letter-spacing:.04em;text-transform:uppercase;margin-bottom:7px;}\n"
    "input{\n"
    "  width:100%;background:rgba(255,255,255,.04);\n"
    "  border:1px solid rgba(255,255,255,.08);border-radius:9px;\n"
    "  color:#e2e8f0;padding:11px 14px;font-size:0.94rem;\n"
    "  font-family:inherit;outline:none;\n"
    "  transition:border-color 150ms,box-shadow 150ms;\n"
    "}\n"
    "input:focus{border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.15);}\n"
    "input::placeholder{color:#475569;}\n"
    ".btn{\n"
    "  width:100%;margin-top:8px;\n"
    "  background:linear-gradient(135deg,#1e40af,#3b82f6);\n"
    "  color:#fff;border:none;border-radius:9px;\n"
    "  padding:12px;font-size:0.95rem;font-weight:600;\n"
    "  font-family:inherit;cursor:pointer;\n"
    "  box-shadow:0 4px 16px rgba(59,130,246,.25);\n"
    "  transition:opacity 150ms,transform 150ms,box-shadow 150ms;\n"
    "}\n"
    ".btn:hover{opacity:.9;transform:translateY(-1px);box-shadow:0 6px 22px rgba(59,130,246,.35);}\n"
    ".btn:active{transform:none;}\n"
    ".err{\n"
    "  display:flex;align-items:center;gap:8px;\n"
    "  color:#fca5a5;font-size:0.8rem;\n"
    "  background:rgba(239,68,68,.07);\n"
    "  border:1px solid rgba(239,68,68,.2);\n"
    "  border-radius:9px;padding:10px 13px;margin-top:16px;\n"
    "}\n"
    ".err svg{width:15px;height:15px;flex-shrink:0;color:#f87171;}\n"
    "@media(prefers-reduced-motion:reduce){body::before,body::after{animation:none}}\n"
    "</style>\n"
    "</head>\n"
    "<body>\n"
    '<div class="bg-grid"></div>\n'
    '<div class="wrap">\n'
    '  <div class="card">\n'
    '    <div class="logo">\n'
    '      <svg viewBox="0 0 24 24" fill="none" stroke="#93c5fd" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">\n'
    '        <rect x="2" y="3" width="20" height="14" rx="2"/>\n'
    '        <path d="M8 21h8M12 17v4"/>\n'
    '        <path d="M6 8h4M6 11h6M6 14h2"/>\n'
    "      </svg>\n"
    "    </div>\n"
    "    <h1>Usage Dashboard</h1>\n"
    '    <div class="sub">\u8bf7\u767b\u5f55\u4ee5\u67e5\u770b\u7528\u91cf\u6570\u636e</div>\n'
    '    <form method="post" action="/auth/login">\n'
    '      <div class="field">\n'
    '        <label for="u">\u7528\u6237\u540d</label>\n'
    '        <input id="u" name="username" type="text" autocomplete="username" placeholder="admin" autofocus required>\n'
    "      </div>\n"
    '      <div class="field">\n'
    '        <label for="p">\u5bc6\u7801</label>\n'
    '        <input id="p" name="password" type="password" autocomplete="current-password" placeholder="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" required>\n'
    "      </div>\n"
    '      <button class="btn" type="submit">\u767b\u5f55</button>\n'
    "      {error}\n"
    "    </form>\n"
    "  </div>\n"
    "</div>\n"
    "</body>\n"
    "</html>"
)


@app.get("/auth/login", response_class=HTMLResponse)
async def login_page():
    return LOGIN_HTML.replace("{error}", "")


@app.post("/auth/login")
async def login(
    username: str = Form(...),
    password: str = Form(...),
):
    if not _check_credentials(username, password):
        html = LOGIN_HTML.replace("{error}", '<div class="err">用户名或密码错误</div>')
        return HTMLResponse(content=html, status_code=401)

    token = _make_session_token()
    _sessions[token] = time.time() + SESSION_TTL

    resp = RedirectResponse(url="/", status_code=302)
    resp.set_cookie(
        key="session",
        value=token,
        max_age=SESSION_TTL,
        httponly=True,
        samesite="lax",
    )
    return resp


@app.get("/auth/logout")
async def logout(session: str | None = Cookie(default=None)):
    _sessions.pop(session or "", None)
    resp = RedirectResponse(url="/auth/login", status_code=302)
    resp.delete_cookie("session")
    return resp


# ---------- Auth middleware ----------

UNPROTECTED = {"/auth/login", "/api/health"}


@app.middleware("http")
async def auth_middleware(request: Request, call_next):
    path = request.url.path
    if path in UNPROTECTED:
        return await call_next(request)

    session = request.cookies.get("session")
    if not _is_valid_session(session):
        if path.startswith("/api/"):
            return JSONResponse({"error": "unauthorized"}, status_code=401)
        return RedirectResponse(url="/auth/login", status_code=302)

    return await call_next(request)


# ---------- API routes ----------

def _read_provider_cache(provider: str) -> dict:
    """Read from cache only — never triggers an external API call."""
    entry = _get_provider_cache(provider)
    is_fresh = _provider_cache_is_fresh(entry)
    cached = _provider_payload_from_cache(entry, stale=not is_fresh)
    if cached is not None:
        if not is_fresh:
            cached = _annotate_reset_projections(cached, entry) or cached
        return cached
    return {"provider": provider, "error": "采集中，请稍候…"}


@app.get("/api/usage")
async def get_usage():
    providers = [_read_provider_cache(p) for p in ("Claude", "Codex", "Gemini")]
    return JSONResponse({"updatedAt": int(time.time() * 1000), "providers": providers})


@app.get("/api/health")
async def health():
    return {"status": "ok"}


# Serve frontend (must be last — catches all remaining paths)
app.mount("/", StaticFiles(directory="/app/static", html=True), name="static")
