//! `mcptest cache` handler.
//!
//! Operator-facing controls on the local cache store. The store itself
//! lives in `mcptest_core::cache::FsCacheStore`; this module is the
//! thin CLI surface that lists entries, totals their size, and evicts
//! them. Caching telemetry (hit/miss rates) is a future runner concern
//! and shows up here as a placeholder until the runner records it.

use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};

use anyhow::{anyhow, bail, Context, Result};
use mcptest_core::cache::{CacheStore, FsCacheStore};

/// Args for [`run_cache`].
#[derive(Debug, Clone)]
pub struct CacheRunArgs {
    /// Which subcommand the operator chose.
    pub command: CacheSubcommand,
    /// Override the cache directory. Defaults to the per-OS user cache
    /// dir joined with `mcptest`.
    pub cache_dir: Option<PathBuf>,
}

/// `mcptest cache <sub>` actions.
///
/// Kept narrow so each variant lines up with one CLI subcommand the
/// operator types. The args module in `cli.rs` translates clap into
/// this plain-data shape.
#[derive(Debug, Clone)]
pub enum CacheSubcommand {
    /// Print every cached entry plus its size and age.
    List,
    /// Print totals (entry count, total bytes, oldest age) for the
    /// store. Hit/miss rates are placeholder until the runner records
    /// them.
    Stats,
    /// Remove entries. `older_than` is an optional human-friendly age
    /// (`30m`, `2h`, `7d`); without it every entry is removed.
    Clear {
        /// Only remove entries older than this duration spec.
        older_than: Option<String>,
    },
    /// Remove entries older than 30 days. Equivalent to
    /// `Clear { older_than: Some("30d") }` but explicit so CI scripts
    /// can express intent without picking a number.
    Prune,
}

/// Default prune age. Thirty days matches the platform expectation for
/// GitHub Actions cache; longer than that and entries are usually a
/// cleanup candidate even if they have not aged out of TTL.
const DEFAULT_PRUNE_AGE_SECONDS: u64 = 30 * 24 * 60 * 60;

/// Handle `mcptest cache <sub>`. Returns the exit code.
pub fn run_cache(args: CacheRunArgs) -> Result<i32> {
    let store = open_store(args.cache_dir.as_deref())?;
    match args.command {
        CacheSubcommand::List => list(&store),
        CacheSubcommand::Stats => stats(&store),
        CacheSubcommand::Clear { older_than } => clear(&store, older_than),
        CacheSubcommand::Prune => clear(&store, Some(format!("{DEFAULT_PRUNE_AGE_SECONDS}s"))),
    }
}

/// Resolve the on-disk cache store. Honors `--cache-dir`, falls back
/// to the user cache directory.
fn open_store(override_dir: Option<&std::path::Path>) -> Result<FsCacheStore> {
    if let Some(path) = override_dir {
        Ok(FsCacheStore::with_root(path))
    } else {
        FsCacheStore::user_default()
            .map_err(|e| anyhow!("could not resolve user cache directory: {e}"))
    }
}

fn list(store: &FsCacheStore) -> Result<i32> {
    let mut keys = store.list().context("could not list cache entries")?;
    keys.sort();
    if keys.is_empty() {
        println!("Cache is empty.");
        return Ok(0);
    }
    let now = now_unix();
    for key in &keys {
        let (size, age) = entry_metadata(store, key, now);
        println!("{key}  size={size}B  age={age}s");
    }
    println!("\n{} entries.", keys.len());
    Ok(0)
}

fn stats(store: &FsCacheStore) -> Result<i32> {
    let keys = store.list().context("could not list cache entries")?;
    let now = now_unix();
    let mut total_bytes: u64 = 0;
    let mut oldest_age: u64 = 0;
    for key in &keys {
        let (size, age) = entry_metadata(store, key, now);
        total_bytes = total_bytes.saturating_add(size);
        if age > oldest_age {
            oldest_age = age;
        }
    }
    println!("entries: {}", keys.len());
    println!("bytes:   {}", total_bytes);
    println!("oldest:  {}s", oldest_age);
    println!("hit-rate: n/a (runner telemetry not wired yet)");
    Ok(0)
}

fn clear(store: &FsCacheStore, older_than: Option<String>) -> Result<i32> {
    match older_than {
        Some(spec) => {
            let seconds = parse_duration(&spec)
                .with_context(|| format!("could not parse --older-than {spec}"))?;
            let removed = store.evict_older_than(seconds).context("eviction failed")?;
            println!("Removed {removed} entries older than {spec}.");
            Ok(0)
        }
        None => {
            let keys = store.list().context("could not list cache entries")?;
            let mut removed = 0usize;
            for key in &keys {
                store
                    .delete(key)
                    .with_context(|| format!("could not delete {key}"))?;
                removed += 1;
            }
            println!("Removed {removed} entries.");
            Ok(0)
        }
    }
}

