//! `deepseek metrics` — reads the audit log and session/task stores and prints
//! a human-readable usage rollup.
//!
//! Data sources:
//! - `~/.deepseek/audit.log`   — one JSON line per event (approvals, credentials)
//! - `~/.deepseek/sessions/`   — saved session JSON files (tool call history)
//! - `~/.deepseek/tasks/runtime/events/` — runtime thread JSONL event streams

use std::collections::HashMap;
use std::path::{Path, PathBuf};

use anyhow::Result;
use chrono::{DateTime, Duration, Utc};
use serde_json::Value;

// ──────────────────────────────────────────────────────────────────────────────
// Public entry-point
// ──────────────────────────────────────────────────────────────────────────────

/// Arguments accepted by `deepseek metrics`.
#[derive(Debug, Default)]
pub struct MetricsArgs {
    /// Emit machine-readable JSON instead of human text.
    pub json: bool,
    /// Restrict to events newer than this cutoff (inclusive).
    pub since: Option<DateTime<Utc>>,
}

pub fn run(args: MetricsArgs) -> Result<()> {
    let base = deepseek_home();

    // Collect data from every source; treat missing files as empty.
    let mut rollup = Rollup::default();
    read_audit_log(&base.join("audit.log"), args.since, &mut rollup);
    read_session_files(&base.join("sessions"), args.since, &mut rollup);
    read_runtime_events(
        &base.join("tasks").join("runtime").join("events"),
        args.since,
        &mut rollup,
    );

    if args.json {
        print_json(&rollup)?;
    } else {
        print_human(&rollup);
    }

    Ok(())
}

// ──────────────────────────────────────────────────────────────────────────────
// Duration-string parser  ("7d", "24h", "30m", "2h", "now-2h", "2h30m")
// ──────────────────────────────────────────────────────────────────────────────

/// Parse a loose humantime-ish duration string into an absolute `DateTime<Utc>`
/// cutoff (i.e. `Utc::now() - duration`).
///
/// Accepted forms:
/// - `7d` / `24h` / `30m` / `90s`
/// - `2h30m`, `1d12h`
/// - `now-2h` (leading `now-` is stripped before parsing)
pub fn parse_since(s: &str) -> Result<DateTime<Utc>> {
    let s = s.trim().to_ascii_lowercase();
    let s = s.strip_prefix("now-").unwrap_or(&s);
    let secs = parse_duration_secs(s)?;
    Ok(Utc::now() - Duration::seconds(secs))
}

fn parse_duration_secs(s: &str) -> Result<i64> {
    // Walk through the string accumulating numbers and consuming unit suffixes.
    let mut total: i64 = 0;
    let mut num_buf = String::new();

    for ch in s.chars() {
        match ch {
            '0'..='9' => num_buf.push(ch),
            'd' | 'h' | 'm' | 's' => {
                let n: i64 = num_buf
                    .parse()
                    .map_err(|_| anyhow::anyhow!("invalid duration component: {:?}", num_buf))?;
                num_buf.clear();
                let factor = match ch {
                    'd' => 86_400,
                    'h' => 3_600,
                    'm' => 60,
                    's' => 1,
                    _ => unreachable!(),
                };
                total += n * factor;
            }
            _ => anyhow::bail!("unrecognised character {:?} in duration {:?}", ch, s),
        }
    }

    if !num_buf.is_empty() {
        // Trailing bare number — treat as seconds.
        let n: i64 = num_buf.parse()?;
        total += n;
    }

    if total == 0 {
        anyhow::bail!("duration {:?} resolved to zero seconds", s);
    }

    Ok(total)
}

// ──────────────────────────────────────────────────────────────────────────────
// Rollup data model
// ──────────────────────────────────────────────────────────────────────────────

