use std::{
    io,
    path::PathBuf,
    sync::mpsc,
    thread,
    time::{Duration, Instant},
};

use chrono::{DateTime, Datelike, Local, SecondsFormat, Utc};
use crossterm::{
    event::{self, Event, KeyCode, KeyEventKind},
    execute,
    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
    backend::CrosstermBackend,
    layout::{Alignment, Constraint, Direction, Layout, Rect},
    style::{Color, Modifier, Style},
    symbols::Marker,
    text::{Line, Span},
    widgets::{
        canvas::{Canvas, Points},
        Block, BorderType, Borders, Paragraph, Wrap,
    },
    Frame, Terminal,
};

// Usage limits move slowly; poll gently so we never burden the APIs.
const BASE_INTERVAL: Duration = Duration::from_secs(60);
const MAX_BACKOFF: Duration = Duration::from_secs(15 * 60);
const MANUAL_SPACING: Duration = Duration::from_secs(5);
// Cached results older than this aren't shown at startup (a passed reset
// would make them actively wrong, not just stale).
const CACHE_MAX_AGE: Duration = Duration::from_secs(60 * 60);
// How often a running session re-asks the terminal for its background, so the
// default theme follows live dark/light toggles (lasso, for one, repaints the
// xterm.js palette in place without any signal to the running process).
const BG_CHECK_INTERVAL: Duration = Duration::from_secs(2);
// Runtime re-checks ride the UI thread, so they get a short leash; a miss just
// means the theme updates on a later check.
const BG_CHECK_TIMEOUT: Duration = Duration::from_millis(400);
const USER_AGENT: &str = concat!("clui/", env!("CARGO_PKG_VERSION"));
const CODEX_USAGE_URL: &str = "https://chatgpt.com/backend-api/wham/usage";
const CODEX_REFRESH_URL: &str = "https://auth.openai.com/oauth/token";
const CODEX_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";

// ---------- theme ----------

#[derive(Clone)]
struct Theme {
    gauge_low: Color,  // < 70% used
    gauge_mid: Color,  // 70–89%
    gauge_high: Color, // >= 90%
    ring_bg: Color,
    accent: Color,
    dim: Color,
    error: Color,
    border: Color,
}

impl Theme {
    // Gauge thresholds (Nothing data-status): <70% monochrome (in range),
    // 70-89% warning amber, >=90% accent red.
    fn gauge_color(&self, pct: f64) -> Color {
        if pct >= 90.0 {
            self.gauge_high
        } else if pct >= 70.0 {
            self.gauge_mid
        } else {
            self.gauge_low
        }
    }
}

fn rgb(hex: u32) -> Color {
    Color::Rgb((hex >> 16) as u8, (hex >> 8) as u8, hex as u8)
}

// Nothing design system: monochrome canvas, color only as data status. The
// gauge value reads as *brightness* (white on dark / black on light) while in
// range, shifting to amber then red as it fills — color encodes the value, not
// decoration. Fixed RGB throughout (named ANSI colors can't be trusted to stay
// legible across terminal palettes). Mirrors lasso's --h-* light/dark seam.
fn default_theme(light_bg: bool) -> Theme {
    if light_bg {
        // "Printed technical manual" — black ink on warm off-white paper.
        Theme {
            gauge_low: rgb(0x1a1a1a),  // in range → black ink (text-primary)
            gauge_mid: rgb(0x9a7b1f),  // 70-89% → warning amber (darkened)
            gauge_high: rgb(0xc0141b), // >=90% → Nothing red (darkened)
            ring_bg: rgb(0xcccccc),    // unfilled ring → light seam
            accent: rgb(0x1a1a1a),     // monochrome active signal (black)
            dim: rgb(0x666666),        // text-secondary
            error: rgb(0xc0141b),      // Nothing red
            border: rgb(0xc4c4c4),     // card outline (flat, no shadow)
        }
    } else {
        // "Instrument panel in a dark room" — white data glowing on OLED black.
        Theme {
            gauge_low: rgb(0xededed),  // in range → text-display white
            gauge_mid: rgb(0xd4a843),  // 70-89% → warning amber
            gauge_high: rgb(0xd71921), // >=90% → Nothing red
            ring_bg: rgb(0x2a2a2a),    // unfilled ring → subtle seam
            accent: rgb(0xf0f0f0),     // monochrome active signal (white)
            dim: rgb(0x8a8a8a),        // text-secondary
            error: rgb(0xd71921),      // Nothing red
            border: rgb(0x333333),     // card outline (flat, no shadow)
        }
    }
}

#[derive(Clone, Debug)]
struct UsageBlock {
    percent_used: f64,
    resets_at: Option<DateTime<Utc>>,
}

#[derive(Clone, Debug, Default)]
struct ClaudeUsage {
    five_hour: Option<UsageBlock>,
    seven_day: Option<UsageBlock>,
    seven_day_opus: Option<UsageBlock>,
}

#[derive(Clone, Debug, Default)]
struct CodexUsage {
    session: Option<UsageBlock>,
    weekly: Option<UsageBlock>,
    plan: Option<String>,
}

enum Msg {
    Claude(Result<ClaudeUsage, PollError>),
    Codex(Result<CodexUsage, PollError>),
}

// A failed poll, with enough structure for the UI to decide how loudly to
// report it (rate limits are routine and shown dimly; real errors in red).
#[derive(Clone)]
struct PollError {
    message: String,
    rate_limited: bool,
    retry_in: Duration,
}

struct FetchFailure {
    message: String,
    rate_limited: bool,
    retry_after: Option<Duration>,
}

impl FetchFailure {
    fn other(message: impl Into<String>) -> Self {
        Self {
            message: message.into(),
            rate_limited: false,
            retry_after: None,
        }
    }
}

