//! Persistent run statistics for cumulative token savings tracking.
//!
//! Stores one JSON line per SHIFT invocation in `~/.shift/stats.jsonl`.
//! Inspired by RTK's `rtk gain` analytics system.

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::{BufRead, BufReader, Write};
use std::path::PathBuf;

/// Maximum number of records to load from the stats file.
/// Prevents unbounded memory allocation from huge/malicious files.
const MAX_STATS_RECORDS: usize = 100_000;

/// Maximum line length (bytes) to accept when reading the stats file.
/// Lines longer than this are skipped as likely corrupt.
const MAX_LINE_LENGTH: usize = 65_536;

/// Records older than this many days are automatically purged.
const RETENTION_DAYS: u64 = 90;

use crate::cost::TokenSavings;

/// A single run record persisted to the stats file.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunRecord {
    /// ISO 8601 timestamp
    pub timestamp: String,
    /// Date portion (YYYY-MM-DD) for daily aggregation
    pub date: String,
    /// Provider used
    pub provider: String,
    /// Number of images processed
    pub images: usize,
    /// Number of images modified
    pub modified: usize,
    /// Number of images dropped (economy mode excess, SVG source mode)
    #[serde(default)]
    pub dropped: usize,
    /// Number of SVGs rasterized
    #[serde(default)]
    pub svgs_rasterized: usize,
    /// Byte sizes
    pub bytes_before: usize,
    pub bytes_after: usize,
    /// Token savings
    pub token_savings: TokenSavings,
    /// Pipeline execution time in milliseconds
    #[serde(default)]
    pub duration_ms: u64,
    /// Per-action counts: (action_name, count)
    #[serde(default)]
    pub action_counts: Vec<(String, usize)>,
}

/// Aggregated gain summary.
#[derive(Debug, Clone, Default)]
pub struct GainSummary {
    pub total_runs: usize,
    pub total_images: usize,
    pub total_modified: usize,
    pub total_bytes_before: u64,
    pub total_bytes_after: u64,
    pub total_openai_before: u64,
    pub total_openai_after: u64,
    pub total_anthropic_before: u64,
    pub total_anthropic_after: u64,
    pub total_duration_ms: u64,
    /// Per-provider breakdown sorted by tokens saved descending.
    pub by_provider: Vec<ProviderGain>,
    /// Per-action breakdown sorted by count descending.
    pub by_action: Vec<ActionGain>,
}

/// Per-provider aggregated stats.
#[derive(Debug, Clone)]
pub struct ProviderGain {
    pub provider: String,
    pub runs: usize,
    pub images: usize,
    pub tokens_saved: u64,
    /// Aggregate savings percentage: (total_saved / total_before) * 100.
    pub overall_pct: f64,
    pub avg_duration_ms: u64,
}

/// Per-action aggregated stats.
#[derive(Debug, Clone)]
pub struct ActionGain {
    pub action: String,
    pub count: usize,
}

/// Daily aggregation bucket.
#[derive(Debug, Clone)]
pub struct DailyGain {
    pub date: String,
    pub runs: usize,
    pub images: usize,
    pub openai_saved: u64,
    pub anthropic_saved: u64,
}

impl GainSummary {
    pub fn openai_saved(&self) -> u64 {
        self.total_openai_before
            .saturating_sub(self.total_openai_after)
    }

    pub fn anthropic_saved(&self) -> u64 {
        self.total_anthropic_before
            .saturating_sub(self.total_anthropic_after)
    }

    pub fn openai_pct(&self) -> f64 {
        if self.total_openai_before == 0 {
            return 0.0;
        }
        (self.openai_saved() as f64 / self.total_openai_before as f64) * 100.0
    }

    pub fn anthropic_pct(&self) -> f64 {
        if self.total_anthropic_before == 0 {
            return 0.0;
        }
        (self.anthropic_saved() as f64 / self.total_anthropic_before as f64) * 100.0
    }

    pub fn bytes_saved(&self) -> u64 {
        self.total_bytes_before
            .saturating_sub(self.total_bytes_after)
    }
}

/// Get the default stats file path: `~/.shift/stats.jsonl`.
pub fn default_stats_path() -> Result<PathBuf> {
    let home = std::env::var("HOME")
        .or_else(|_| std::env::var("USERPROFILE"))
        .context("could not determine home directory")?;
    Ok(PathBuf::from(home).join(".shift").join("stats.jsonl"))
}