/// Per-tool aggregated counters.
#[derive(Debug, Default, serde::Serialize)]
pub struct ToolStats {
    pub calls: u64,
    /// Calls that were auto-approved (no prompt required).
    pub auto_approved: u64,
    /// Calls that required a manual prompt.
    pub prompted: u64,
    /// Total elapsed ms (from events that carry this field).
    pub total_elapsed_ms: u64,
    /// Number of elapsed_ms samples included in `total_elapsed_ms`.
    pub elapsed_samples: u64,
    /// Successful calls (where we have result data).
    pub successes: u64,
    /// Failed calls.
    pub failures: u64,
}

impl ToolStats {
    fn success_rate_pct(&self) -> Option<f64> {
        let judged = self.successes + self.failures;
        if judged == 0 {
            None
        } else {
            Some(self.successes as f64 / judged as f64 * 100.0)
        }
    }

    fn avg_elapsed_ms(&self) -> Option<u64> {
        self.total_elapsed_ms.checked_div(self.elapsed_samples)
    }
}

/// Compaction event stats.
#[derive(Debug, Default, serde::Serialize)]
pub struct CompactionStats {
    pub events: u64,
    /// Sum of `reduction_ratio` from events that carry it (0.0–1.0 each).
    pub ratio_sum: f64,
    pub ratio_samples: u64,
}

impl CompactionStats {
    fn avg_reduction_pct(&self) -> Option<f64> {
        if self.ratio_samples == 0 {
            None
        } else {
            Some(self.ratio_sum / self.ratio_samples as f64 * 100.0)
        }
    }
}

/// Sub-agent spawn stats.
#[derive(Debug, Default, serde::Serialize)]
pub struct AgentStats {
    pub spawns: u64,
    pub successes: u64,
    pub failures: u64,
}

impl AgentStats {
    fn success_rate_pct(&self) -> Option<f64> {
        let judged = self.successes + self.failures;
        if judged == 0 {
            None
        } else {
            Some(self.successes as f64 / judged as f64 * 100.0)
        }
    }
}

/// Capacity-controller / rate-limit intervention stats.
#[derive(Debug, Default, serde::Serialize)]
pub struct CapacityStats {
    pub total: u64,
    pub by_category: HashMap<String, u64>,
}

/// Credential / session event stats (from audit log).
#[derive(Debug, Default, serde::Serialize)]
pub struct CredentialStats {
    pub saves: u64,
    pub clears: u64,
}

/// Top-level rollup.
#[derive(Debug, Default, serde::Serialize)]
pub struct Rollup {
    /// UTC timestamp of the earliest event we've seen.
    pub earliest_ts: Option<DateTime<Utc>>,
    /// UTC timestamp of the latest event we've seen.
    pub latest_ts: Option<DateTime<Utc>>,
    /// Per-tool stats keyed by tool name.
    pub tools: HashMap<String, ToolStats>,
    pub compaction: CompactionStats,
    pub agents: AgentStats,
    pub capacity: CapacityStats,
    pub credentials: CredentialStats,
    /// Total lines read across all sources.
    pub total_lines: u64,
    /// Lines successfully parsed.
    pub parsed_lines: u64,
}

impl Rollup {
    fn touch_ts(&mut self, ts: &DateTime<Utc>) {
        match self.earliest_ts {
            None => self.earliest_ts = Some(*ts),
            Some(ref cur) if ts < cur => self.earliest_ts = Some(*ts),
            _ => {}
        }
        match self.latest_ts {
            None => self.latest_ts = Some(*ts),
            Some(ref cur) if ts > cur => self.latest_ts = Some(*ts),
            _ => {}
        }
    }

    fn tool_mut(&mut self, name: &str) -> &mut ToolStats {
        self.tools.entry(name.to_string()).or_default()
    }

    fn total_tool_calls(&self) -> u64 {
        self.tools.values().map(|t| t.calls).sum()
    }
}

// ──────────────────────────────────────────────────────────────────────────────
// Source readers
// ──────────────────────────────────────────────────────────────────────────────