fn failure_from_ureq(e: ureq::Error) -> FetchFailure {
    match e {
        ureq::Error::Status(429, resp) => FetchFailure {
            message: "Rate limited (HTTP 429)".into(),
            rate_limited: true,
            retry_after: resp
                .header("retry-after")
                .and_then(|s| s.trim().parse::<u64>().ok())
                .map(Duration::from_secs),
        },
        ureq::Error::Status(code, _) => {
            FetchFailure::other(format!("Failed to load usage data: HTTP {code}"))
        }
        other => FetchFailure::other(format!("Failed to load usage data: {other}")),
    }
}

// ---------- Claude ----------

fn claude_token_from_json(raw: &str) -> Option<String> {
    let json = serde_json::from_str::<serde_json::Value>(raw).ok()?;
    let token = json
        .pointer("/claudeAiOauth/accessToken")?
        .as_str()?
        .to_string();
    token.starts_with("sk-ant-oat").then_some(token)
}

fn read_claude_token() -> Result<String, String> {
    let path = dirs::home_dir()
        .ok_or("no home directory")?
        .join(".claude/.credentials.json");
    if let Ok(raw) = std::fs::read_to_string(&path) {
        if let Some(token) = claude_token_from_json(&raw) {
            return Ok(token);
        }
    }
    // Fallback: GNOME keyring.
    if let Ok(out) = std::process::Command::new("secret-tool")
        .args(["lookup", "service", "Claude Code"])
        .output()
    {
        if out.status.success() {
            if let Some(token) = claude_token_from_json(String::from_utf8_lossy(&out.stdout).trim())
            {
                return Ok(token);
            }
        }
    }
    Err("Not available. Make sure you're logged in to Claude Code.".into())
}

fn parse_claude_block(value: Option<&serde_json::Value>) -> Option<UsageBlock> {
    let v = value?;
    let percent_used = v.get("utilization")?.as_f64()?;
    let resets_at = v
        .get("resets_at")
        .and_then(|r| r.as_str())
        .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
        .map(|dt| dt.with_timezone(&Utc));
    Some(UsageBlock {
        percent_used,
        resets_at,
    })
}

fn fetch_claude() -> Result<ClaudeUsage, FetchFailure> {
    let token = read_claude_token().map_err(FetchFailure::other)?;
    let resp = ureq::get("https://api.anthropic.com/api/oauth/usage")
        .set("Content-Type", "application/json")
        .set("User-Agent", USER_AGENT)
        .set("Authorization", &format!("Bearer {token}"))
        .set("anthropic-beta", "oauth-2025-04-20")
        .timeout(Duration::from_secs(10))
        .call()
        .map_err(failure_from_ureq)?;
    let json: serde_json::Value = resp
        .into_json()
        .map_err(|e| FetchFailure::other(format!("Failed to load usage data: {e}")))?;
    Ok(ClaudeUsage {
        five_hour: parse_claude_block(json.get("five_hour")),
        seven_day: parse_claude_block(json.get("seven_day")),
        seven_day_opus: parse_claude_block(json.get("seven_day_opus")),
    })
}

// ---------- Codex (same flow as CodexBar's CodexOAuthUsageFetcher) ----------

fn codex_auth_path() -> PathBuf {
    std::env::var("CODEX_HOME")
        .ok()
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
        .map(PathBuf::from)
        .unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join(".codex"))
        .join("auth.json")
}

fn parse_codex_window(value: Option<&serde_json::Value>) -> Option<(i64, UsageBlock)> {
    let v = value?;
    let percent_used = v.get("used_percent")?.as_f64()?;
    let window_secs = v
        .get("limit_window_seconds")
        .and_then(|w| w.as_i64())
        .unwrap_or(0);
    let resets_at = v
        .get("reset_at")
        .and_then(|r| r.as_i64())
        .and_then(DateTime::from_timestamp_secs);
    Some((
        window_secs,
        UsageBlock {
            percent_used,
            resets_at,
        },
    ))
}

enum CodexFetchError {
    Unauthorized,
    Failure(FetchFailure),
}

impl CodexFetchError {
    fn into_failure(self) -> FetchFailure {
        match self {
            CodexFetchError::Unauthorized => {
                FetchFailure::other("Unauthorized. Run `codex` to re-authenticate.")
            }
            CodexFetchError::Failure(f) => f,
        }
    }
}

fn request_codex_usage(
    token: &str,
    account_id: Option<&str>,
) -> Result<CodexUsage, CodexFetchError> {
    let mut req = ureq::get(CODEX_USAGE_URL)
        .set("Authorization", &format!("Bearer {token}"))
        .set("Accept", "application/json")
        .set("User-Agent", USER_AGENT)
        .timeout(Duration::from_secs(10));
    if let Some(account) = account_id.filter(|a| !a.is_empty()) {
        req = req.set("ChatGPT-Account-Id", account);
    }
    let resp = req.call().map_err(|e| match e {
        ureq::Error::Status(401 | 403, _) => CodexFetchError::Unauthorized,
        other => CodexFetchError::Failure(failure_from_ureq(other)),
    })?;
    let json: serde_json::Value = resp.into_json().map_err(|e| {
        CodexFetchError::Failure(FetchFailure::other(format!(
            "Failed to load usage data: {e}"
        )))
    })?;

    // Smaller window is the 5h session, larger the weekly (CodexBar's normalizer).
    let mut windows: Vec<(i64, UsageBlock)> = [
        parse_codex_window(json.pointer("/rate_limit/primary_window")),
        parse_codex_window(json.pointer("/rate_limit/secondary_window")),
    ]
    .into_iter()
    .flatten()
    .collect();
    windows.sort_by_key(|(secs, _)| *secs);

    let mut usage = CodexUsage {
        plan: json
            .get("plan_type")
            .and_then(|p| p.as_str())
            .map(format_codex_plan),
        ..Default::default()
    };
    match windows.len() {
        2 => {
            let mut it = windows.into_iter();
            usage.session = Some(it.next().unwrap().1);
            usage.weekly = Some(it.next().unwrap().1);
        }
        1 => {
            let (secs, block) = windows.pop().unwrap();
            if secs <= 6 * 3600 {
                usage.session = Some(block);
            } else {
                usage.weekly = Some(block);
            }
        }
        _ => {}
    }
    Ok(usage)
}