/// Append a run record to the stats file.
pub fn record_run(record: &RunRecord, path: Option<&PathBuf>) -> Result<()> {
    let stats_path = match path {
        Some(p) => p.clone(),
        None => default_stats_path()?,
    };

    // Ensure parent directory exists
    if let Some(parent) = stats_path.parent() {
        fs::create_dir_all(parent).context("failed to create ~/.shift directory")?;

        // Reject symlinks on the directory (consistent with pipeline.rs profile path validation)
        let dir_meta = fs::symlink_metadata(parent)
            .with_context(|| format!("failed to stat {}", parent.display()))?;
        if dir_meta.file_type().is_symlink() {
            anyhow::bail!(
                "stats directory {} is a symlink (possible symlink attack)",
                parent.display()
            );
        }
    }

    // Open the file with O_NOFOLLOW on Unix to atomically reject symlinks
    // (avoids TOCTOU race between stat and open).
    #[cfg(unix)]
    let mut file = {
        use std::os::unix::fs::OpenOptionsExt;
        fs::OpenOptions::new()
            .create(true)
            .append(true)
            .custom_flags(libc::O_NOFOLLOW)
            .open(&stats_path)
            .with_context(|| {
                format!(
                    "failed to open stats file: {} (symlinks are rejected)",
                    stats_path.display()
                )
            })?
    };

    #[cfg(not(unix))]
    let mut file = {
        // Fallback: stat-then-open (TOCTOU risk, but best we can do on non-Unix)
        if stats_path.exists() {
            let file_meta = fs::symlink_metadata(&stats_path)
                .with_context(|| format!("failed to stat {}", stats_path.display()))?;
            if file_meta.file_type().is_symlink() {
                anyhow::bail!(
                    "stats file {} is a symlink (possible symlink attack)",
                    stats_path.display()
                );
            }
        }
        fs::OpenOptions::new()
            .create(true)
            .append(true)
            .open(&stats_path)
            .with_context(|| format!("failed to open stats file: {}", stats_path.display()))?
    };

    // Serialize to a single buffer and write atomically to reduce interleave risk
    let mut line = serde_json::to_string(record).context("failed to serialize run record")?;
    line.push('\n');
    file.write_all(line.as_bytes())
        .context("failed to write to stats file")?;
    file.flush().context("failed to flush stats file")?;

    // Drop the file handle before purging (purge needs exclusive access)
    drop(file);

    // Auto-purge records older than RETENTION_DAYS.
    // Only run when the file exceeds 50KB to amortize the cost.
    if let Ok(meta) = fs::metadata(&stats_path) {
        if meta.len() > 50_000 {
            if let Err(e) = purge_old_records(&stats_path) {
                eprintln!("shift-ai: warning: auto-purge failed: {}", e);
            }
        }
    }

    Ok(())
}

/// Remove records older than RETENTION_DAYS from the stats file.
///
/// Reads all records, filters to those within the retention window,
/// and rewrites the file atomically via a temp file + rename.
///
/// Uses `tempfile::NamedTempFile` for a unique temp filename (safe under
/// concurrent invocations) and auto-cleanup on failure. Calls `sync_all()`
/// before `persist()` to ensure data durability before the rename.
pub fn purge_old_records(path: &PathBuf) -> Result<usize> {
    let cutoff_date = {
        let now_secs = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs();
        let cutoff_secs = now_secs.saturating_sub(RETENTION_DAYS * 86400);
        let (y, m, d) = days_to_ymd(cutoff_secs / 86400);
        format!("{:04}-{:02}-{:02}", y, m, d)
    };

    let load_result = load_records(Some(path))?;
    let total = load_result.records.len();
    let kept: Vec<&RunRecord> = load_result
        .records
        .iter()
        .filter(|r| r.date >= cutoff_date)
        .collect();
    let purged = total - kept.len();

    if purged == 0 {
        return Ok(0);
    }

    // Write to a unique temp file in the same directory. NamedTempFile
    // auto-deletes on drop if persist() is never called, so crashes and
    // errors never leave orphaned files behind.
    let parent = path
        .parent()
        .context("stats file has no parent directory")?;
    let mut tmp_file =
        tempfile::NamedTempFile::new_in(parent).context("failed to create temp file for purge")?;

    for record in &kept {
        let mut line = serde_json::to_string(record)?;
        line.push('\n');
        tmp_file.write_all(line.as_bytes())?;
    }
    tmp_file.flush()?;

    // Ensure data reaches persistent storage before the atomic rename
    tmp_file
        .as_file()
        .sync_all()
        .context("failed to sync temp file")?;

    // persist() atomically renames the temp file over the target path.
    // On failure the temp file is still auto-cleaned up by Drop.
    tmp_file
        .persist(path)
        .context("failed to rename purged stats file")?;

    Ok(purged)
}