/// Read one-JSON-line-per-event audit log.
fn read_audit_log(path: &Path, since: Option<DateTime<Utc>>, rollup: &mut Rollup) {
    let content = match std::fs::read_to_string(path) {
        Ok(c) => c,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
        Err(e) => {
            tracing::trace!(
                "metrics: could not read audit log {}: {}",
                path.display(),
                e
            );
            return;
        }
    };

    for raw_line in content.lines() {
        rollup.total_lines += 1;
        let line = raw_line.trim();
        if line.is_empty() {
            continue;
        }

        let v: Value = match serde_json::from_str(line) {
            Ok(v) => v,
            Err(e) => {
                tracing::trace!("metrics: skipping malformed audit line: {e}");
                continue;
            }
        };

        // Parse timestamp — field is "ts" in audit log.
        let ts = parse_ts_field(&v, "ts");

        if let Some(cutoff) = since {
            match ts {
                Some(t) if t < cutoff => continue,
                _ => {}
            }
        }

        rollup.parsed_lines += 1;
        if let Some(t) = &ts {
            rollup.touch_ts(t);
        }

        let event = v.get("event").and_then(|e| e.as_str()).unwrap_or("");

        match event {
            "tool.approval.auto_approve" => {
                let tool_name = v
                    .pointer("/details/tool_name")
                    .and_then(|t| t.as_str())
                    .unwrap_or("unknown");
                let stats = rollup.tool_mut(tool_name);
                stats.calls += 1;
                stats.auto_approved += 1;
            }
            "tool.approval.prompted" => {
                let tool_name = v
                    .pointer("/details/tool_name")
                    .and_then(|t| t.as_str())
                    .unwrap_or("unknown");
                let stats = rollup.tool_mut(tool_name);
                stats.calls += 1;
                stats.prompted += 1;
            }
            "tool.completed" | "tool.result" => {
                let tool_name = v
                    .pointer("/details/tool_name")
                    .or_else(|| v.pointer("/payload/tool_name"))
                    .and_then(|t| t.as_str())
                    .unwrap_or("unknown");
                let stats = rollup.tool_mut(tool_name);
                stats.calls += 1;

                // Optional elapsed_ms
                if let Some(ms) = v
                    .pointer("/details/elapsed_ms")
                    .or_else(|| v.pointer("/payload/elapsed_ms"))
                    .and_then(|v| v.as_u64())
                {
                    stats.total_elapsed_ms += ms;
                    stats.elapsed_samples += 1;
                }

                // Success / failure
                let success = v
                    .pointer("/details/success")
                    .or_else(|| v.pointer("/payload/success"))
                    .and_then(|b| b.as_bool())
                    .unwrap_or(true);
                if success {
                    stats.successes += 1;
                } else {
                    stats.failures += 1;
                }
            }
            "compaction.completed" | "context.compaction" => {
                rollup.compaction.events += 1;
                if let Some(ratio) = v
                    .pointer("/details/reduction_ratio")
                    .or_else(|| v.pointer("/payload/reduction_ratio"))
                    .and_then(|r| r.as_f64())
                {
                    rollup.compaction.ratio_sum += ratio;
                    rollup.compaction.ratio_samples += 1;
                }
            }
            "agent.spawn" | "subagent.spawned" => {
                rollup.agents.spawns += 1;
            }
            "agent.completed" | "subagent.completed" => {
                let success = v
                    .pointer("/details/success")
                    .or_else(|| v.pointer("/payload/success"))
                    .and_then(|b| b.as_bool())
                    .unwrap_or(true);
                if success {
                    rollup.agents.successes += 1;
                } else {
                    rollup.agents.failures += 1;
                }
            }
            e if e.starts_with("capacity.") => {
                rollup.capacity.total += 1;
                let category = v
                    .pointer("/details/category")
                    .or_else(|| v.pointer("/payload/category"))
                    .and_then(|c| c.as_str())
                    .unwrap_or(e.trim_start_matches("capacity."));
                *rollup
                    .capacity
                    .by_category
                    .entry(category.to_string())
                    .or_insert(0) += 1;
            }
            "credential.save" => {
                rollup.credentials.saves += 1;
            }
            "credential.clear" => {
                rollup.credentials.clears += 1;
            }
            _ => {
                // Unknown event — tracked in parsed_lines but otherwise ignored.
            }
        }
    }
}