// CodexBar's CodexPlanFormatting: "pro" → "Pro 20x", "pro_lite" → "Pro 5x", else title case.
fn format_codex_plan(plan: &str) -> String {
    match plan {
        "pro" => "Pro 20x".into(),
        "pro_lite" | "prolite" => "Pro 5x".into(),
        other => other
            .split(['_', ' '])
            .filter(|w| !w.is_empty())
            .map(|w| {
                let mut c = w.chars();
                match c.next() {
                    Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
                    None => String::new(),
                }
            })
            .collect::<Vec<_>>()
            .join(" "),
    }
}

// POST auth.openai.com/oauth/token and persist new tokens back to auth.json,
// preserving unrelated keys — same behavior as CodexBar's CodexTokenRefresher.
fn refresh_codex_tokens(
    path: &PathBuf,
    auth: &mut serde_json::Value,
) -> Result<String, String> {
    let refresh_token = auth
        .pointer("/tokens/refresh_token")
        .and_then(|v| v.as_str())
        .ok_or("no refresh token in auth.json")?
        .to_string();
    let resp = ureq::post(CODEX_REFRESH_URL)
        .set("Content-Type", "application/json")
        .set("User-Agent", USER_AGENT)
        .timeout(Duration::from_secs(10))
        .send_json(serde_json::json!({
            "client_id": CODEX_CLIENT_ID,
            "grant_type": "refresh_token",
            "refresh_token": refresh_token,
            "scope": "openid profile email",
        }))
        .map_err(|e| format!("token refresh failed: {e}"))?;
    let json: serde_json::Value = resp
        .into_json()
        .map_err(|e| format!("token refresh failed: {e}"))?;
    let access = json
        .get("access_token")
        .and_then(|v| v.as_str())
        .ok_or("token refresh returned no access_token")?
        .to_string();
    if let Some(tokens) = auth.get_mut("tokens") {
        tokens["access_token"] = serde_json::Value::String(access.clone());
        for key in ["refresh_token", "id_token"] {
            if let Some(v) = json.get(key).and_then(|v| v.as_str()) {
                tokens[key] = serde_json::Value::String(v.to_string());
            }
        }
    }
    auth["last_refresh"] = serde_json::Value::String(
        Utc::now().to_rfc3339_opts(SecondsFormat::Micros, true),
    );
    if let Ok(serialized) = serde_json::to_string_pretty(auth) {
        let _ = std::fs::write(path, serialized);
    }
    Ok(access)
}

fn fetch_codex() -> Result<CodexUsage, FetchFailure> {
    let path = codex_auth_path();
    let raw = std::fs::read_to_string(&path)
        .map_err(|_| FetchFailure::other("Not available. Run `codex` to sign in."))?;
    let mut auth: serde_json::Value = serde_json::from_str(&raw)
        .map_err(|e| FetchFailure::other(format!("bad auth.json: {e}")))?;

    // Plain API-key auth: use it directly, no refresh, no account header.
    if let Some(key) = auth
        .get("OPENAI_API_KEY")
        .and_then(|v| v.as_str())
        .filter(|s| !s.is_empty())
    {
        return request_codex_usage(key, None).map_err(CodexFetchError::into_failure);
    }

    let mut access = auth
        .pointer("/tokens/access_token")
        .and_then(|v| v.as_str())
        .ok_or_else(|| FetchFailure::other("Not available. Run `codex` to sign in."))?
        .to_string();
    let account_id = auth
        .pointer("/tokens/account_id")
        .and_then(|v| v.as_str())
        .map(str::to_string);

    // Proactive refresh when last_refresh is missing or older than 8 days (CodexBar policy).
    let stale = auth
        .get("last_refresh")
        .and_then(|v| v.as_str())
        .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
        .map(|t| Utc::now() - t.with_timezone(&Utc) > chrono::Duration::days(8))
        .unwrap_or(true);
    let mut refreshed = false;
    if stale {
        if let Ok(new_access) = refresh_codex_tokens(&path, &mut auth) {
            access = new_access;
            refreshed = true;
        }
    }

    match request_codex_usage(&access, account_id.as_deref()) {
        Ok(usage) => Ok(usage),
        Err(CodexFetchError::Unauthorized) if !refreshed => {
            let new_access = refresh_codex_tokens(&path, &mut auth).map_err(|_| {
                FetchFailure::other("Unauthorized. Run `codex` to re-authenticate.")
            })?;
            request_codex_usage(&new_access, account_id.as_deref())
                .map_err(CodexFetchError::into_failure)
        }
        Err(e) => Err(e.into_failure()),
    }
}

// ---------- disk cache ----------
// Successful fetches are persisted so a fresh launch paints immediately and,
// while the cache is younger than BASE_INTERVAL, skips the startup request —
// Anthropic rate-limits the usage endpoint aggressively, and restarting the
// TUI shouldn't cost a request.

fn cache_path() -> PathBuf {
    dirs::cache_dir().unwrap_or_default().join("clui/cache.json")
}