/// Result of loading stats records, including count of skipped malformed lines.
pub struct LoadResult {
    pub records: Vec<RunRecord>,
    pub skipped_lines: usize,
}

/// Load all run records from the stats file.
pub fn load_records(path: Option<&PathBuf>) -> Result<LoadResult> {
    let stats_path = match path {
        Some(p) => p.clone(),
        None => default_stats_path()?,
    };

    if !stats_path.exists() {
        return Ok(LoadResult {
            records: Vec::new(),
            skipped_lines: 0,
        });
    }

    let file = fs::File::open(&stats_path)
        .with_context(|| format!("failed to open stats file: {}", stats_path.display()))?;
    let reader = BufReader::new(file);
    let mut records = Vec::new();
    let mut skipped_lines = 0;

    for (i, line) in reader.lines().enumerate() {
        let line = line.with_context(|| format!("failed to read line {} of stats file", i + 1))?;
        let trimmed = line.trim();
        if trimmed.is_empty() {
            continue;
        }
        // Skip excessively long lines (likely corrupt)
        if trimmed.len() > MAX_LINE_LENGTH {
            eprintln!(
                "shift-ai: warning: skipping oversized stats line {} ({} bytes)",
                i + 1,
                trimmed.len()
            );
            skipped_lines += 1;
            continue;
        }
        match serde_json::from_str::<RunRecord>(trimmed) {
            Ok(record) => records.push(record),
            Err(e) => {
                // Skip malformed lines rather than failing
                eprintln!(
                    "shift-ai: warning: skipping malformed stats line {}: {}",
                    i + 1,
                    e
                );
                skipped_lines += 1;
            }
        }
        // Cap total records to prevent unbounded memory allocation
        if records.len() >= MAX_STATS_RECORDS {
            eprintln!(
                "shift-ai: warning: stats file has >{} entries, loading only the first {}",
                MAX_STATS_RECORDS, MAX_STATS_RECORDS
            );
            break;
        }
    }

    Ok(LoadResult {
        records,
        skipped_lines,
    })
}

