//! Persistent ROM cache — survives across program restarts.
//!
//! Stores `RomList` per platform/collection on disk as JSON. On load, entries
//! are validated against the live `rom_count` from the API; stale entries are
//! silently discarded so only changed platforms trigger a re-fetch.
//!
//! Disk-backed cache for ROM metadata and collection lists.
//!
//! This module provides a fast, persistent cache to reduce API calls
//! and improve the responsiveness of the TUI and CLI.

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

use serde::{Deserialize, Serialize};

use crate::types::RomList;

/// Cache file name used by both default and legacy paths.
const DEFAULT_CACHE_FILE: &str = "romm-cache.json";

// ---------------------------------------------------------------------------
// Cache key
// ---------------------------------------------------------------------------

#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
pub enum RomCacheKey {
    Platform(u64),
    /// Manual (user) collection from `GET /api/collections`.
    Collection(u64),
    /// Smart collection from `GET /api/collections/smart` (distinct ID space from manual).
    SmartCollection(u64),
    /// Virtual (autogenerated) collection from `GET /api/collections/virtual` (string id).
    VirtualCollection(String),
}

// ---------------------------------------------------------------------------
// On-disk format
// ---------------------------------------------------------------------------

#[derive(Serialize, Deserialize)]
struct CacheFile {
    version: u32,
    entries: Vec<CacheEntry>,
}

#[derive(Serialize, Deserialize)]
struct CacheEntry {
    key: RomCacheKey,
    /// The `platform.rom_count` (or `collection.rom_count`) at the time we
    /// cached this data.  On lookup we compare this against the *current*
    /// platform count, NOT against `data.total` (which can legitimately differ).
    expected_count: u64,
    data: RomList,
}

// ---------------------------------------------------------------------------
// RomCache
// ---------------------------------------------------------------------------

/// In-memory view of the persisted ROM cache.
///
/// Internally this is just a `HashMap` keyed by [`RomCacheKey`], plus the
/// path of the JSON file on disk. All callers go through [`RomCache::load`]
/// so they never touch the filesystem directly.
pub struct RomCache {
    entries: HashMap<RomCacheKey, (u64, RomList)>, // (expected_count, data)
    path: PathBuf,
}

/// Basic diagnostics for the ROM cache file on disk.
#[derive(Debug, Clone)]
pub struct RomCacheInfo {
    pub path: PathBuf,
    pub exists: bool,
    pub size_bytes: Option<u64>,
    pub version: Option<u32>,
    pub entry_count: Option<usize>,
    pub parse_error: Option<String>,
}

impl RomCache {
    /// Load cache from disk (or start empty if the file is missing / corrupt).
    pub fn load() -> Self {
        let (path, from_env_override) = cache_path_with_override();
        if !from_env_override {
            maybe_migrate_legacy_cache(&path);
        }
        Self::load_from(path)
    }

    /// Effective ROM cache path (`ROMM_CACHE_PATH` override wins over OS-local default).
    pub fn effective_path() -> PathBuf {
        cache_path_with_override().0
    }

    /// Remove the cache file from disk if it exists.
    pub fn clear_file() -> std::io::Result<bool> {
        let path = Self::effective_path();
        if path.is_file() {
            std::fs::remove_file(path)?;
            return Ok(true);
        }
        Ok(false)
    }

    /// Read best-effort metadata and parse information for the current cache file.
    pub fn read_info() -> RomCacheInfo {
        let path = Self::effective_path();
        let meta = std::fs::metadata(&path).ok();
        let exists = meta.is_some();
        let size_bytes = meta.map(|m| m.len());

        let mut info = RomCacheInfo {
            path: path.clone(),
            exists,
            size_bytes,
            version: None,
            entry_count: None,
            parse_error: None,
        };

        if !exists {
            return info;
        }

        match std::fs::read_to_string(&path) {
            Ok(data) => match serde_json::from_str::<CacheFile>(&data) {
                Ok(file) => {
                    info.version = Some(file.version);
                    info.entry_count = Some(file.entries.len());
                }
                Err(err) => {
                    info.parse_error = Some(err.to_string());
                }
            },
            Err(err) => {
                info.parse_error = Some(err.to_string());
            }
        }

        info
    }