fn block_to_json(b: &UsageBlock) -> serde_json::Value {
    let mut o = serde_json::Map::new();
    o.insert("percent_used".into(), b.percent_used.into());
    if let Some(r) = b.resets_at {
        o.insert("resets_at".into(), r.to_rfc3339().into());
    }
    serde_json::Value::Object(o)
}

fn block_from_json(v: Option<&serde_json::Value>) -> Option<UsageBlock> {
    let v = v?;
    Some(UsageBlock {
        percent_used: v.get("percent_used")?.as_f64()?,
        resets_at: v
            .get("resets_at")
            .and_then(|r| r.as_str())
            .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
            .map(|dt| dt.with_timezone(&Utc)),
    })
}

fn claude_to_json(u: &ClaudeUsage) -> serde_json::Value {
    let mut o = serde_json::Map::new();
    for (key, block) in [
        ("five_hour", &u.five_hour),
        ("seven_day", &u.seven_day),
        ("seven_day_opus", &u.seven_day_opus),
    ] {
        if let Some(b) = block {
            o.insert(key.into(), block_to_json(b));
        }
    }
    serde_json::Value::Object(o)
}

fn claude_from_json(v: &serde_json::Value) -> ClaudeUsage {
    ClaudeUsage {
        five_hour: block_from_json(v.get("five_hour")),
        seven_day: block_from_json(v.get("seven_day")),
        seven_day_opus: block_from_json(v.get("seven_day_opus")),
    }
}

fn codex_to_json(u: &CodexUsage) -> serde_json::Value {
    let mut o = serde_json::Map::new();
    if let Some(b) = &u.session {
        o.insert("session".into(), block_to_json(b));
    }
    if let Some(b) = &u.weekly {
        o.insert("weekly".into(), block_to_json(b));
    }
    if let Some(p) = &u.plan {
        o.insert("plan".into(), p.clone().into());
    }
    serde_json::Value::Object(o)
}

fn codex_from_json(v: &serde_json::Value) -> CodexUsage {
    CodexUsage {
        session: block_from_json(v.get("session")),
        weekly: block_from_json(v.get("weekly")),
        plan: v.get("plan").and_then(|p| p.as_str()).map(str::to_string),
    }
}

struct Cached<T> {
    data: T,
    fetched_at: DateTime<Utc>,
}

struct DiskCache {
    path: PathBuf,
    root: serde_json::Value,
}

impl DiskCache {
    fn load() -> Self {
        let path = cache_path();
        let root = std::fs::read_to_string(&path)
            .ok()
            .and_then(|raw| serde_json::from_str::<serde_json::Value>(&raw).ok())
            .filter(|v| v.is_object())
            .unwrap_or_else(|| serde_json::json!({}));
        Self { path, root }
    }

    fn provider<T>(&self, key: &str, parse: impl Fn(&serde_json::Value) -> T) -> Option<Cached<T>> {
        let v = self.root.get(key)?;
        let fetched_at = v
            .get("fetched_at")?
            .as_str()
            .and_then(|s| DateTime::parse_from_rfc3339(s).ok())?
            .with_timezone(&Utc);
        let age = (Utc::now() - fetched_at).to_std().unwrap_or(Duration::ZERO);
        (age <= CACHE_MAX_AGE).then(|| Cached {
            data: parse(v),
            fetched_at,
        })
    }

    fn store(&mut self, key: &str, mut value: serde_json::Value) {
        value["fetched_at"] = Utc::now().to_rfc3339().into();
        self.root[key] = value;
        if let Some(dir) = self.path.parent() {
            let _ = std::fs::create_dir_all(dir);
        }
        if let Ok(raw) = serde_json::to_string(&self.root) {
            let _ = std::fs::write(&self.path, raw);
        }
    }
}

// Hold the first fetch until the cached result turns BASE_INTERVAL old.
fn startup_delay(fetched_at: Option<DateTime<Utc>>) -> Duration {
    let Some(t) = fetched_at else {
        return Duration::ZERO;
    };
    match (Utc::now() - t).to_std() {
        Ok(age) => BASE_INTERVAL.saturating_sub(age),
        Err(_) => Duration::ZERO, // timestamp in the future: clock skew, just fetch
    }
}

// ---------- formatting ----------

// "45m" / "2h 30m".
fn format_time_remaining(reset: DateTime<Utc>) -> String {
    let mins = (reset - Utc::now()).num_minutes().max(0);
    if mins < 60 {
        format!("{mins}m")
    } else {
        format!("{}h {}m", mins / 60, mins % 60)
    }
}

// "14:30" / "Tomorrow 14:30" / "Wed 14:30" / "Dec 24 14:30".
fn format_reset_date(reset: DateTime<Utc>) -> String {
    let local = reset.with_timezone(&Local);
    let now = Local::now();
    let days = (local.date_naive() - now.date_naive()).num_days();
    let time = local.format("%H:%M");
    if days <= 0 {
        format!("{time}")
    } else if days == 1 {
        format!("Tomorrow {time}")
    } else if days < 7 {
        format!("{} {time}", local.format("%a"))
    } else {
        format!("{} {} {time}", local.format("%b"), local.day())
    }
}

// Percent of the 7-day window ending at `reset` that has elapsed.
fn week_progress_percent(reset: DateTime<Utc>) -> i64 {
    let now = Utc::now();
    let week = chrono::Duration::days(7);
    let week_ms = week.num_milliseconds() as f64;
    if now > reset {
        (((now - reset).num_milliseconds() as f64 / week_ms) * 100.0).round() as i64
    } else {
        let start = reset - week;
        let pct = ((now - start).num_milliseconds() as f64 / week_ms) * 100.0;
        (pct.round() as i64).clamp(0, 100)
    }
}

// ---------- cards ----------

struct Card {
    label: &'static str,
    subtitle: String,
    percent: f64,
}