/// Compute aggregate gain summary from records.
pub fn summarize(records: &[RunRecord]) -> GainSummary {
    use std::collections::BTreeMap;

    let mut s = GainSummary::default();

    // Per-provider accumulators: (runs, images, tokens_before, tokens_after, total_duration_ms)
    let mut providers: BTreeMap<String, (usize, usize, u64, u64, u64)> = BTreeMap::new();
    // Per-action accumulators
    let mut actions: BTreeMap<String, usize> = BTreeMap::new();

    for r in records {
        s.total_runs += 1;
        s.total_images += r.images;
        s.total_modified += r.modified;
        s.total_bytes_before += r.bytes_before as u64;
        s.total_bytes_after += r.bytes_after as u64;
        s.total_openai_before += r.token_savings.openai_before;
        s.total_openai_after += r.token_savings.openai_after;
        s.total_anthropic_before += r.token_savings.anthropic_before;
        s.total_anthropic_after += r.token_savings.anthropic_after;
        s.total_duration_ms += r.duration_ms;

        // Per-provider
        let entry = providers.entry(r.provider.clone()).or_default();
        entry.0 += 1; // runs
        entry.1 += r.images; // images
                             // Use the matching provider's tokens (case-insensitive)
        let provider_lower = r.provider.to_ascii_lowercase();
        let (before, after) = if provider_lower == "anthropic" {
            (
                r.token_savings.anthropic_before,
                r.token_savings.anthropic_after,
            )
        } else {
            // Default to OpenAI tokens for "openai" and any unknown providers
            (r.token_savings.openai_before, r.token_savings.openai_after)
        };
        entry.2 += before;
        entry.3 += after;
        entry.4 += r.duration_ms;

        // Per-action
        for (action, count) in &r.action_counts {
            *actions.entry(action.clone()).or_default() += count;
        }
    }

    // Build per-provider vec sorted by tokens saved descending
    let mut by_provider: Vec<ProviderGain> = providers
        .into_iter()
        .map(|(name, (runs, images, before, after, dur))| {
            let saved = before.saturating_sub(after);
            let overall_pct = if before > 0 {
                (saved as f64 / before as f64) * 100.0
            } else {
                0.0
            };
            let avg_dur = if runs > 0 { dur / runs as u64 } else { 0 };
            ProviderGain {
                provider: name,
                runs,
                images,
                tokens_saved: saved,
                overall_pct,
                avg_duration_ms: avg_dur,
            }
        })
        .collect();
    by_provider.sort_by_key(|b| std::cmp::Reverse(b.tokens_saved));
    s.by_provider = by_provider;

    // Build per-action vec sorted by count descending
    let mut by_action: Vec<ActionGain> = actions
        .into_iter()
        .map(|(action, count)| ActionGain { action, count })
        .collect();
    by_action.sort_by_key(|b| std::cmp::Reverse(b.count));
    s.by_action = by_action;

    s
}

/// Compute daily breakdown from records.
pub fn daily_breakdown(records: &[RunRecord]) -> Vec<DailyGain> {
    use std::collections::BTreeMap;

    let mut days: BTreeMap<String, DailyGain> = BTreeMap::new();

    for r in records {
        let entry = days.entry(r.date.clone()).or_insert_with(|| DailyGain {
            date: r.date.clone(),
            runs: 0,
            images: 0,
            openai_saved: 0,
            anthropic_saved: 0,
        });
        entry.runs += 1;
        entry.images += r.images;
        entry.openai_saved += r
            .token_savings
            .openai_before
            .saturating_sub(r.token_savings.openai_after);
        entry.anthropic_saved += r
            .token_savings
            .anthropic_before
            .saturating_sub(r.token_savings.anthropic_after);
    }

    days.into_values().collect()
}

/// Build a RunRecord from a completed Report.
pub fn record_from_report(
    report: &crate::report::Report,
    provider: &str,
    duration_ms: u64,
) -> RunRecord {
    // Get current timestamp
    let now = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs();

    // Format as ISO 8601 (basic — no chrono dependency)
    let secs_per_day = 86400;
    let days_since_epoch = now / secs_per_day;
    let secs_today = now % secs_per_day;
    let hours = secs_today / 3600;
    let minutes = (secs_today % 3600) / 60;
    let seconds = secs_today % 60;

    // Civil date calculation (Hinnant algorithm, exact for proleptic Gregorian calendar)
    let (year, month, day) = days_to_ymd(days_since_epoch);

    let timestamp = format!(
        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
        year, month, day, hours, minutes, seconds
    );
    let date = format!("{:04}-{:02}-{:02}", year, month, day);

    // Compute per-action counts from action records
    let mut action_map = std::collections::BTreeMap::new();
    for a in &report.actions {
        *action_map.entry(a.action.clone()).or_insert(0usize) += 1;
    }
    let action_counts: Vec<(String, usize)> = action_map.into_iter().collect();

    RunRecord {
        timestamp,
        date,
        provider: provider.to_string(),
        images: report.images_found,
        modified: report.images_modified,
        dropped: report.images_dropped,
        svgs_rasterized: report.svgs_rasterized,
        bytes_before: report.original_size,
        bytes_after: report.transformed_size,
        token_savings: report.token_savings.clone(),
        duration_ms,
        action_counts,
    }
}

