//! Disk-persisted sha256 cache for workspace scans.
//!
//! Keys are `"{path} {size} {mtime_ms}"` (same scheme workspace.rs has always
//! used in memory), values are sha256 hex. One JSON file serves every
//! workspace; entries are pruned per-root after each hashed scan so the file
//! tracks the live workspace contents.

use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};

pub struct HashCacheState {
    pub entries: HashMap<String, String>,
    /// Whether `entries` has been hydrated from disk yet (lazy, on first scan).
    pub loaded: bool,
}

impl Default for HashCacheState {
    fn default() -> Self {
        Self { entries: HashMap::new(), loaded: false }
    }
}

pub fn cache_file_path(app_cache_dir: &Path) -> PathBuf {
    app_cache_dir.join("workspace-hash-cache-v1.json")
}

/// Missing or corrupt cache file is not an error — correctness never depends
/// on the cache, only rescan speed.
pub fn load_entries(path: &Path) -> HashMap<String, String> {
    std::fs::read(path)
        .ok()
        .and_then(|bytes| serde_json::from_slice(&bytes).ok())
        .unwrap_or_default()
}

static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);

/// Atomic write (temp file + rename) so a crash mid-save can't corrupt the
/// cache into a state load_entries would half-read. The temp name is unique
/// per call (pid + a monotonic counter) so two concurrent hashed scans —
/// e.g. an overlapping refresh storm — can't interleave writes to the same
/// temp path and corrupt each other's save (worst case was a reload-empty
/// cache; correctness never depended on it, but it's cheap to close off).
pub fn save_entries(path: &Path, entries: &HashMap<String, String>) -> std::io::Result<()> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let pid = std::process::id();
    let n = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
    let tmp = path.with_extension(format!("json.tmp{pid}-{n}"));
    std::fs::write(&tmp, serde_json::to_vec(entries)?)?;
    std::fs::rename(&tmp, path)?;
    Ok(())
}

/// Drop entries under `root` whose key wasn't seen in the scan that just
/// finished (deleted files, stale mtimes). Entries for other workspace roots
/// are kept. Returns how many entries were removed.
pub fn prune_stale(
    entries: &mut HashMap<String, String>,
    root: &Path,
    live_keys: &HashSet<String>,
) -> usize {
    let prefix = format!("{}{}", root.display(), std::path::MAIN_SEPARATOR);
    let before = entries.len();
    entries.retain(|key, _| !key.starts_with(&prefix) || live_keys.contains(key));
    before - entries.len()
}

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

    #[test]
    fn save_load_roundtrip() {
        let dir = tempfile::tempdir().unwrap();
        let path = cache_file_path(dir.path());
        let mut entries = HashMap::new();
        entries.insert("/ws/a.txt 5 123".to_string(), "abc".to_string());
        save_entries(&path, &entries).unwrap();
        assert_eq!(load_entries(&path), entries);
        // No temp file left behind — the temp name is unique per call (pid +
        // counter), so check the whole parent dir rather than one fixed path.
        let leftover_tmp = std::fs::read_dir(dir.path())
            .unwrap()
            .filter_map(|e| e.ok())
            .any(|e| e.file_name().to_string_lossy().contains(".tmp"));
        assert!(!leftover_tmp, "no *.tmp* sibling files should remain after save");
    }

    #[test]
    fn corrupt_or_missing_file_loads_empty() {
        let dir = tempfile::tempdir().unwrap();
        let path = cache_file_path(dir.path());
        assert!(load_entries(&path).is_empty()); // missing
        std::fs::write(&path, b"{not json").unwrap();
        assert!(load_entries(&path).is_empty()); // corrupt
    }

    #[test]
    fn prune_drops_stale_keys_under_root_only() {
        let mut entries = HashMap::new();
        entries.insert("/ws/a.txt 5 1".to_string(), "h1".to_string()); // live
        entries.insert("/ws/a.txt 5 0".to_string(), "h0".to_string()); // stale (old mtime)
        entries.insert("/ws/gone.txt 9 2".to_string(), "h2".to_string()); // deleted file
        entries.insert("/other/b.txt 5 1".to_string(), "h3".to_string()); // other root
        let live: HashSet<String> = ["/ws/a.txt 5 1".to_string()].into();
        let removed = prune_stale(&mut entries, Path::new("/ws"), &live);
        assert_eq!(removed, 2);
        assert!(entries.contains_key("/ws/a.txt 5 1"));
        assert!(entries.contains_key("/other/b.txt 5 1"));
    }
}