fn build_claude_cards(data: &ClaudeUsage) -> Vec<Card> {
    let mut cards = Vec::new();
    if let Some(b) = &data.five_hour {
        cards.push(Card {
            label: "5-Hour Block",
            subtitle: b
                .resets_at
                .map(|r| format!("Resets in {}", format_time_remaining(r)))
                .unwrap_or_default(),
            percent: b.percent_used,
        });
    }
    if let Some(b) = &data.seven_day {
        cards.push(Card {
            label: "7-Day Rolling",
            subtitle: b
                .resets_at
                .map(|r| {
                    format!(
                        "Resets {} · {}% through week",
                        format_reset_date(r),
                        week_progress_percent(r)
                    )
                })
                .unwrap_or_default(),
            percent: b.percent_used,
        });
    }
    if let Some(b) = &data.seven_day_opus {
        cards.push(Card {
            label: "Opus Weekly",
            subtitle: b
                .resets_at
                .map(|r| format!("Resets {}", format_reset_date(r)))
                .unwrap_or_default(),
            percent: b.percent_used,
        });
    }
    cards
}

fn build_codex_cards(data: &CodexUsage) -> Vec<Card> {
    let mut cards = Vec::new();
    if let Some(b) = &data.session {
        cards.push(Card {
            label: "Session",
            subtitle: b
                .resets_at
                .map(|r| format!("Resets in {}", format_time_remaining(r)))
                .unwrap_or_default(),
            percent: b.percent_used,
        });
    }
    if let Some(b) = &data.weekly {
        cards.push(Card {
            label: "Weekly",
            subtitle: b
                .resets_at
                .map(|r| format!("Resets {}", format_reset_date(r)))
                .unwrap_or_default(),
            percent: b.percent_used,
        });
    }
    cards
}

// ---------- rendering ----------

fn render_gauge(frame: &mut Frame, area: Rect, percent: f64, color: Color, ring_bg: Color) {
    let canvas = Canvas::default()
        .marker(Marker::Braille)
        .x_bounds([-1.15, 1.15])
        .y_bounds([-1.15, 1.15])
        .paint(move |ctx| {
            // Fill the whole annulus by testing every Braille dot (2x4 per cell)
            // against it, so ring cells render fully set instead of as sparse dots.
            let (r_inner, r_outer) = (0.78, 1.0);
            let sweep = (percent.clamp(0.0, 100.0) / 100.0) * 360.0;
            let dots_x = area.width as usize * 2;
            let dots_y = area.height as usize * 4;
            let mut bg: Vec<(f64, f64)> = Vec::new();
            let mut fg: Vec<(f64, f64)> = Vec::new();
            for iy in 0..dots_y {
                let y = -1.15 + (iy as f64 + 0.5) * 2.3 / dots_y as f64;
                for ix in 0..dots_x {
                    let x = -1.15 + (ix as f64 + 0.5) * 2.3 / dots_x as f64;
                    let r = (x * x + y * y).sqrt();
                    if !(r_inner..=r_outer).contains(&r) {
                        continue;
                    }
                    // degrees clockwise from 12 o'clock
                    let t = x.atan2(y).to_degrees().rem_euclid(360.0);
                    if t < sweep {
                        fg.push((x, y));
                    } else {
                        bg.push((x, y));
                    }
                }
            }
            ctx.draw(&Points {
                coords: &bg,
                color: ring_bg,
            });
            ctx.draw(&Points { coords: &fg, color });
        });
    frame.render_widget(canvas, area);

    // Percentage centered in the ring's hole.
    let text = format!("{:.0}%", percent);
    let w = (text.len() as u16).min(area.width);
    let label_area = Rect {
        x: area.x + (area.width.saturating_sub(w)) / 2,
        y: area.y + area.height / 2,
        width: w,
        height: 1,
    };
    frame.render_widget(
        Paragraph::new(text).style(Style::default().fg(color).add_modifier(Modifier::BOLD)),
        label_area,
    );
}

fn render_card(frame: &mut Frame, area: Rect, card: &Card, theme: &Theme) {
    let block = Block::default()
        .borders(Borders::ALL)
        .border_type(BorderType::Rounded)
        .border_style(Style::default().fg(theme.border));
    let inner = block.inner(area);
    frame.render_widget(block, area);
    if inner.width < 10 || inner.height < 3 {
        return;
    }

    // Square-ish gauge on the left (2 cols ≈ 1 row in terminal cells), text on the right.
    // Pin the rect to that 2:1 ratio — Braille dots are 2x4 per cell, so 2:1 in cells
    // is square in dot space — and center it vertically so narrow terminals (tall
    // cards) get a circle instead of a stretched ellipse.
    let gauge_w = (inner.height * 2).clamp(8, inner.width / 2);
    let gauge_h = (gauge_w / 2).max(4).min(inner.height);
    let chunks = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([
            Constraint::Length(gauge_w),
            Constraint::Length(1),
            Constraint::Min(0),
        ])
        .split(inner);

    let gauge_area = Rect {
        x: chunks[0].x,
        y: inner.y + (inner.height - gauge_h) / 2,
        width: chunks[0].width,
        height: gauge_h,
    };
    render_gauge(
        frame,
        gauge_area,
        card.percent,
        theme.gauge_color(card.percent),
        theme.ring_bg,
    );

    let text_area = chunks[2];
    let pad = text_area.height.saturating_sub(3) / 2;
    let mut lines: Vec<Line> = (0..pad).map(|_| Line::raw("")).collect();
    lines.push(Line::from(Span::styled(
        card.label,
        Style::default().add_modifier(Modifier::BOLD),
    )));
    lines.push(Line::raw(""));
    lines.push(Line::from(Span::styled(
        card.subtitle.clone(),
        Style::default().fg(theme.dim),
    )));
    frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: true }), text_area);
}

