//! Caching utilities for CLI performance optimization.
//!
//! This module provides caching for expensive operations like:
//! - Cargo metadata results
//! - Runner binary staleness detection via content hashing

use es_fluent_runner::PackageName;
use fs_err as fs;
use indexmap::IndexMap;
use path_slash::PathExt as _;
use serde::{Deserialize, Serialize};
use std::path::{Component, Path};

const GENERATED_ROOT_SOURCE_DIRS: &[&str] = &[".es-fluent", "target"];

fn is_ignored_root_source_entry(src_dir: &Path, path: &Path, ignored_root_dirs: &[&str]) -> bool {
    let Ok(relative_path) = path.strip_prefix(src_dir) else {
        return false;
    };

    relative_path
        .components()
        .next()
        .and_then(|component| match component {
            Component::Normal(name) => Some(name),
            _ => None,
        })
        .is_some_and(|name| ignored_root_dirs.iter().any(|ignored| name == *ignored))
}

fn hash_rs_sources(hasher: &mut blake3::Hasher, src_dir: &Path, ignored_root_dirs: &[&str]) {
    let mut files: Vec<std::path::PathBuf> = Vec::new();

    if src_dir.exists() {
        let walker = walkdir::WalkDir::new(src_dir)
            .into_iter()
            .filter_entry(|entry| {
                !is_ignored_root_source_entry(src_dir, entry.path(), ignored_root_dirs)
            });
        for entry in walker.into_iter().filter_map(|e| e.ok()) {
            let path = entry.path();
            if path.is_file() && path.extension().is_some_and(|e| e == "rs") {
                files.push(path.to_path_buf());
            }
        }
    }

    files.sort();

    for path in files {
        if let Ok(content) = fs::read(&path) {
            let relative_path = path.strip_prefix(src_dir).unwrap_or(&path);
            let normalized_path = relative_path.to_slash_lossy();
            hasher.update(normalized_path.as_bytes());
            hasher.update(&content);
        }
    }
}

fn hash_optional_file(hasher: &mut blake3::Hasher, label: &str, path: &Path) {
    if let Ok(content) = fs::read(path) {
        hasher.update(label.as_bytes());
        hasher.update(&content);
    }
}

/// Cache of cargo metadata results.
///
/// Stores extracted dependency info keyed by Cargo.lock hash to avoid
/// running cargo_metadata on every invocation.
#[derive(Debug, Deserialize, Serialize)]
pub struct MetadataCache {
    /// Hash of Cargo.lock when cache was created
    pub cargo_lock_hash: String,
    /// Extracted es-fluent dependency spec
    pub es_fluent_dep: cargo_manifest::Dependency,
    /// Extracted es-fluent-cli-helpers dependency spec
    pub es_fluent_cli_helpers_dep: cargo_manifest::Dependency,
    /// Target directory
    pub target_dir: String,
}

impl MetadataCache {
    const CACHE_FILE: &'static str = "metadata_cache.json";

    /// Load cache from the temp directory.
    pub fn load(temp_dir: &Path) -> Option<Self> {
        let cache_path = temp_dir.join(Self::CACHE_FILE);
        let content = fs::read_to_string(&cache_path).ok()?;
        serde_json::from_str(&content).ok()
    }

    /// Save cache to the temp directory.
    pub fn save(&self, temp_dir: &Path) -> std::io::Result<()> {
        let cache_path = temp_dir.join(Self::CACHE_FILE);
        let content = serde_json::to_string_pretty(self)?;
        fs::write(cache_path, content)
    }

    /// Compute hash of Cargo.lock file.
    pub fn hash_cargo_lock(workspace_root: &Path) -> Option<String> {
        let lock_path = workspace_root.join("Cargo.lock");
        let content = fs::read(&lock_path).ok()?;
        Some(blake3::hash(&content).to_hex().to_string())
    }

    /// Check if the Cargo.lock hash matches the cached one.
    pub fn is_valid(&self, workspace_root: &Path) -> bool {
        Self::hash_cargo_lock(workspace_root)
            .map(|h| h == self.cargo_lock_hash)
            .unwrap_or(false)
    }
}