/// Read session JSON files under `sessions/` (one per session).
/// These carry tool call history with optional elapsed_ms and result data.
fn read_session_files(sessions_dir: &Path, since: Option<DateTime<Utc>>, rollup: &mut Rollup) {
    let rd = match std::fs::read_dir(sessions_dir) {
        Ok(rd) => rd,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
        Err(e) => {
            tracing::trace!(
                "metrics: could not list sessions dir {}: {}",
                sessions_dir.display(),
                e
            );
            return;
        }
    };

    for entry in rd.flatten() {
        let path = entry.path();
        // Only look at .json files directly in sessions/; skip sub-dirs.
        if path.is_dir() || path.extension().map(|e| e != "json").unwrap_or(true) {
            continue;
        }
        read_session_file(&path, since, rollup);
    }
}

fn read_session_file(path: &Path, since: Option<DateTime<Utc>>, rollup: &mut Rollup) {
    let content = match std::fs::read_to_string(path) {
        Ok(c) => c,
        Err(e) => {
            tracing::trace!(
                "metrics: could not read session file {}: {}",
                path.display(),
                e
            );
            return;
        }
    };

    rollup.total_lines += 1;

    let v: Value = match serde_json::from_str(&content) {
        Ok(v) => v,
        Err(e) => {
            tracing::trace!(
                "metrics: skipping malformed session file {}: {}",
                path.display(),
                e
            );
            return;
        }
    };

    rollup.parsed_lines += 1;

    // Session-level timestamp filter (check metadata.created_at or updated_at).
    let session_ts = v
        .pointer("/metadata/updated_at")
        .or_else(|| v.pointer("/metadata/created_at"))
        .and_then(|t| t.as_str())
        .and_then(|s| s.parse::<DateTime<Utc>>().ok());

    if let Some(cutoff) = since
        && let Some(ts) = &session_ts
        && *ts < cutoff
    {
        return;
    }

    if let Some(ts) = session_ts {
        rollup.touch_ts(&ts);
    }

    // Walk messages looking for tool_use calls with associated results.
    let messages = match v.get("messages").and_then(|m| m.as_array()) {
        Some(m) => m,
        None => return,
    };

    // Build a map from tool_use_id → (tool_name, elapsed_ms_option, started_at_option).
    let mut pending: HashMap<String, (String, Option<u64>)> = HashMap::new();

    for msg in messages {
        let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
        let content_arr = match msg.get("content").and_then(|c| c.as_array()) {
            Some(c) => c,
            None => continue,
        };

        for block in content_arr {
            let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
            match (role, block_type) {
                ("assistant", "tool_use") => {
                    let id = block.get("id").and_then(|i| i.as_str()).unwrap_or("");
                    let name = block
                        .get("name")
                        .and_then(|n| n.as_str())
                        .unwrap_or("unknown");
                    let elapsed_ms = block.get("elapsed_ms").and_then(|e| e.as_u64());
                    if !id.is_empty() {
                        pending.insert(id.to_string(), (name.to_string(), elapsed_ms));
                    }
                }
                ("user", "tool_result") => {
                    let id = block
                        .get("tool_use_id")
                        .and_then(|i| i.as_str())
                        .unwrap_or("");
                    if let Some((name, elapsed_ms)) = pending.remove(id) {
                        let stats = rollup.tool_mut(&name);
                        // Only count if not already counted via audit log (we don't de-dup, so
                        // session files may double-count approvals; that's acceptable — users who
                        // want precise counts should use --json and cross-reference).
                        stats.calls += 1;
                        if let Some(ms) = elapsed_ms {
                            stats.total_elapsed_ms += ms;
                            stats.elapsed_samples += 1;
                        }
                        // Tool result success: absence of "is_error": true
                        let is_error = block
                            .get("is_error")
                            .and_then(|e| e.as_bool())
                            .unwrap_or(false);
                        if is_error {
                            stats.failures += 1;
                        } else {
                            stats.successes += 1;
                        }
                    }
                }
                _ => {}
            }
        }
    }

    // Walk messages for compaction events embedded as special user messages.
    for msg in messages {
        if let Some(compaction) = msg
            .get("compaction")
            .or_else(|| msg.pointer("/metadata/compaction"))
        {
            rollup.compaction.events += 1;
            if let Some(ratio) = compaction.get("reduction_ratio").and_then(|r| r.as_f64()) {
                rollup.compaction.ratio_sum += ratio;
                rollup.compaction.ratio_samples += 1;
            }
        }
    }
}