enum Row {
    Title(Line<'static>),
    Cards(Vec<Card>),
    Notice(String, Color),
}

struct Provider {
    error: Option<PollError>,
    cards: Option<Vec<Card>>,
    title: Line<'static>,
}

fn provider_rows(p: Provider, rows: &mut Vec<Row>, theme: &Theme) {
    let mut title = p.title;
    if p.cards.is_some() {
        if let Some(err) = &p.error {
            // Stale data stays up; note the failed refresh quietly in dim —
            // retries are routine and shouldn't read like alarms.
            let note = if err.rate_limited {
                format!("  · rate limited, retrying in {}", format_delay(err.retry_in))
            } else {
                format!("  · refresh failed, retrying in {}", format_delay(err.retry_in))
            };
            title.push_span(Span::styled(note, Style::default().fg(theme.dim)));
        }
    }
    rows.push(Row::Title(title));
    match (p.cards, p.error) {
        (Some(cards), _) if cards.is_empty() => {
            rows.push(Row::Notice("No usage limits reported.".into(), theme.dim));
        }
        (Some(cards), _) => {
            let mut cards = cards;
            while !cards.is_empty() {
                let rest = cards.split_off(cards.len().min(2));
                rows.push(Row::Cards(cards));
                cards = rest;
            }
        }
        (None, Some(err)) if err.rate_limited => rows.push(Row::Notice(
            format!("Rate limited — retrying in {}", format_delay(err.retry_in)),
            theme.dim,
        )),
        (None, Some(err)) => rows.push(Row::Notice(err.message, theme.error)),
        (None, None) => rows.push(Row::Notice("Loading…".into(), theme.dim)),
    }
}

struct App {
    claude: Option<ClaudeUsage>,
    claude_error: Option<PollError>,
    codex: Option<CodexUsage>,
    codex_error: Option<PollError>,
    last_updated: Option<DateTime<Local>>,
    theme: Theme,
    light_bg: bool,
    // When set, the user has pinned the theme with `t` (or `--theme`), so
    // background auto-detection stops overriding their choice. None = follow
    // the terminal; Some(true) = forced light; Some(false) = forced dark.
    theme_override: Option<bool>,
}

fn render_app(frame: &mut Frame, app: &App) {
    let theme = &app.theme;
    let outer = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Min(0), Constraint::Length(1)])
        .split(frame.area());

    let codex_title = match app.codex.as_ref().and_then(|c| c.plan.as_deref()) {
        Some(plan) => Line::from(vec![
            Span::styled(" Codex ", Style::default().add_modifier(Modifier::BOLD)),
            Span::styled(format!("· {plan}"), Style::default().fg(theme.dim)),
        ]),
        None => Line::from(Span::styled(
            " Codex",
            Style::default().add_modifier(Modifier::BOLD),
        )),
    };

    let mut rows = Vec::new();
    provider_rows(
        Provider {
            cards: app.claude.as_ref().map(build_claude_cards),
            error: app.claude_error.clone(),
            title: Line::from(vec![
                Span::styled(
                    " Claude Code ",
                    Style::default().add_modifier(Modifier::BOLD),
                ),
                Span::styled("· Usage Limits", Style::default().fg(theme.dim)),
            ]),
        },
        &mut rows,
        theme,
    );
    provider_rows(
        Provider {
            cards: app.codex.as_ref().map(build_codex_cards),
            error: app.codex_error.clone(),
            title: codex_title,
        },
        &mut rows,
        theme,
    );

    // Card height is capped by what the gauge can use: gauge width maxes out at
    // half the card's inner width, so rows taller than that are dead space.
    // Shrink evenly instead when the terminal is too short for the cap.
    let card_inner_w = (outer[0].width / 2).saturating_sub(2);
    let ideal_card_h = (card_inner_w / 4 + 2).max(8);
    let fixed_h: u16 = rows
        .iter()
        .map(|r| match r {
            Row::Title(_) => 1,
            Row::Cards(_) => 0,
            Row::Notice(..) => 3,
        })
        .sum();
    let card_rows = rows.iter().filter(|r| matches!(r, Row::Cards(_))).count() as u16;
    let card_h = if card_rows > 0 {
        ideal_card_h.min(outer[0].height.saturating_sub(fixed_h) / card_rows)
    } else {
        0
    };

    let mut constraints: Vec<Constraint> = rows
        .iter()
        .map(|r| match r {
            Row::Title(_) => Constraint::Length(1),
            Row::Cards(_) => Constraint::Length(card_h),
            Row::Notice(..) => Constraint::Length(3),
        })
        .collect();
    constraints.push(Constraint::Min(0)); // leftover space falls below the cards
    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints(constraints)
        .split(outer[0]);

    for (row, chunk) in rows.into_iter().zip(chunks.iter()) {
        match row {
            Row::Title(line) => frame.render_widget(Paragraph::new(line), *chunk),
            Row::Cards(cards) => {
                let cols = Layout::default()
                    .direction(Direction::Horizontal)
                    .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
                    .split(*chunk);
                for (i, card) in cards.iter().enumerate() {
                    render_card(frame, cols[i], card, theme);
                }
            }
            Row::Notice(text, color) => frame.render_widget(
                Paragraph::new(text)
                    .style(Style::default().fg(color))
                    .wrap(Wrap { trim: true })
                    .alignment(Alignment::Center),
                *chunk,
            ),
        }
    }

    let mut footer = vec![
        Span::styled(" q ", Style::default().fg(theme.accent)),
        Span::styled("quit  ", Style::default().fg(theme.dim)),
        Span::styled("r ", Style::default().fg(theme.accent)),
        Span::styled("refresh  ", Style::default().fg(theme.dim)),
        Span::styled("t ", Style::default().fg(theme.accent)),
        Span::styled(
            format!(
                "theme:{}",
                match app.theme_override {
                    None => "auto",
                    Some(true) => "light",
                    Some(false) => "dark",
                }
            ),
            Style::default().fg(theme.dim),
        ),
    ];
    if let Some(t) = app.last_updated {
        // Cached results can predate today; include the date when they do.
        let stamp = if t.date_naive() == Local::now().date_naive() {
            t.format("%H:%M:%S").to_string()
        } else {
            format!("{} {} {}", t.format("%b"), t.day(), t.format("%H:%M"))
        };
        footer.push(Span::styled(
            format!("  ·  updated {stamp}"),
            Style::default().fg(theme.dim),
        ));
    }
    frame.render_widget(Paragraph::new(Line::from(footer)), outer[1]);
}