    fn load_from(path: PathBuf) -> Self {
        let entries = Self::read_file(&path).unwrap_or_default();
        Self { entries, path }
    }

    fn read_file(path: &Path) -> Option<HashMap<RomCacheKey, (u64, RomList)>> {
        let data = std::fs::read_to_string(path).ok()?;
        let file: CacheFile = serde_json::from_str(&data).ok()?;
        if file.version != 1 {
            return None;
        }
        let map = file
            .entries
            .into_iter()
            .map(|e| (e.key, (e.expected_count, e.data)))
            .collect();
        Some(map)
    }

    /// Persist current cache to disk (best-effort; errors are silently ignored).
    pub fn save(&self) {
        let file = CacheFile {
            version: 1,
            entries: self
                .entries
                .iter()
                .map(|(k, (ec, v))| CacheEntry {
                    key: k.clone(),
                    expected_count: *ec,
                    data: v.clone(),
                })
                .collect(),
        };
        let path = self.path.clone();

        let write_fn = move || match serde_json::to_string(&file) {
            Ok(json) => {
                if let Some(parent) = path.parent() {
                    if let Err(err) = std::fs::create_dir_all(parent) {
                        eprintln!(
                            "warning: failed to create ROM cache directory {:?}: {}",
                            parent, err
                        );
                        return;
                    }
                }
                if let Err(err) = std::fs::write(&path, json) {
                    eprintln!(
                        "warning: failed to write ROM cache file {:?}: {}",
                        path, err
                    );
                }
            }
            Err(err) => {
                eprintln!(
                    "warning: failed to serialize ROM cache file {:?}: {}",
                    path, err
                );
            }
        };

        #[cfg(test)]
        write_fn();

        #[cfg(not(test))]
        std::thread::spawn(write_fn);
    }

    /// Return cached data **only** if the platform's `rom_count` hasn't changed
    /// since we cached it.  We compare the stored count (from the platforms
    /// endpoint at cache time) against the current count — NOT `RomList.total`,
    /// which can legitimately differ from `rom_count`.
    pub fn get_valid(&self, key: &RomCacheKey, expected_count: u64) -> Option<&RomList> {
        self.entries
            .get(key)
            .filter(|(stored_count, _)| *stored_count == expected_count)
            .map(|(_, list)| list)
    }

    /// Insert (or replace) an entry, then persist to disk.
    /// `expected_count` is the platform/collection `rom_count` at this moment.
    pub fn insert(&mut self, key: RomCacheKey, data: RomList, expected_count: u64) {
        self.entries.insert(key, (expected_count, data));
        self.save();
    }

    /// Drop one cache entry if present, then persist.
    pub fn remove(&mut self, key: &RomCacheKey) -> bool {
        let removed = self.entries.remove(key).is_some();
        if removed {
            self.save();
        }
        removed
    }

    /// Remove every [`RomCacheKey::Platform`] entry (e.g. after a full `scan_library`).
    pub fn remove_all_platform_entries(&mut self) -> usize {
        let before = self.entries.len();
        self.entries
            .retain(|k, _| !matches!(k, RomCacheKey::Platform(_)));
        let removed = before - self.entries.len();
        if removed > 0 {
            self.save();
        }
        removed
    }
}

fn cache_path_with_override() -> (PathBuf, bool) {
    if let Ok(path) = std::env::var("ROMM_CACHE_PATH") {
        return (PathBuf::from(path), true);
    }
    (default_cache_path(), false)
}

fn default_cache_path() -> PathBuf {
    if let Ok(dir) = std::env::var("ROMM_TEST_CACHE_DIR") {
        return PathBuf::from(dir).join(DEFAULT_CACHE_FILE);
    }
    if let Some(dir) = dirs::cache_dir() {
        return dir.join("romm-cli").join(DEFAULT_CACHE_FILE);
    }
    PathBuf::from(DEFAULT_CACHE_FILE)
}