/// Convert days since Unix epoch to (year, month, day).
fn days_to_ymd(days: u64) -> (u64, u64, u64) {
    // Simplified civil date calculation
    let z = days + 719468;
    let era = z / 146097;
    let doe = z - era * 146097;
    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
    let y = yoe + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
    let y = if m <= 2 { y + 1 } else { y };
    (y, m, d)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::cost::TokenSavings;
    use tempfile::NamedTempFile;

    fn make_record(date: &str, openai_before: u64, openai_after: u64) -> RunRecord {
        RunRecord {
            timestamp: format!("{}T12:00:00Z", date),
            date: date.to_string(),
            provider: "openai".to_string(),
            images: 3,
            modified: 2,
            dropped: 0,
            svgs_rasterized: 0,
            bytes_before: 5_000_000,
            bytes_after: 1_000_000,
            token_savings: TokenSavings {
                openai_before,
                openai_after,
                anthropic_before: 3000,
                anthropic_after: 1000,
            },
            duration_ms: 500,
            action_counts: vec![("resize".to_string(), 2)],
        }
    }

    #[test]
    fn test_record_and_load_roundtrip() {
        let tmp = NamedTempFile::new().unwrap();
        let path = tmp.path().to_path_buf();

        let r1 = make_record("2026-04-20", 1000, 300);
        let r2 = make_record("2026-04-21", 2000, 500);

        record_run(&r1, Some(&path)).unwrap();
        record_run(&r2, Some(&path)).unwrap();

        let result = load_records(Some(&path)).unwrap();
        assert_eq!(result.records.len(), 2);
        assert_eq!(result.skipped_lines, 0);
        assert_eq!(result.records[0].date, "2026-04-20");
        assert_eq!(result.records[1].date, "2026-04-21");
    }

    #[test]
    fn test_load_empty_file() {
        let tmp = NamedTempFile::new().unwrap();
        let path = tmp.path().to_path_buf();
        let result = load_records(Some(&path)).unwrap();
        assert!(result.records.is_empty());
        assert_eq!(result.skipped_lines, 0);
    }

    #[test]
    fn test_load_nonexistent_file() {
        let path = PathBuf::from("/tmp/shift-test-nonexistent-stats.jsonl");
        let result = load_records(Some(&path)).unwrap();
        assert!(result.records.is_empty());
        assert_eq!(result.skipped_lines, 0);
    }

    #[test]
    fn test_summarize() {
        let records = vec![
            make_record("2026-04-20", 1000, 300),
            make_record("2026-04-21", 2000, 500),
        ];
        let summary = summarize(&records);
        assert_eq!(summary.total_runs, 2);
        assert_eq!(summary.total_images, 6);
        assert_eq!(summary.total_modified, 4);
        assert_eq!(summary.total_openai_before, 3000);
        assert_eq!(summary.total_openai_after, 800);
        assert_eq!(summary.openai_saved(), 2200);
    }

    #[test]
    fn test_daily_breakdown() {
        let records = vec![
            make_record("2026-04-20", 1000, 300),
            make_record("2026-04-20", 500, 200),
            make_record("2026-04-21", 2000, 500),
        ];
        let daily = daily_breakdown(&records);
        assert_eq!(daily.len(), 2);
        assert_eq!(daily[0].date, "2026-04-20");
        assert_eq!(daily[0].runs, 2);
        assert_eq!(daily[0].openai_saved, 1000); // (1000-300) + (500-200)
        assert_eq!(daily[1].date, "2026-04-21");
        assert_eq!(daily[1].runs, 1);
    }

    #[test]
    fn test_summary_percentages() {
        let summary = GainSummary {
            total_openai_before: 10000,
            total_openai_after: 3000,
            total_anthropic_before: 5000,
            total_anthropic_after: 1000,
            ..Default::default()
        };
        assert!((summary.openai_pct() - 70.0).abs() < 0.1);
        assert!((summary.anthropic_pct() - 80.0).abs() < 0.1);
    }

    #[test]
    fn test_summary_zero_division() {
        let summary = GainSummary::default();
        assert_eq!(summary.openai_pct(), 0.0);
        assert_eq!(summary.anthropic_pct(), 0.0);
    }

    #[test]
    fn test_malformed_lines_skipped() {
        let tmp = NamedTempFile::new().unwrap();
        let path = tmp.path().to_path_buf();

        // Write valid + invalid lines
        let r = make_record("2026-04-20", 1000, 300);
        record_run(&r, Some(&path)).unwrap();
        // Append garbage
        let mut f = fs::OpenOptions::new().append(true).open(&path).unwrap();
        writeln!(f, "not json at all").unwrap();
        writeln!(f, "{{\"partial\": true}}").unwrap();
        // Write another valid record
        record_run(&r, Some(&path)).unwrap();

        let result = load_records(Some(&path)).unwrap();
        assert_eq!(result.records.len(), 2); // only the 2 valid records
        assert_eq!(result.skipped_lines, 2); // 2 malformed lines skipped
    }

    #[test]
    fn test_record_from_report() {
        let mut report = crate::report::Report::new();
        report.images_found = 3;
        report.images_modified = 2;
        report.original_size = 5_000_000;
        report.transformed_size = 1_000_000;
        report.token_savings = TokenSavings {
            openai_before: 2000,
            openai_after: 500,
            anthropic_before: 3000,
            anthropic_after: 800,
        };

        let record = record_from_report(&report, "openai", 1234);
        assert_eq!(record.provider, "openai");
        assert_eq!(record.images, 3);
        assert_eq!(record.modified, 2);
        assert_eq!(record.duration_ms, 1234);
        assert!(!record.timestamp.is_empty());
        assert!(!record.date.is_empty());
    }

    #[test]
    fn test_days_to_ymd() {
        // Unix epoch
        let (y, m, d) = days_to_ymd(0);
        assert_eq!((y, m, d), (1970, 1, 1));

        // Leap year: 2000-02-29 = day 11016
        let (y, m, d) = days_to_ymd(11016);
        assert_eq!((y, m, d), (2000, 2, 29));

        // Day after leap day: 2000-03-01 = day 11017
        let (y, m, d) = days_to_ymd(11017);
        assert_eq!((y, m, d), (2000, 3, 1));

        // Non-leap century year: 2100-02-28 = day 47540
        let (y, m, d) = days_to_ymd(47540);
        assert_eq!((y, m, d), (2100, 2, 28));

        // 2100-03-01 = day 47541 (no Feb 29 in 2100)
        let (y, m, d) = days_to_ymd(47541);
        assert_eq!((y, m, d), (2100, 3, 1));

        // Year boundary: 2025-12-31 = day 20453
        let (y, m, d) = days_to_ymd(20453);
        assert_eq!((y, m, d), (2025, 12, 31));

        // 2026-01-01 = day 20454
        let (y, m, d) = days_to_ymd(20454);
        assert_eq!((y, m, d), (2026, 1, 1));
    }

    #[cfg(unix)]
    #[test]
    fn test_symlink_directory_rejected() {
        use std::os::unix::fs as unix_fs;

        let real_dir = tempfile::tempdir().unwrap();
        let symlink_dir = tempfile::tempdir().unwrap();
        let symlink_path = symlink_dir.path().join("symlinked-shift");

        // Create symlink to real directory
        unix_fs::symlink(real_dir.path(), &symlink_path).unwrap();

        let stats_file = symlink_path.join("stats.jsonl");
        let r = make_record("2026-04-22", 100, 50);
        let result = record_run(&r, Some(&stats_file));

        assert!(result.is_err());
        let err_msg = format!("{}", result.unwrap_err());
        assert!(
            err_msg.contains("symlink"),
            "expected symlink error, got: {}",
            err_msg
        );
    }

    #[cfg(unix)]
    #[test]
    fn test_symlink_file_rejected() {
        use std::os::unix::fs as unix_fs;

        let tmp_dir = tempfile::tempdir().unwrap();
        let real_file = tmp_dir.path().join("real-stats.jsonl");
        let symlink_file = tmp_dir.path().join("stats.jsonl");

        // Create the real file
        fs::write(&real_file, "").unwrap();
        // Create symlink pointing to real file
        unix_fs::symlink(&real_file, &symlink_file).unwrap();

        let r = make_record("2026-04-22", 100, 50);
        let result = record_run(&r, Some(&symlink_file));

        assert!(result.is_err());
        let err_msg = format!("{}", result.unwrap_err());
        assert!(
            err_msg.contains("symlink"),
            "expected symlink error, got: {}",
            err_msg
        );
    }

    #[test]
    fn test_skipped_lines_counted() {
        let tmp = NamedTempFile::new().unwrap();
        let path = tmp.path().to_path_buf();

        let r = make_record("2026-04-22", 500, 200);
        record_run(&r, Some(&path)).unwrap();

        // Append 3 garbage lines
        let mut f = fs::OpenOptions::new().append(true).open(&path).unwrap();
        writeln!(f, "garbage1").unwrap();
        writeln!(f, "garbage2").unwrap();
        writeln!(f, "garbage3").unwrap();

        record_run(&r, Some(&path)).unwrap();

        let result = load_records(Some(&path)).unwrap();
        assert_eq!(result.records.len(), 2);
        assert_eq!(result.skipped_lines, 3);
    }

    // ── Helpers for multi-provider tests ─────────────────────────────

    fn make_anthropic_record(date: &str, anthropic_before: u64, anthropic_after: u64) -> RunRecord {
        RunRecord {
            timestamp: format!("{}T12:00:00Z", date),
            date: date.to_string(),
            provider: "anthropic".to_string(),
            images: 2,
            modified: 1,
            dropped: 0,
            svgs_rasterized: 0,
            bytes_before: 3_000_000,
            bytes_after: 800_000,
            token_savings: TokenSavings {
                openai_before: 500,
                openai_after: 200,
                anthropic_before,
                anthropic_after,
            },
            duration_ms: 300,
            action_counts: vec![("recompress".to_string(), 1)],
        }
    }

    fn make_record_with_actions(date: &str, actions: Vec<(String, usize)>) -> RunRecord {
        RunRecord {
            action_counts: actions,
            ..make_record(date, 1000, 300)
        }
    }

    // ── Purge tests ──────────────────────────────────────────────────

    #[test]
    fn test_purge_removes_old_records() {
        let tmp = NamedTempFile::new().unwrap();
        let path = tmp.path().to_path_buf();

        // Write a record with a very old date (should be purged)
        let old = make_record("2020-01-01", 1000, 300);
        record_run(&old, Some(&path)).unwrap();

        // Write a record with today's date (should be kept)
        let now_secs = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_secs();
        let (y, m, d) = days_to_ymd(now_secs / 86400);
        let today = format!("{:04}-{:02}-{:02}", y, m, d);
        let recent = make_record(&today, 2000, 500);
        record_run(&recent, Some(&path)).unwrap();

        let purged = purge_old_records(&path).unwrap();
        assert_eq!(purged, 1);

        let result = load_records(Some(&path)).unwrap();
        assert_eq!(result.records.len(), 1);
        assert_eq!(result.records[0].date, today);
    }

    #[test]
    fn test_purge_no_op_when_nothing_to_purge() {
        let tmp = NamedTempFile::new().unwrap();
        let path = tmp.path().to_path_buf();

        let now_secs = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_secs();
        let (y, m, d) = days_to_ymd(now_secs / 86400);
        let today = format!("{:04}-{:02}-{:02}", y, m, d);

        let r = make_record(&today, 1000, 300);
        record_run(&r, Some(&path)).unwrap();

        let purged = purge_old_records(&path).unwrap();
        assert_eq!(purged, 0);

        let result = load_records(Some(&path)).unwrap();
        assert_eq!(result.records.len(), 1);
    }

    #[test]
    fn test_purge_all_records_expired() {
        let tmp = NamedTempFile::new().unwrap();
        let path = tmp.path().to_path_buf();

        let old1 = make_record("2019-01-01", 1000, 300);
        let old2 = make_record("2019-06-15", 2000, 500);
        record_run(&old1, Some(&path)).unwrap();
        record_run(&old2, Some(&path)).unwrap();

        let purged = purge_old_records(&path).unwrap();
        assert_eq!(purged, 2);

        let result = load_records(Some(&path)).unwrap();
        assert_eq!(result.records.len(), 0);
    }

    #[test]
    fn test_purge_preserves_record_data() {
        let tmp = NamedTempFile::new().unwrap();
        let path = tmp.path().to_path_buf();

        let now_secs = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_secs();
        let (y, m, d) = days_to_ymd(now_secs / 86400);
        let today = format!("{:04}-{:02}-{:02}", y, m, d);

        let r = RunRecord {
            provider: "anthropic".to_string(),
            images: 7,
            modified: 5,
            duration_ms: 1234,
            action_counts: vec![("resize".to_string(), 3), ("convert".to_string(), 2)],
            ..make_record(&today, 5000, 1500)
        };
        // Also write an old record to force purge to actually rewrite
        let old = make_record("2020-01-01", 100, 50);
        record_run(&old, Some(&path)).unwrap();
        record_run(&r, Some(&path)).unwrap();

        let purged = purge_old_records(&path).unwrap();
        assert_eq!(purged, 1);

        let result = load_records(Some(&path)).unwrap();
        assert_eq!(result.records.len(), 1);
        let kept = &result.records[0];
        assert_eq!(kept.provider, "anthropic");
        assert_eq!(kept.images, 7);
        assert_eq!(kept.modified, 5);
        assert_eq!(kept.duration_ms, 1234);
        assert_eq!(kept.action_counts.len(), 2);
    }

    // ── Per-provider summarization tests ─────────────────────────────

    #[test]
    fn test_summarize_by_provider() {
        let records = vec![
            make_record("2026-04-20", 1000, 300),            // openai
            make_record("2026-04-21", 2000, 500),            // openai
            make_anthropic_record("2026-04-20", 4000, 1000), // anthropic
        ];
        let summary = summarize(&records);

        assert_eq!(summary.by_provider.len(), 2);

        // Sorted by tokens_saved descending
        // anthropic: 4000-1000 = 3000
        // openai:    (1000-300) + (2000-500) = 700 + 1500 = 2200
        assert_eq!(summary.by_provider[0].provider, "anthropic");
        assert_eq!(summary.by_provider[0].tokens_saved, 3000);
        assert_eq!(summary.by_provider[0].runs, 1);
        assert_eq!(summary.by_provider[0].images, 2);
        assert!((summary.by_provider[0].overall_pct - 75.0).abs() < 0.1);

        assert_eq!(summary.by_provider[1].provider, "openai");
        assert_eq!(summary.by_provider[1].tokens_saved, 2200);
        assert_eq!(summary.by_provider[1].runs, 2);
        assert_eq!(summary.by_provider[1].images, 6);
    }

    #[test]
    fn test_summarize_single_provider() {
        let records = vec![make_record("2026-04-20", 1000, 300)];
        let summary = summarize(&records);
        assert_eq!(summary.by_provider.len(), 1);
        assert_eq!(summary.by_provider[0].provider, "openai");
        assert_eq!(summary.by_provider[0].tokens_saved, 700);
    }

    #[test]
    fn test_summarize_provider_duration() {
        let records = vec![
            make_record("2026-04-20", 1000, 300), // duration_ms = 500
            make_record("2026-04-21", 2000, 500), // duration_ms = 500
        ];
        let summary = summarize(&records);
        assert_eq!(summary.by_provider[0].avg_duration_ms, 500); // 1000 total / 2 runs
        assert_eq!(summary.total_duration_ms, 1000);
    }

    // ── Per-action summarization tests ───────────────────────────────

    #[test]
    fn test_summarize_by_action() {
        let records = vec![
            make_record_with_actions(
                "2026-04-20",
                vec![("resize".to_string(), 3), ("convert".to_string(), 1)],
            ),
            make_record_with_actions(
                "2026-04-21",
                vec![("resize".to_string(), 2), ("recompress".to_string(), 4)],
            ),
        ];
        let summary = summarize(&records);

        assert_eq!(summary.by_action.len(), 3);
        // Sorted by count descending: resize=5, recompress=4, convert=1
        assert_eq!(summary.by_action[0].action, "resize");
        assert_eq!(summary.by_action[0].count, 5);
        assert_eq!(summary.by_action[1].action, "recompress");
        assert_eq!(summary.by_action[1].count, 4);
        assert_eq!(summary.by_action[2].action, "convert");
        assert_eq!(summary.by_action[2].count, 1);
    }

    #[test]
    fn test_summarize_empty_actions() {
        let mut r = make_record("2026-04-20", 1000, 300);
        r.action_counts = vec![];
        let summary = summarize(&[r]);
        assert!(summary.by_action.is_empty());
    }

    #[test]
    fn test_summarize_empty_records() {
        let summary = summarize(&[]);
        assert_eq!(summary.total_runs, 0);
        assert!(summary.by_provider.is_empty());
        assert!(summary.by_action.is_empty());
    }
}