/// Look up the on-disk size and the age in seconds for one cache key.
/// Missing metadata returns zeros so the operator's `list` output stays
/// readable even when the store has half-written entries.
fn entry_metadata(store: &FsCacheStore, key: &str, now: u64) -> (u64, u64) {
    let root = store.root();
    let shard = if key.len() >= 2 { &key[..2] } else { "_" };
    let payload = root.join(shard).join(format!("{key}.bin"));
    let size = fs::metadata(&payload).map(|m| m.len()).unwrap_or(0);
    let modified = fs::metadata(&payload)
        .and_then(|m| m.modified())
        .ok()
        .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
        .map(|d| d.as_secs())
        .unwrap_or(now);
    let age = now.saturating_sub(modified);
    (size, age)
}

fn now_unix() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0)
}

/// Parse a duration spec like `30m`, `2h`, `7d`, or `45s` into seconds.
/// Single suffix, integer value. Rejects empty input, missing suffix,
/// negative numbers, and unknown suffixes.
fn parse_duration(spec: &str) -> Result<u64> {
    let spec = spec.trim();
    if spec.is_empty() {
        bail!("empty duration");
    }
    let (digits, suffix) = spec.split_at(spec.len() - 1);
    let multiplier = match suffix {
        "s" => 1u64,
        "m" => 60,
        "h" => 60 * 60,
        "d" => 60 * 60 * 24,
        other => bail!("unknown duration suffix `{other}` (use s, m, h, or d)"),
    };
    let value: u64 = digits
        .parse()
        .map_err(|_| anyhow!("could not parse `{digits}` as a non-negative integer"))?;
    Ok(value.saturating_mul(multiplier))
}

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

    fn empty_store() -> (FsCacheStore, tempfile::TempDir) {
        let dir = tempdir().expect("tempdir");
        let store = FsCacheStore::with_root(dir.path());
        (store, dir)
    }

    #[test]
    fn list_handles_empty_cache_without_error() {
        let (_store, dir) = empty_store();
        let code = run_cache(CacheRunArgs {
            command: CacheSubcommand::List,
            cache_dir: Some(dir.path().to_path_buf()),
        })
        .expect("run");
        assert_eq!(code, 0);
    }

    #[test]
    fn stats_handles_empty_cache() {
        let (_store, dir) = empty_store();
        let code = run_cache(CacheRunArgs {
            command: CacheSubcommand::Stats,
            cache_dir: Some(dir.path().to_path_buf()),
        })
        .expect("run");
        assert_eq!(code, 0);
    }

    #[test]
    fn clear_with_no_filter_removes_every_entry() {
        let (store, dir) = empty_store();
        let key = "a".repeat(64);
        store.put(&key, b"payload").expect("put");
        let code = run_cache(CacheRunArgs {
            command: CacheSubcommand::Clear { older_than: None },
            cache_dir: Some(dir.path().to_path_buf()),
        })
        .expect("run");
        assert_eq!(code, 0);
        assert!(store.get(&key).expect("get").is_none());
    }

    #[test]
    fn clear_with_far_future_threshold_keeps_fresh_entries() {
        let (store, dir) = empty_store();
        let key = "b".repeat(64);
        store.put(&key, b"payload").expect("put");
        let code = run_cache(CacheRunArgs {
            command: CacheSubcommand::Clear {
                older_than: Some("365d".into()),
            },
            cache_dir: Some(dir.path().to_path_buf()),
        })
        .expect("run");
        assert_eq!(code, 0);
        assert!(store.get(&key).expect("get").is_some());
    }

    #[test]
    fn prune_runs_without_error_on_empty_cache() {
        let (_store, dir) = empty_store();
        let code = run_cache(CacheRunArgs {
            command: CacheSubcommand::Prune,
            cache_dir: Some(dir.path().to_path_buf()),
        })
        .expect("run");
        assert_eq!(code, 0);
    }

    #[test]
    fn parse_duration_accepts_seconds_minutes_hours_days() {
        assert_eq!(parse_duration("45s").unwrap(), 45);
        assert_eq!(parse_duration("30m").unwrap(), 30 * 60);
        assert_eq!(parse_duration("2h").unwrap(), 2 * 60 * 60);
        assert_eq!(parse_duration("7d").unwrap(), 7 * 24 * 60 * 60);
    }

    #[test]
    fn parse_duration_rejects_empty_and_unknown_suffix() {
        assert!(parse_duration("").is_err());
        assert!(parse_duration("10x").is_err());
        assert!(parse_duration("abc").is_err());
    }
}