fn legacy_cache_path() -> PathBuf {
    PathBuf::from(DEFAULT_CACHE_FILE)
}

fn maybe_migrate_legacy_cache(destination: &Path) {
    if destination.is_file() {
        return;
    }

    let legacy = legacy_cache_path();
    if !legacy.is_file() {
        return;
    }
    if RomCache::read_file(&legacy).is_none() {
        tracing::warn!(
            "Skipping legacy ROM cache migration from {} because file is unreadable or invalid",
            legacy.display()
        );
        return;
    }
    if let Some(parent) = destination.parent() {
        if let Err(err) = std::fs::create_dir_all(parent) {
            tracing::warn!(
                "Failed to create ROM cache directory {} for migration: {}",
                parent.display(),
                err
            );
            return;
        }
    }
    match std::fs::copy(&legacy, destination) {
        Ok(_) => tracing::info!(
            "Migrated ROM cache from {} to {}",
            legacy.display(),
            destination.display()
        ),
        Err(err) => tracing::warn!(
            "Failed to migrate ROM cache from {} to {}: {}",
            legacy.display(),
            destination.display(),
            err
        ),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::types::Rom;
    use std::sync::{Mutex, MutexGuard, OnceLock};
    use std::time::{SystemTime, UNIX_EPOCH};

    fn env_lock() -> &'static Mutex<()> {
        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
        LOCK.get_or_init(|| Mutex::new(()))
    }

    struct TestEnv {
        _guard: MutexGuard<'static, ()>,
    }

    impl TestEnv {
        fn new() -> Self {
            let guard = env_lock().lock().expect("env lock");
            std::env::remove_var("ROMM_CACHE_PATH");
            std::env::remove_var("ROMM_TEST_CACHE_DIR");
            Self { _guard: guard }
        }
    }

    impl Drop for TestEnv {
        fn drop(&mut self) {
            std::env::remove_var("ROMM_CACHE_PATH");
            std::env::remove_var("ROMM_TEST_CACHE_DIR");
        }
    }

    fn sample_rom_list() -> RomList {
        RomList {
            items: vec![Rom {
                id: 1,
                platform_id: 10,
                platform_slug: None,
                platform_fs_slug: None,
                platform_custom_name: Some("NES".to_string()),
                platform_display_name: Some("NES".to_string()),
                fs_name: "Mario (USA).zip".to_string(),
                fs_name_no_tags: "Mario".to_string(),
                fs_name_no_ext: "Mario".to_string(),
                fs_extension: "zip".to_string(),
                fs_path: "/roms/mario.zip".to_string(),
                fs_size_bytes: 1234,
                name: "Mario".to_string(),
                slug: Some("mario".to_string()),
                summary: Some("A platform game".to_string()),
                path_cover_small: None,
                path_cover_large: None,
                url_cover: None,
                has_manual: false,
                path_manual: None,
                url_manual: None,
                is_unidentified: false,
                is_identified: true,
                files: Vec::new(),
            }],
            total: 1,
            limit: 50,
            offset: 0,
        }
    }

    fn temp_cache_path() -> PathBuf {
        let ts = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .expect("time")
            .as_nanos();
        std::env::temp_dir().join(format!("romm-cache-test-{}.json", ts))
    }

    #[test]
    fn returns_cache_only_for_matching_expected_count() {
        let path = temp_cache_path();
        let mut cache = RomCache::load_from(path.clone());
        let key = RomCacheKey::Platform(42);
        let list = sample_rom_list();
        cache.insert(key.clone(), list.clone(), 7);

        assert!(cache.get_valid(&key, 7).is_some());
        assert!(cache.get_valid(&key, 8).is_none());

        let _ = std::fs::remove_file(path);
    }

    #[test]
    fn persists_and_reloads_entries_from_disk() {
        let path = temp_cache_path();
        let mut cache = RomCache::load_from(path.clone());
        let key = RomCacheKey::Collection(9);
        let list = sample_rom_list();
        cache.insert(key.clone(), list.clone(), 3);

        let loaded = RomCache::load_from(path.clone());
        let cached = loaded.get_valid(&key, 3).expect("cached value");
        assert_eq!(cached.items.len(), 1);
        assert_eq!(cached.items[0].name, "Mario");

        let _ = std::fs::remove_file(path);
    }

    #[test]
    fn persists_virtual_collection_key() {
        let path = temp_cache_path();
        let mut cache = RomCache::load_from(path.clone());
        let key = RomCacheKey::VirtualCollection("recent".to_string());
        let list = sample_rom_list();
        cache.insert(key.clone(), list.clone(), 5);

        let loaded = RomCache::load_from(path.clone());
        let cached = loaded.get_valid(&key, 5).expect("cached value");
        assert_eq!(cached.items.len(), 1);

        let _ = std::fs::remove_file(path);
    }

    #[test]
    fn remove_and_remove_all_platform_entries() {
        let path = temp_cache_path();
        let mut cache = RomCache::load_from(path.clone());
        cache.insert(RomCacheKey::Platform(1), sample_rom_list(), 1);
        cache.insert(RomCacheKey::Platform(2), sample_rom_list(), 1);
        cache.insert(RomCacheKey::Collection(9), sample_rom_list(), 1);

        assert!(cache.remove(&RomCacheKey::Platform(1)));
        assert!(cache.get_valid(&RomCacheKey::Platform(1), 1).is_none());
        assert!(cache.get_valid(&RomCacheKey::Platform(2), 1).is_some());

        assert_eq!(cache.remove_all_platform_entries(), 1);
        assert!(cache.get_valid(&RomCacheKey::Platform(2), 1).is_none());
        assert!(cache.get_valid(&RomCacheKey::Collection(9), 1).is_some());

        let _ = std::fs::remove_file(path);
    }

    #[test]
    fn effective_path_uses_env_override_when_set() {
        let _env = TestEnv::new();
        let path = std::env::temp_dir().join("romm-cache-env-override.json");
        std::env::set_var("ROMM_CACHE_PATH", &path);
        assert_eq!(RomCache::effective_path(), path);
    }

    #[test]
    fn effective_path_uses_test_cache_dir_without_override() {
        let _env = TestEnv::new();
        let dir = std::env::temp_dir().join("romm-cache-default-dir-test");
        std::env::set_var("ROMM_TEST_CACHE_DIR", &dir);
        assert_eq!(RomCache::effective_path(), dir.join(DEFAULT_CACHE_FILE));
    }

    #[test]
    fn migrates_legacy_cache_once_for_default_path() {
        let _env = TestEnv::new();
        let ts = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .expect("time")
            .as_nanos();
        let work_dir = std::env::temp_dir().join(format!("romm-cache-migrate-cwd-{ts}"));
        let cache_dir = std::env::temp_dir().join(format!("romm-cache-migrate-dest-{ts}"));
        std::fs::create_dir_all(&work_dir).expect("create work dir");
        std::env::set_var("ROMM_TEST_CACHE_DIR", &cache_dir);

        let prev_cwd = std::env::current_dir().expect("cwd");
        std::env::set_current_dir(&work_dir).expect("set cwd");
        let mut legacy = RomCache::load_from(PathBuf::from(DEFAULT_CACHE_FILE));
        let key = RomCacheKey::Platform(7);
        legacy.insert(key.clone(), sample_rom_list(), 1);

        let migrated = RomCache::load();
        assert!(migrated.get_valid(&key, 1).is_some());

        std::env::set_current_dir(prev_cwd).expect("restore cwd");
        let _ = std::fs::remove_dir_all(&work_dir);
        let _ = std::fs::remove_dir_all(&cache_dir);
    }

    #[test]
    fn clear_file_removes_existing_cache_file() {
        let _env = TestEnv::new();
        let path = temp_cache_path();
        std::env::set_var("ROMM_CACHE_PATH", &path);
        let mut cache = RomCache::load();
        cache.insert(RomCacheKey::Platform(1), sample_rom_list(), 1);
        assert!(path.is_file());
        assert!(RomCache::clear_file().expect("clear should work"));
        assert!(!path.exists());
    }
}