/// Read JSONL event streams from the tasks runtime events directory.
fn read_runtime_events(events_dir: &Path, since: Option<DateTime<Utc>>, rollup: &mut Rollup) {
    let rd = match std::fs::read_dir(events_dir) {
        Ok(rd) => rd,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
        Err(e) => {
            tracing::trace!(
                "metrics: could not list events dir {}: {}",
                events_dir.display(),
                e
            );
            return;
        }
    };

    for entry in rd.flatten() {
        let path = entry.path();
        if path.extension().map(|e| e != "jsonl").unwrap_or(true) {
            continue;
        }
        read_events_jsonl(&path, since, rollup);
    }
}

fn read_events_jsonl(path: &Path, since: Option<DateTime<Utc>>, rollup: &mut Rollup) {
    let content = match std::fs::read_to_string(path) {
        Ok(c) => c,
        Err(e) => {
            tracing::trace!(
                "metrics: could not read events file {}: {}",
                path.display(),
                e
            );
            return;
        }
    };

    for raw_line in content.lines() {
        rollup.total_lines += 1;
        let line = raw_line.trim();
        if line.is_empty() {
            continue;
        }

        let v: Value = match serde_json::from_str(line) {
            Ok(v) => v,
            Err(e) => {
                tracing::trace!("metrics: skipping malformed event line: {e}");
                continue;
            }
        };

        let ts = parse_ts_field(&v, "timestamp");

        if let Some(cutoff) = since {
            match ts {
                Some(t) if t < cutoff => continue,
                _ => {}
            }
        }

        rollup.parsed_lines += 1;
        if let Some(t) = &ts {
            rollup.touch_ts(t);
        }

        let event = v.get("event").and_then(|e| e.as_str()).unwrap_or("");

        match event {
            "tool.started" | "tool.completed" | "tool.failed" => {
                let tool_name = v
                    .pointer("/payload/tool_name")
                    .or_else(|| v.pointer("/payload/name"))
                    .and_then(|t| t.as_str())
                    .unwrap_or("unknown");
                let stats = rollup.tool_mut(tool_name);

                if event == "tool.started" {
                    stats.calls += 1;
                } else if event == "tool.completed" {
                    stats.successes += 1;
                    if let Some(ms) = v.pointer("/payload/elapsed_ms").and_then(|v| v.as_u64()) {
                        stats.total_elapsed_ms += ms;
                        stats.elapsed_samples += 1;
                    }
                } else {
                    // tool.failed
                    stats.failures += 1;
                }
            }
            "compaction.completed" => {
                rollup.compaction.events += 1;
                if let Some(ratio) = v
                    .pointer("/payload/reduction_ratio")
                    .and_then(|r| r.as_f64())
                {
                    rollup.compaction.ratio_sum += ratio;
                    rollup.compaction.ratio_samples += 1;
                }
            }
            "agent.spawned" | "subagent.spawned" => {
                rollup.agents.spawns += 1;
            }
            "agent.completed" | "subagent.completed" => {
                let success = v
                    .pointer("/payload/success")
                    .and_then(|b| b.as_bool())
                    .unwrap_or(true);
                if success {
                    rollup.agents.successes += 1;
                } else {
                    rollup.agents.failures += 1;
                }
            }
            e if e.starts_with("capacity.") => {
                rollup.capacity.total += 1;
                let category = v
                    .pointer("/payload/category")
                    .and_then(|c| c.as_str())
                    .unwrap_or(e.trim_start_matches("capacity."));
                *rollup
                    .capacity
                    .by_category
                    .entry(category.to_string())
                    .or_insert(0) += 1;
            }
            _ => {}
        }
    }
}