// Per-provider schedule: 60s cadence normally; on HTTP 429 back off
// exponentially (up to 15 min), honoring Retry-After when the server sends one.
struct Poller {
    next: Instant,
    backoff: Duration,
    rate_limited: bool,
    last_attempt: Option<Instant>,
}

impl Poller {
    // A fresh disk cache pushes the first fetch out via `initial_delay`.
    fn new(initial_delay: Duration) -> Self {
        Self {
            next: Instant::now() + initial_delay,
            backoff: BASE_INTERVAL,
            rate_limited: false,
            last_attempt: None,
        }
    }

    fn poll<T>(&mut self, fetch: impl FnOnce() -> Result<T, FetchFailure>) -> Option<Result<T, PollError>> {
        if Instant::now() < self.next {
            return None;
        }
        self.last_attempt = Some(Instant::now());
        match fetch() {
            Ok(data) => {
                self.backoff = BASE_INTERVAL;
                self.rate_limited = false;
                self.next = Instant::now() + BASE_INTERVAL;
                Some(Ok(data))
            }
            Err(failure) => {
                self.rate_limited = failure.rate_limited;
                let delay = if failure.rate_limited {
                    self.backoff = (self.backoff * 2).min(MAX_BACKOFF);
                    self.backoff.max(failure.retry_after.unwrap_or(Duration::ZERO))
                } else {
                    self.backoff = BASE_INTERVAL;
                    BASE_INTERVAL
                };
                self.next = Instant::now() + delay;
                Some(Err(PollError {
                    message: failure.message,
                    rate_limited: failure.rate_limited,
                    retry_in: delay,
                }))
            }
        }
    }

    // Manual refresh: pull the next fetch forward, but never bypass a 429
    // backoff and never fire more often than MANUAL_SPACING.
    fn request_now(&mut self) {
        if self.rate_limited {
            return;
        }
        let earliest = self
            .last_attempt
            .map(|t| t + MANUAL_SPACING)
            .unwrap_or_else(Instant::now)
            .max(Instant::now());
        self.next = self.next.min(earliest);
    }
}

fn format_delay(d: Duration) -> String {
    let secs = d.as_secs();
    if secs < 120 {
        format!("{secs}s")
    } else {
        format!("{}m", secs.div_ceil(60))
    }
}

// ---------- cli ----------

const HELP: &str = "\
clui — TUI for Claude Code and Codex usage limits

USAGE
  clui [OPTIONS]

OPTIONS
  -h, --help            Show this help
  -V, --version         Show version
      --theme <MODE>    Force the palette: auto (default), light, or dark

KEYS
  q/Esc quit · r refresh · t cycle theme (auto → light → dark)

The color palette adapts to the terminal background, queried at startup and
every couple of seconds while running, so live dark/light toggles follow.
Terminals that drop the background query (some web/embedded terminals) leave
it stuck on dark — use `t` or `--theme light` to pin the palette by hand.
";

// Returns the forced theme: None = follow the terminal, Some(true) = light,
// Some(false) = dark.
fn parse_cli() -> Result<Option<bool>, String> {
    let mut args = std::env::args().skip(1);
    let mut theme_override: Option<bool> = None;
    while let Some(arg) = args.next() {
        match arg.as_str() {
            "-h" | "--help" => {
                print!("{HELP}");
                std::process::exit(0);
            }
            "-V" | "--version" => {
                println!("clui {}", env!("CARGO_PKG_VERSION"));
                std::process::exit(0);
            }
            "--theme" => {
                let val = args
                    .next()
                    .ok_or("`--theme` needs a value (auto|light|dark)")?;
                theme_override = match val.as_str() {
                    "auto" => None,
                    "light" => Some(true),
                    "dark" => Some(false),
                    other => {
                        return Err(format!("invalid --theme `{other}` (auto|light|dark)"))
                    }
                };
            }
            other => return Err(format!("unknown argument `{other}` (try --help)")),
        }
    }
    Ok(theme_override)
}

// Asks the terminal for its background (OSC 10/11 with env fallbacks, via
// terminal-colorsaurus): Some(true) = light, Some(false) = dark, None = no
// answer. Terminals without color queries fail fast (DA1 fallback), so the
// timeout is only hit when nothing responds at all.
fn query_background(timeout: Duration) -> Option<bool> {
    use terminal_colorsaurus::{theme_mode, QueryOptions, ThemeMode};
    let mut opts = QueryOptions::default();
    opts.timeout = timeout;
    match theme_mode(opts) {
        Ok(ThemeMode::Light) => Some(true),
        Ok(ThemeMode::Dark) => Some(false),
        Err(_) => None,
    }
}