/// Compute blake3 hash of crate-local inputs that affect the monolithic runner and watch mode.
///
/// This includes:
/// - `src/**/*.rs`
/// - `i18n.toml` when present
/// - crate-local `Cargo.toml`
/// - crate-local `build.rs`
pub fn compute_crate_inputs_hash(
    manifest_dir: &Path,
    src_dir: &Path,
    i18n_toml_path: Option<&Path>,
) -> String {
    use blake3::Hasher;

    let mut hasher = Hasher::new();
    let ignored_root_dirs = if src_dir == manifest_dir {
        GENERATED_ROOT_SOURCE_DIRS
    } else {
        &[]
    };
    hash_rs_sources(&mut hasher, src_dir, ignored_root_dirs);

    if let Some(toml_path) = i18n_toml_path
        && toml_path.is_file()
    {
        hash_optional_file(&mut hasher, "i18n.toml", toml_path);
    }

    hash_optional_file(&mut hasher, "Cargo.toml", &manifest_dir.join("Cargo.toml"));
    hash_optional_file(&mut hasher, "build.rs", &manifest_dir.join("build.rs"));

    hasher.finalize().to_hex().to_string()
}

/// Compute blake3 hash of workspace-level inputs that affect the monolithic runner crate.
///
/// The generated runner mirrors the workspace root `Cargo.toml` overrides and copies
/// `Cargo.lock`, so both files must invalidate the cached binary when their contents change.
pub fn compute_workspace_inputs_hash(workspace_root: &Path) -> String {
    use blake3::Hasher;

    let mut hasher = Hasher::new();

    for file_name in ["Cargo.toml", "Cargo.lock"] {
        let path = workspace_root.join(file_name);
        if let Ok(content) = fs::read(&path) {
            hasher.update(file_name.as_bytes());
            hasher.update(&content);
        }
    }

    hasher.finalize().to_hex().to_string()
}

/// Runner binary cache tracking which content hashes it was built with.
///
/// Stored at the workspace level since the runner is monolithic.
#[derive(Debug, Default, Deserialize, Serialize)]
pub struct RunnerCache {
    /// Map of crate name -> content hash when runner was last built.
    pub crate_hashes: IndexMap<PackageName, String>,
    /// Mtime of runner binary when cache was created
    pub runner_mtime: u64,
    /// Version of es-fluent-cli that built this runner
    /// Missing/mismatched version triggers rebuild to pick up helper changes
    #[serde(default)]
    pub cli_version: String,
    /// Hash of workspace-level runner inputs like the root manifest and lockfile.
    #[serde(default)]
    pub workspace_inputs_hash: String,
}

impl RunnerCache {
    const CACHE_FILE: &'static str = "runner_cache.json";

    /// Load cache from the temp directory.
    pub fn load(temp_dir: &Path) -> Option<Self> {
        let cache_path = temp_dir.join(Self::CACHE_FILE);
        let content = fs::read_to_string(&cache_path).ok()?;
        serde_json::from_str(&content).ok()
    }