// ──────────────────────────────────────────────────────────────────────────────
// Output formatters
// ──────────────────────────────────────────────────────────────────────────────

fn print_json(rollup: &Rollup) -> Result<()> {
    println!("{}", serde_json::to_string_pretty(rollup)?);
    Ok(())
}

fn print_human(rollup: &Rollup) {
    // Period header
    match (rollup.earliest_ts, rollup.latest_ts) {
        (Some(start), Some(end)) => {
            let days = (end - start).num_days();
            println!(
                "Period: {} → {} ({} days)",
                start.format("%Y-%m-%d"),
                end.format("%Y-%m-%d"),
                days
            );
        }
        (Some(start), None) | (None, Some(start)) => {
            println!("Period: {} → (unknown)", start.format("%Y-%m-%d"));
        }
        (None, None) => {
            println!("Period: (no data)");
        }
    }

    // ── Tools ──────────────────────────────────────────────────────────────
    let total_calls = rollup.total_tool_calls();
    if total_calls > 0 {
        // Overall success rate from session-file data (where we have result info).
        let total_ok: u64 = rollup.tools.values().map(|t| t.successes).sum();
        let total_judged: u64 = rollup
            .tools
            .values()
            .map(|t| t.successes + t.failures)
            .sum();
        let overall_rate = if total_judged > 0 {
            format!(
                "{:.1}% success",
                total_ok as f64 / total_judged as f64 * 100.0
            )
        } else {
            // Only approval events — show prompt breakdown.
            let auto: u64 = rollup.tools.values().map(|t| t.auto_approved).sum();
            let prompted: u64 = rollup.tools.values().map(|t| t.prompted).sum();
            format!("{auto} auto-approved, {prompted} prompted")
        };

        println!(
            "Tools: {:>6} calls ({})",
            fmt_num(total_calls),
            overall_rate
        );

        // Sort tools by call count descending, top 15.
        let mut tools: Vec<(&String, &ToolStats)> = rollup.tools.iter().collect();
        tools.sort_by_key(|b| std::cmp::Reverse(b.1.calls));
        for (name, stats) in tools.iter().take(15) {
            let rate_str = match stats.success_rate_pct() {
                Some(pct) => format!("{pct:5.1}%"),
                None => {
                    // Only approval data available — show auto/prompted breakdown.
                    let a = stats.auto_approved;
                    let p = stats.prompted;
                    if p == 0 {
                        format!("auto×{a}  ")
                    } else {
                        format!("auto×{a}/prompted×{p}")
                    }
                }
            };
            let avg_str = match stats.avg_elapsed_ms() {
                Some(ms) => format!("  avg {ms}ms"),
                None => String::new(),
            };
            println!(
                "  {name:<22} {:>6}  {rate_str}{avg_str}",
                fmt_num(stats.calls)
            );
        }
        if tools.len() > 15 {
            println!("  … and {} more tools", tools.len() - 15);
        }
    } else {
        println!("Tools: (no data)");
    }

    // ── Compaction ─────────────────────────────────────────────────────────
    if rollup.compaction.events > 0 {
        let avg_str = match rollup.compaction.avg_reduction_pct() {
            Some(pct) => format!(", avg {pct:.0}% size reduction"),
            None => String::new(),
        };
        println!(
            "Compaction: {} events{}",
            fmt_num(rollup.compaction.events),
            avg_str
        );
    } else {
        println!("Compaction: (no data)");
    }

    // ── Sub-agents ─────────────────────────────────────────────────────────
    if rollup.agents.spawns > 0 {
        let rate_str = match rollup.agents.success_rate_pct() {
            Some(pct) => format!(", {pct:.1}% success"),
            None => String::new(),
        };
        println!(
            "Sub-agents: {} spawns{}",
            fmt_num(rollup.agents.spawns),
            rate_str
        );
    } else {
        println!("Sub-agents: (no data)");
    }

    // ── Capacity interventions ─────────────────────────────────────────────
    if rollup.capacity.total > 0 {
        let cat_str: String = {
            let mut cats: Vec<(&String, &u64)> = rollup.capacity.by_category.iter().collect();
            cats.sort_by(|a, b| b.1.cmp(a.1));
            cats.iter()
                .map(|(k, v)| format!("{} {}", v, k))
                .collect::<Vec<_>>()
                .join(", ")
        };
        println!(
            "Capacity interventions: {} ({})",
            fmt_num(rollup.capacity.total),
            cat_str
        );
    } else {
        println!("Capacity interventions: (no data)");
    }

    // ── Credentials ────────────────────────────────────────────────────────
    if rollup.credentials.saves > 0 || rollup.credentials.clears > 0 {
        println!(
            "Credentials: {} saves, {} clears",
            rollup.credentials.saves, rollup.credentials.clears
        );
    }
}