fn main() -> io::Result<()> {
    let theme_override = match parse_cli() {
        Ok(o) => o,
        Err(e) => {
            eprintln!("clui: {e}");
            std::process::exit(2);
        }
    };
    // A forced theme wins; otherwise fall back to the terminal's background.
    let light_bg = theme_override.unwrap_or(query_background(Duration::from_secs(1)) == Some(true));

    // Seed the UI from the disk cache so a fresh launch paints immediately
    // and doesn't spend a request while the cached data is still fresh.
    let mut cache = DiskCache::load();
    let claude_cached = cache.provider("claude", claude_from_json);
    let codex_cached = cache.provider("codex", codex_from_json);
    let claude_delay = startup_delay(claude_cached.as_ref().map(|c| c.fetched_at));
    let codex_delay = startup_delay(codex_cached.as_ref().map(|c| c.fetched_at));
    let cached_at = claude_cached
        .as_ref()
        .map(|c| c.fetched_at)
        .into_iter()
        .chain(codex_cached.as_ref().map(|c| c.fetched_at))
        .max();
    let claude_seed = claude_cached.map(|c| c.data);
    let codex_seed = codex_cached.map(|c| c.data);

    let (tx_msg, rx_msg) = mpsc::channel::<Msg>();
    let (tx_cmd, rx_cmd) = mpsc::channel::<()>();
    thread::spawn(move || {
        let mut claude = Poller::new(claude_delay);
        let mut codex = Poller::new(codex_delay);
        loop {
            if let Some(result) = claude.poll(fetch_claude) {
                if let Ok(data) = &result {
                    cache.store("claude", claude_to_json(data));
                }
                if tx_msg.send(Msg::Claude(result)).is_err() {
                    return;
                }
            }
            if let Some(result) = codex.poll(fetch_codex) {
                if let Ok(data) = &result {
                    cache.store("codex", codex_to_json(data));
                }
                if tx_msg.send(Msg::Codex(result)).is_err() {
                    return;
                }
            }
            let sleep = claude
                .next
                .min(codex.next)
                .saturating_duration_since(Instant::now())
                .max(Duration::from_millis(100));
            match rx_cmd.recv_timeout(sleep) {
                Ok(()) => {
                    claude.request_now();
                    codex.request_now();
                }
                Err(mpsc::RecvTimeoutError::Timeout) => {}
                Err(mpsc::RecvTimeoutError::Disconnected) => return,
            }
        }
    });

    enable_raw_mode()?;
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen)?;
    let mut terminal = Terminal::new(CrosstermBackend::new(stdout))?;

    let mut app = App {
        claude: claude_seed,
        claude_error: None,
        codex: codex_seed,
        codex_error: None,
        last_updated: cached_at.map(|t| t.with_timezone(&Local)),
        theme: default_theme(light_bg),
        light_bg,
        theme_override,
    };

    let mut next_bg_check = Instant::now() + BG_CHECK_INTERVAL;
    let result = loop {
        let mut fresh_data = false;
        while let Ok(msg) = rx_msg.try_recv() {
            fresh_data = true;
            match msg {
                Msg::Claude(Ok(data)) => {
                    app.claude = Some(data);
                    app.claude_error = None;
                    app.last_updated = Some(Local::now());
                }
                Msg::Claude(Err(e)) => app.claude_error = Some(e),
                Msg::Codex(Ok(data)) => {
                    app.codex = Some(data);
                    app.codex_error = None;
                    app.last_updated = Some(Local::now());
                }
                Msg::Codex(Err(e)) => app.codex_error = Some(e),
            }
        }

        // Follow live dark/light toggles. Queries run here, between event
        // polls on this same thread, so the OSC reply can't be eaten by
        // crossterm's event reader. Checks continue even when the startup
        // query went unanswered: under tmux the answer appears only once
        // tmux learns the client's colors, which can be well after we start
        // (lasso prewarms tmux sessions before any client attaches). Failed
        // checks are cheap — terminals answer DA1 promptly even when they
        // ignore color queries.
        if app.theme_override.is_none() && Instant::now() >= next_bg_check {
            next_bg_check = Instant::now() + BG_CHECK_INTERVAL;
            if let Some(light) = query_background(BG_CHECK_TIMEOUT) {
                if light != app.light_bg {
                    app.light_bg = light;
                    app.theme = default_theme(light);
                    let _ = terminal.clear();
                }
            }
        }

        // Full repaint on new data: shells (e.g. starship init) can write to the tty
        // right after startup, clobbering the first frame; diffs alone never recover.
        if fresh_data {
            let _ = terminal.clear();
        }
        if let Err(e) = terminal.draw(|f| render_app(f, &app)) {
            break Err(e);
        }

        match event::poll(Duration::from_millis(250)) {
            Ok(true) => match event::read() {
                Ok(Event::Key(key)) if key.kind == KeyEventKind::Press => match key.code {
                    KeyCode::Char('q') | KeyCode::Esc => break Ok(()),
                    KeyCode::Char('r') => {
                        let _ = terminal.clear();
                        let _ = tx_cmd.send(());
                    }
                    // Cycle auto → light → dark → auto. Pinning a theme stops
                    // background detection from fighting the user's choice (it
                    // resumes on the way back to auto).
                    KeyCode::Char('t') => {
                        app.theme_override = match app.theme_override {
                            None => Some(true),
                            Some(true) => Some(false),
                            Some(false) => None,
                        };
                        // Re-detect right away when returning to auto.
                        let light = app.theme_override.unwrap_or_else(|| {
                            next_bg_check = Instant::now();
                            app.light_bg
                        });
                        app.light_bg = light;
                        app.theme = default_theme(light);
                        let _ = terminal.clear();
                    }
                    _ => {}
                },
                Ok(_) => {}
                Err(e) => break Err(e),
            },
            Ok(false) => {}
            Err(e) => break Err(e),
        }
    };

    disable_raw_mode()?;
    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
    terminal.show_cursor()?;
    result
}