    /// Save cache to the temp directory.
    pub fn save(&self, temp_dir: &Path) -> std::io::Result<()> {
        let cache_path = temp_dir.join(Self::CACHE_FILE);
        let content = serde_json::to_string_pretty(self)?;
        fs::write(cache_path, content)
    }
}

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

    const I18N_CONFIG: &str = "fallback_language = \"en\"\nassets_dir = \"i18n\"\n";

    /// Compute blake3 hash of all .rs files in a source directory, plus the i18n.toml file.
    ///
    /// Used for staleness detection - saving a file without modifications
    /// won't change the hash, avoiding unnecessary rebuilds.
    ///
    /// The `i18n_toml_path` parameter includes the i18n.toml configuration file
    /// in the hash, so changes to settings like `fluent_feature` trigger rebuilds.
    pub fn compute_content_hash(src_dir: &Path, i18n_toml_path: Option<&Path>) -> String {
        use blake3::Hasher;

        let mut hasher = Hasher::new();
        hash_rs_sources(&mut hasher, src_dir, &[]);

        // Include i18n.toml if provided and exists
        if let Some(toml_path) = i18n_toml_path
            && toml_path.is_file()
        {
            hash_optional_file(&mut hasher, "i18n.toml", toml_path);
        }

        hasher.finalize().to_hex().to_string()
    }

    #[test]
    fn test_compute_content_hash_without_i18n_toml() {
        let temp_dir = tempfile::tempdir().unwrap();
        let src_dir = temp_dir.path().join("src");
        fs::create_dir_all(&src_dir).unwrap();
        fs::write(src_dir.join("lib.rs"), "fn main() {}").unwrap();

        let hash1 = compute_content_hash(&src_dir, None);
        let hash2 = compute_content_hash(&src_dir, None);

        // Same content should produce same hash
        assert_eq!(hash1, hash2);
        assert!(!hash1.is_empty());
    }

    #[test]
    fn test_compute_workspace_inputs_hash_changes_when_manifest_changes() {
        let temp_dir = tempfile::tempdir().unwrap();
        fs::write(
            temp_dir.path().join("Cargo.toml"),
            "[workspace]\nmembers = []\n",
        )
        .unwrap();

        let first = compute_workspace_inputs_hash(temp_dir.path());
        fs::write(
            temp_dir.path().join("Cargo.toml"),
            "[workspace]\nmembers = []\nresolver = \"3\"\n",
        )
        .unwrap();
        let second = compute_workspace_inputs_hash(temp_dir.path());

        assert_ne!(first, second);
    }

    #[test]
    fn test_compute_workspace_inputs_hash_changes_when_lockfile_changes() {
        let temp_dir = tempfile::tempdir().unwrap();
        fs::write(
            temp_dir.path().join("Cargo.toml"),
            "[workspace]\nmembers = []\n",
        )
        .unwrap();
        fs::write(temp_dir.path().join("Cargo.lock"), "version = 4\n").unwrap();

        let first = compute_workspace_inputs_hash(temp_dir.path());
        fs::write(temp_dir.path().join("Cargo.lock"), "version = 5\n").unwrap();
        let second = compute_workspace_inputs_hash(temp_dir.path());

        assert_ne!(first, second);
    }

    #[test]
    fn test_compute_content_hash_with_i18n_toml() {
        let temp_dir = tempfile::tempdir().unwrap();
        let src_dir = temp_dir.path().join("src");
        fs::create_dir_all(&src_dir).unwrap();
        fs::write(src_dir.join("lib.rs"), "fn main() {}").unwrap();

        let i18n_path = temp_dir.path().join("i18n.toml");
        fs::write(&i18n_path, I18N_CONFIG).unwrap();

        let hash_with_toml = compute_content_hash(&src_dir, Some(&i18n_path));
        let hash_without_toml = compute_content_hash(&src_dir, None);

        // Hash should differ when i18n.toml is included
        assert_ne!(hash_with_toml, hash_without_toml);
    }

    #[test]
    fn test_compute_crate_inputs_hash_changes_when_crate_manifest_changes() {
        let temp_dir = tempfile::tempdir().unwrap();
        let src_dir = temp_dir.path().join("src");
        fs::create_dir_all(&src_dir).unwrap();
        fs::write(src_dir.join("lib.rs"), "fn main() {}").unwrap();
        fs::write(
            temp_dir.path().join("Cargo.toml"),
            "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n",
        )
        .unwrap();

        let first = compute_crate_inputs_hash(temp_dir.path(), &src_dir, None);
        fs::write(
            temp_dir.path().join("Cargo.toml"),
            "[package]\nname = \"demo\"\nversion = \"0.2.0\"\n",
        )
        .unwrap();
        let second = compute_crate_inputs_hash(temp_dir.path(), &src_dir, None);

        assert_ne!(first, second);
    }

    #[test]
    fn test_compute_crate_inputs_hash_changes_when_build_script_changes() {
        let temp_dir = tempfile::tempdir().unwrap();
        let src_dir = temp_dir.path().join("src");
        fs::create_dir_all(&src_dir).unwrap();
        fs::write(src_dir.join("lib.rs"), "fn main() {}").unwrap();

        let first = compute_crate_inputs_hash(temp_dir.path(), &src_dir, None);
        fs::write(temp_dir.path().join("build.rs"), "fn main() {}\n").unwrap();
        let second = compute_crate_inputs_hash(temp_dir.path(), &src_dir, None);

        assert_ne!(first, second);
    }

    #[test]
    fn test_compute_crate_inputs_hash_ignores_generated_dirs_for_root_source_dir() {
        let temp_dir = tempfile::tempdir().unwrap();
        fs::write(temp_dir.path().join("lib.rs"), "pub struct Demo;\n").unwrap();

        let first = compute_crate_inputs_hash(temp_dir.path(), temp_dir.path(), None);

        fs::create_dir_all(temp_dir.path().join(".es-fluent/src")).unwrap();
        fs::write(
            temp_dir.path().join(".es-fluent/src/main.rs"),
            "fn main() {}\n",
        )
        .unwrap();
        fs::create_dir_all(temp_dir.path().join("target/debug/build/demo/out")).unwrap();
        fs::write(
            temp_dir
                .path()
                .join("target/debug/build/demo/out/generated.rs"),
            "pub fn generated() {}\n",
        )
        .unwrap();

        let second = compute_crate_inputs_hash(temp_dir.path(), temp_dir.path(), None);
        assert_eq!(first, second);

        fs::write(temp_dir.path().join("module.rs"), "pub struct Changed;\n").unwrap();
        let third = compute_crate_inputs_hash(temp_dir.path(), temp_dir.path(), None);
        assert_ne!(second, third);
    }

    #[test]
    fn test_compute_content_hash_changes_when_i18n_toml_changes() {
        let temp_dir = tempfile::tempdir().unwrap();
        let src_dir = temp_dir.path().join("src");
        fs::create_dir_all(&src_dir).unwrap();
        fs::write(src_dir.join("lib.rs"), "fn main() {}").unwrap();

        let i18n_path = temp_dir.path().join("i18n.toml");
        fs::write(&i18n_path, I18N_CONFIG).unwrap();

        let hash1 = compute_content_hash(&src_dir, Some(&i18n_path));

        // Change the i18n.toml content (e.g., changing fluent_feature)
        fs::write(
            &i18n_path,
            "fallback_language = \"en\"\nassets_dir = \"i18n\"\nfluent_feature = [\"i18n\"]",
        )
        .unwrap();

        let hash2 = compute_content_hash(&src_dir, Some(&i18n_path));

        // Hash should change when i18n.toml content changes
        assert_ne!(hash1, hash2);
    }

    #[test]
    fn test_compute_content_hash_unchanged_when_rs_unchanged() {
        let temp_dir = tempfile::tempdir().unwrap();
        let src_dir = temp_dir.path().join("src");
        fs::create_dir_all(&src_dir).unwrap();
        fs::write(src_dir.join("lib.rs"), "fn main() {}").unwrap();

        let i18n_path = temp_dir.path().join("i18n.toml");
        fs::write(&i18n_path, I18N_CONFIG).unwrap();

        let hash1 = compute_content_hash(&src_dir, Some(&i18n_path));

        // Re-write same content (simulates save without changes)
        fs::write(src_dir.join("lib.rs"), "fn main() {}").unwrap();
        fs::write(&i18n_path, I18N_CONFIG).unwrap();

        let hash2 = compute_content_hash(&src_dir, Some(&i18n_path));

        // Hash should remain the same when content is identical
        assert_eq!(hash1, hash2);
    }

    #[test]
    fn test_compute_content_hash_nonexistent_i18n_toml() {
        let temp_dir = tempfile::tempdir().unwrap();
        let src_dir = temp_dir.path().join("src");
        fs::create_dir_all(&src_dir).unwrap();
        fs::write(src_dir.join("lib.rs"), "fn main() {}").unwrap();

        let nonexistent_path = temp_dir.path().join("nonexistent.toml");

        // Should not panic and should produce same hash as None
        let hash_with_nonexistent = compute_content_hash(&src_dir, Some(&nonexistent_path));
        let hash_without = compute_content_hash(&src_dir, None);

        assert_eq!(hash_with_nonexistent, hash_without);
    }

    #[test]
    fn test_compute_content_hash_ignores_path_aliases() {
        let temp_dir = tempfile::tempdir().unwrap();
        let src_dir = temp_dir.path().join("src");
        fs::create_dir_all(&src_dir).unwrap();
        fs::write(src_dir.join("lib.rs"), "fn main() {}").unwrap();

        let i18n_path = temp_dir.path().join("i18n.toml");
        fs::write(&i18n_path, I18N_CONFIG).unwrap();

        let aliased_src_dir = temp_dir.path().join("src").join(".");
        let aliased_i18n_path = temp_dir.path().join(".").join("i18n.toml");

        let direct_hash = compute_content_hash(&src_dir, Some(&i18n_path));
        let aliased_hash = compute_content_hash(&aliased_src_dir, Some(&aliased_i18n_path));

        assert_eq!(direct_hash, aliased_hash);
    }

    #[test]
    fn test_compute_content_hash_only_rs_files() {
        let temp_dir = tempfile::tempdir().unwrap();
        let src_dir = temp_dir.path().join("src");
        fs::create_dir_all(&src_dir).unwrap();
        fs::write(src_dir.join("lib.rs"), "fn main() {}").unwrap();

        let hash1 = compute_content_hash(&src_dir, None);

        // Add a non-.rs file - should not affect hash
        fs::write(src_dir.join("notes.txt"), "some notes").unwrap();

        let hash2 = compute_content_hash(&src_dir, None);

        assert_eq!(hash1, hash2);
    }

    #[test]
    fn metadata_cache_save_load_and_validity_round_trip() {
        let temp_dir = tempfile::tempdir().unwrap();
        fs::write(temp_dir.path().join("Cargo.lock"), "lock-content").unwrap();

        let cache = MetadataCache {
            cargo_lock_hash: MetadataCache::hash_cargo_lock(temp_dir.path()).unwrap(),
            es_fluent_dep: cargo_manifest::Dependency::Detailed(cargo_manifest::DependencyDetail {
                path: Some("../es-fluent".to_string()),
                ..Default::default()
            }),
            es_fluent_cli_helpers_dep: cargo_manifest::Dependency::Detailed(
                cargo_manifest::DependencyDetail {
                    path: Some("../helpers".to_string()),
                    ..Default::default()
                },
            ),
            target_dir: "target".to_string(),
        };
        cache.save(temp_dir.path()).unwrap();

        let loaded = MetadataCache::load(temp_dir.path()).unwrap();
        assert_eq!(loaded.es_fluent_dep, cache.es_fluent_dep);
        assert!(loaded.is_valid(temp_dir.path()));

        fs::write(temp_dir.path().join("Cargo.lock"), "changed-lock-content").unwrap();
        assert!(!loaded.is_valid(temp_dir.path()));
    }

    #[test]
    fn runner_cache_save_and_load_round_trip() {
        let temp_dir = tempfile::tempdir().unwrap();
        let mut hashes = IndexMap::new();
        hashes.insert(
            PackageName::try_new("test-crate").expect("valid package name"),
            "abc123".to_string(),
        );

        let cache = RunnerCache {
            crate_hashes: hashes.clone(),
            runner_mtime: 42,
            cli_version: "0.1.0".to_string(),
            workspace_inputs_hash: "workspace-hash".to_string(),
        };
        cache.save(temp_dir.path()).unwrap();

        let loaded = RunnerCache::load(temp_dir.path()).unwrap();
        assert_eq!(loaded.runner_mtime, 42);
        assert_eq!(loaded.cli_version, "0.1.0");
        assert_eq!(loaded.crate_hashes, hashes);
        assert_eq!(loaded.workspace_inputs_hash, "workspace-hash");
    }
}