// ──────────────────────────────────────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────────────────────────────────────

fn deepseek_home() -> PathBuf {
    // Respect DEEPSEEK_HOME env override; fall back to ~/.deepseek.
    if let Ok(v) = std::env::var("DEEPSEEK_HOME")
        && !v.is_empty()
    {
        return PathBuf::from(v);
    }
    dirs::home_dir()
        .unwrap_or_else(|| PathBuf::from("."))
        .join(".deepseek")
}

/// Parse a timestamp from a JSON value field (tries RFC3339).
fn parse_ts_field(v: &Value, field: &str) -> Option<DateTime<Utc>> {
    v.get(field)?.as_str()?.parse::<DateTime<Utc>>().ok()
}

/// Format a number with thousands separators.
fn fmt_num(n: u64) -> String {
    let s = n.to_string();
    let mut result = String::with_capacity(s.len() + s.len() / 3);
    for (i, ch) in s.chars().rev().enumerate() {
        if i > 0 && i % 3 == 0 {
            result.push(',');
        }
        result.push(ch);
    }
    result.chars().rev().collect()
}

// ──────────────────────────────────────────────────────────────────────────────
// Tests
// ──────────────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;

    // ── Duration parser ──

    #[test]
    fn parse_since_7d() {
        let cutoff = parse_since("7d").unwrap();
        let expected = Utc::now() - Duration::days(7);
        // Allow ±2s for test execution time.
        assert!((cutoff - expected).num_seconds().abs() < 2);
    }

    #[test]
    fn parse_since_24h() {
        let cutoff = parse_since("24h").unwrap();
        let expected = Utc::now() - Duration::hours(24);
        assert!((cutoff - expected).num_seconds().abs() < 2);
    }

    #[test]
    fn parse_since_30m() {
        let cutoff = parse_since("30m").unwrap();
        let expected = Utc::now() - Duration::minutes(30);
        assert!((cutoff - expected).num_seconds().abs() < 2);
    }

    #[test]
    fn parse_since_now_prefix() {
        // "now-2h" should strip "now-" and parse "2h".
        let cutoff = parse_since("now-2h").unwrap();
        let expected = Utc::now() - Duration::hours(2);
        assert!((cutoff - expected).num_seconds().abs() < 2);
    }

    #[test]
    fn parse_since_compound() {
        let cutoff = parse_since("2h30m").unwrap();
        let expected = Utc::now() - Duration::seconds(2 * 3600 + 30 * 60);
        assert!((cutoff - expected).num_seconds().abs() < 2);
    }

    #[test]
    fn parse_since_compound_days_hours() {
        let cutoff = parse_since("1d12h").unwrap();
        let expected = Utc::now() - Duration::seconds(36 * 3600);
        assert!((cutoff - expected).num_seconds().abs() < 2);
    }

    #[test]
    fn parse_since_error_on_invalid() {
        assert!(parse_since("xyz").is_err());
        assert!(parse_since("").is_err());
    }

    // ── fmt_num ──

    #[test]
    fn fmt_num_zero() {
        assert_eq!(fmt_num(0), "0");
    }

    #[test]
    fn fmt_num_thousands() {
        assert_eq!(fmt_num(1_000), "1,000");
        assert_eq!(fmt_num(12_453), "12,453");
        assert_eq!(fmt_num(1_000_000), "1,000,000");
    }

    // ── Rollup from audit log ──

    fn make_audit_line(event: &str, tool: &str, ts: &str) -> String {
        format!(
            r#"{{"details":{{"mode":"YOLO","session_id":null,"tool_name":"{tool}"}},"event":"{event}","ts":"{ts}"}}"#
        )
    }

    #[test]
    fn audit_log_empty_file() {
        let mut rollup = Rollup::default();
        // Non-existent path — should not panic, rollup stays empty.
        read_audit_log(Path::new("/nonexistent/audit.log"), None, &mut rollup);
        assert_eq!(rollup.total_lines, 0);
    }

    #[test]
    fn audit_log_parses_auto_approve() {
        use std::io::Write;
        let mut tmp = tempfile::NamedTempFile::new().unwrap();
        let line1 = make_audit_line(
            "tool.approval.auto_approve",
            "exec_shell",
            "2026-04-01T10:00:00+00:00",
        );
        let line2 = make_audit_line(
            "tool.approval.auto_approve",
            "read_file",
            "2026-04-02T10:00:00+00:00",
        );
        writeln!(tmp, "{line1}").unwrap();
        writeln!(tmp, "{line2}").unwrap();

        let mut rollup = Rollup::default();
        read_audit_log(tmp.path(), None, &mut rollup);

        assert_eq!(rollup.parsed_lines, 2);
        assert_eq!(rollup.tools["exec_shell"].calls, 1);
        assert_eq!(rollup.tools["exec_shell"].auto_approved, 1);
        assert_eq!(rollup.tools["read_file"].calls, 1);
    }

    #[test]
    fn audit_log_skips_malformed_lines() {
        use std::io::Write;
        let mut tmp = tempfile::NamedTempFile::new().unwrap();
        writeln!(tmp, "not json at all").unwrap();
        writeln!(
            tmp,
            r#"{{"event":"credential.save","ts":"2026-04-01T10:00:00+00:00"}}"#
        )
        .unwrap();

        let mut rollup = Rollup::default();
        read_audit_log(tmp.path(), None, &mut rollup);

        // 2 lines total, 1 malformed skipped, 1 parsed.
        assert_eq!(rollup.total_lines, 2);
        assert_eq!(rollup.parsed_lines, 1);
        assert_eq!(rollup.credentials.saves, 1);
    }

    #[test]
    fn audit_log_since_filter() {
        use std::io::Write;
        let mut tmp = tempfile::NamedTempFile::new().unwrap();
        let line_old = make_audit_line(
            "tool.approval.auto_approve",
            "exec_shell",
            "2025-01-01T00:00:00+00:00",
        );
        let line_new = make_audit_line(
            "tool.approval.auto_approve",
            "read_file",
            "2026-04-01T00:00:00+00:00",
        );
        writeln!(tmp, "{line_old}").unwrap();
        writeln!(tmp, "{line_new}").unwrap();

        let cutoff: DateTime<Utc> = "2026-01-01T00:00:00Z".parse().unwrap();
        let mut rollup = Rollup::default();
        read_audit_log(tmp.path(), Some(cutoff), &mut rollup);

        // Only the newer line should be counted.
        assert_eq!(rollup.parsed_lines, 1);
        assert!(!rollup.tools.contains_key("exec_shell"));
        assert_eq!(rollup.tools["read_file"].calls, 1);
    }

    #[test]
    fn total_tool_calls_sums_across_tools() {
        let mut rollup = Rollup::default();
        rollup.tool_mut("read_file").calls = 4_012;
        rollup.tool_mut("exec_shell").calls = 1_118;
        assert_eq!(rollup.total_tool_calls(), 5_130);
    }
}
