#![warn(missing_docs)]
#![cfg_attr(not(test), warn(clippy::unwrap_used))]

//! File-oriented cache keys and persistent package caches for Shuck.
//!
//! The types in this crate power the `shuck` CLI cache, but are generic enough to reuse in other
//! Rust tooling that wants SHA-256-based cache partitioning and serialized per-file entries.
use std::collections::{BTreeMap, BTreeSet};
use std::fs::{self, File};
use std::io::{self, BufReader, Write};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};

use serde::Serialize;
use serde::de::DeserializeOwned;
use sha2::{Digest, Sha256};
use tempfile::NamedTempFile;

/// Legacy per-project cache directory name used by older shuck releases.
pub const CACHE_DIR_NAME: &str = ".shuck_cache";

const MAX_LAST_SEEN_AGE: Duration = Duration::from_secs(30 * 24 * 60 * 60);

/// Returns the legacy cache directory that lives under a project root.
pub fn legacy_cache_dir(project_root: &Path) -> PathBuf {
    project_root.join(CACHE_DIR_NAME)
}

/// Reads the cached project root marker stored in a legacy cache file.
pub fn read_project_root_from_cache_file(path: &Path) -> io::Result<Option<PathBuf>> {
    let file = match File::open(path) {
        Ok(file) => file,
        Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
        Err(err) => return Err(err),
    };

    let mut reader = BufReader::new(file);
    match bincode::serde::decode_from_std_read(&mut reader, bincode::config::standard()) {
        Ok(project_root) => Ok(Some(project_root)),
        Err(_) => Ok(None),
    }
}

/// Trait for values that can contribute to a deterministic package cache key.
pub trait CacheKey {
    /// Write this value into the structured cache-key hasher.
    fn cache_key(&self, state: &mut CacheKeyHasher);
}

/// Incremental hasher used to build structured cache keys.
pub struct CacheKeyHasher {
    hasher: Sha256,
}

impl CacheKeyHasher {
    /// Create an empty cache-key hasher.
    #[must_use]
    pub fn new() -> Self {
        Self {
            hasher: Sha256::new(),
        }
    }

    /// Write a caller-defined domain tag into the key.
    pub fn write_tag(&mut self, tag: &[u8]) {
        self.write_bytes(tag);
    }

    /// Write a boolean value into the key.
    pub fn write_bool(&mut self, value: bool) {
        self.hasher.update([u8::from(value)]);
    }

    /// Write an unsigned 8-bit integer into the key.
    pub fn write_u8(&mut self, value: u8) {
        self.hasher.update([value]);
    }

    /// Write an unsigned 32-bit integer into the key.
    pub fn write_u32(&mut self, value: u32) {
        self.hasher.update(value.to_le_bytes());
    }

    /// Write an unsigned 64-bit integer into the key.
    pub fn write_u64(&mut self, value: u64) {
        self.hasher.update(value.to_le_bytes());
    }

    /// Write an unsigned 128-bit integer into the key.
    pub fn write_u128(&mut self, value: u128) {
        self.hasher.update(value.to_le_bytes());
    }

    /// Write a `usize` value into the key using a platform-independent encoding.
    pub fn write_usize(&mut self, value: usize) {
        self.write_u64(value as u64);
    }

    /// Write a UTF-8 string into the key.
    pub fn write_str(&mut self, value: &str) {
        self.write_bytes(value.as_bytes());
    }

    /// Write a byte slice into the key with a length prefix.
    pub fn write_bytes(&mut self, bytes: &[u8]) {
        self.write_u64(bytes.len() as u64);
        self.hasher.update(bytes);
    }

    /// Finish the hasher and return a lowercase hex digest.
    #[must_use]
    pub fn finish_hex(self) -> String {
        let digest = self.hasher.finalize();
        let mut out = String::with_capacity(digest.len() * 2);
        for byte in digest {
            use std::fmt::Write as _;
            let _ = write!(&mut out, "{byte:02x}");
        }
        out
    }
}

impl Default for CacheKeyHasher {
    fn default() -> Self {
        Self::new()
    }
}

/// Returns the hex-encoded cache key for a value.
#[must_use]
pub fn cache_key_hex<T: CacheKey>(value: &T) -> String {
    let mut hasher = CacheKeyHasher::new();
    value.cache_key(&mut hasher);
    hasher.finish_hex()
}

impl CacheKey for bool {
    fn cache_key(&self, state: &mut CacheKeyHasher) {
        state.write_bool(*self);
    }
}

impl CacheKey for u8 {
    fn cache_key(&self, state: &mut CacheKeyHasher) {
        state.write_u8(*self);
    }
}

impl CacheKey for u32 {
    fn cache_key(&self, state: &mut CacheKeyHasher) {
        state.write_u32(*self);
    }
}

impl CacheKey for u64 {
    fn cache_key(&self, state: &mut CacheKeyHasher) {
        state.write_u64(*self);
    }
}

impl CacheKey for u128 {
    fn cache_key(&self, state: &mut CacheKeyHasher) {
        state.write_u128(*self);
    }
}

impl CacheKey for usize {
    fn cache_key(&self, state: &mut CacheKeyHasher) {
        state.write_usize(*self);
    }
}

impl CacheKey for str {
    fn cache_key(&self, state: &mut CacheKeyHasher) {
        state.write_str(self);
    }
}

impl CacheKey for String {
    fn cache_key(&self, state: &mut CacheKeyHasher) {
        self.as_str().cache_key(state);
    }
}

impl<T: CacheKey + ?Sized> CacheKey for &T {
    fn cache_key(&self, state: &mut CacheKeyHasher) {
        (**self).cache_key(state);
    }
}

impl<T: CacheKey> CacheKey for Option<T> {
    fn cache_key(&self, state: &mut CacheKeyHasher) {
        match self {
            Some(value) => {
                state.write_u8(1);
                value.cache_key(state);
            }
            None => state.write_u8(0),
        }
    }
}

impl<T: CacheKey> CacheKey for [T] {
    fn cache_key(&self, state: &mut CacheKeyHasher) {
        state.write_usize(self.len());
        for value in self {
            value.cache_key(state);
        }
    }
}

impl<T: CacheKey> CacheKey for Vec<T> {
    fn cache_key(&self, state: &mut CacheKeyHasher) {
        self.as_slice().cache_key(state);
    }
}

impl CacheKey for Path {
    fn cache_key(&self, state: &mut CacheKeyHasher) {
        state.write_str(&self.to_string_lossy());
    }
}

impl CacheKey for PathBuf {
    fn cache_key(&self, state: &mut CacheKeyHasher) {
        self.as_path().cache_key(state);
    }
}

impl<K, V> CacheKey for BTreeMap<K, V>
where
    K: CacheKey + Ord,
    V: CacheKey,
{
    fn cache_key(&self, state: &mut CacheKeyHasher) {
        state.write_usize(self.len());
        for (key, value) in self {
            key.cache_key(state);
            value.cache_key(state);
        }
    }
}

/// File metadata used to validate cached entries against a filesystem path.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct FileCacheKey {
    /// File modification time as nanoseconds since the Unix epoch.
    pub file_last_modified_ns: u128,
    /// File creation time as nanoseconds since the Unix epoch, when available.
    pub file_created_ns: Option<u128>,
    /// File status-change time as nanoseconds since the Unix epoch, when available.
    pub file_status_changed_ns: Option<u128>,
    /// Platform device identifier, when available.
    pub file_device_id: Option<u64>,
    /// Platform file identifier such as an inode, when available.
    pub file_id: Option<u64>,
    /// Platform permission bits or readonly flag used to invalidate stale entries.
    pub file_permissions_mode: u32,
    /// File size in bytes.
    pub file_size_bytes: u64,
}

impl FileCacheKey {
    /// Read file metadata from `path` and convert it into a cache validation key.
    pub fn from_path(path: &Path) -> io::Result<Self> {
        let metadata = path.metadata()?;
        let file_last_modified_ns = system_time_ns(metadata.modified()?)?;
        let file_created_ns = metadata
            .created()
            .ok()
            .and_then(|created| system_time_ns(created).ok());
        let (file_status_changed_ns, file_device_id, file_id) =
            platform_metadata_identity(&metadata);

        #[cfg(unix)]
        let file_permissions_mode = {
            use std::os::unix::fs::PermissionsExt;
            metadata.permissions().mode()
        };

        #[cfg(windows)]
        let file_permissions_mode: u32 = u32::from(metadata.permissions().readonly());

        Ok(Self {
            file_last_modified_ns,
            file_created_ns,
            file_status_changed_ns,
            file_device_id,
            file_id,
            file_permissions_mode,
            file_size_bytes: metadata.len(),
        })
    }
}

fn system_time_ns(time: SystemTime) -> io::Result<u128> {
    Ok(time
        .duration_since(UNIX_EPOCH)
        .map_err(io::Error::other)?
        .as_nanos())
}

#[cfg(unix)]
fn platform_metadata_identity(metadata: &fs::Metadata) -> (Option<u128>, Option<u64>, Option<u64>) {
    use std::os::unix::fs::MetadataExt;

    (
        unix_timestamp_ns(metadata.ctime(), metadata.ctime_nsec()),
        Some(metadata.dev()),
        Some(metadata.ino()),
    )
}

#[cfg(not(unix))]
fn platform_metadata_identity(_: &fs::Metadata) -> (Option<u128>, Option<u64>, Option<u64>) {
    (None, None, None)
}

#[cfg(unix)]
fn unix_timestamp_ns(seconds: i64, nanoseconds: i64) -> Option<u128> {
    if seconds < 0 || nanoseconds < 0 {
        return None;
    }

    Some((seconds as u128) * 1_000_000_000 + nanoseconds as u128)
}

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct CachedFile<T> {
    key: FileCacheKey,
    last_seen_ms: u64,
    data: T,
}

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct StoredPackage<T> {
    project_root: PathBuf,
    files: BTreeMap<PathBuf, CachedFile<T>>,
}

#[derive(Debug, Clone)]
struct Change<T> {
    key: FileCacheKey,
    data: T,
}

/// On-disk cache for file-scoped analysis results within a package.
#[derive(Debug, Clone)]
pub struct PackageCache<T> {
    path: PathBuf,
    package: StoredPackage<T>,
    seen_paths: BTreeSet<PathBuf>,
    changes: BTreeMap<PathBuf, Change<T>>,
    last_seen_ms: u64,
}

impl<T> PackageCache<T>
where
    T: Clone + Serialize + DeserializeOwned,
{
    /// Open a package cache file for a canonical project root and tool version.
    ///
    /// Corrupt, missing, or root-mismatched cache files are treated as empty caches.
    pub fn open(
        cache_root: &Path,
        canonical_root: PathBuf,
        tool_version: &str,
        package_key: &impl CacheKey,
    ) -> io::Result<Self> {
        let key = cache_key_hex(package_key);
        let path = cache_root.join(tool_version).join(format!("{key}.bin"));

        let file = match File::open(&path) {
            Ok(file) => file,
            Err(err) if err.kind() == io::ErrorKind::NotFound => {
                return Ok(Self::empty(path, canonical_root));
            }
            Err(err) => return Err(err),
        };

        let mut reader = BufReader::new(file);
        let package: StoredPackage<T> =
            match bincode::serde::decode_from_std_read(&mut reader, bincode::config::standard()) {
                Ok(package) => package,
                Err(_) => return Ok(Self::empty(path, canonical_root)),
            };

        if package.project_root != canonical_root {
            return Ok(Self::empty(path, canonical_root));
        }

        Ok(Self {
            path,
            package,
            seen_paths: BTreeSet::new(),
            changes: BTreeMap::new(),
            last_seen_ms: current_time_ms(),
        })
    }

    /// Return the on-disk path backing this package cache.
    #[must_use]
    pub fn path(&self) -> &Path {
        &self.path
    }

    /// Return a cached entry when `relative_path` still matches `key`.
    pub fn get(&mut self, relative_path: &Path, key: &FileCacheKey) -> Option<T> {
        let file = self.package.files.get(relative_path)?;
        if &file.key != key {
            return None;
        }

        self.seen_paths.insert(relative_path.to_path_buf());
        Some(file.data.clone())
    }

    /// Insert or replace a cached entry for a package-relative path.
    pub fn insert(&mut self, relative_path: PathBuf, key: FileCacheKey, data: T) {
        self.seen_paths.insert(relative_path.clone());
        self.changes.insert(relative_path, Change { key, data });
    }

    /// Persist touched and changed entries to disk.
    ///
    /// Untouched entries older than the retention window are pruned before writing.
    pub fn persist(mut self) -> io::Result<()> {
        if !self.save() {
            return Ok(());
        }

        let parent = self
            .path
            .parent()
            .ok_or_else(|| io::Error::other("cache path has no parent directory"))?;
        fs::create_dir_all(parent)?;

        let mut temp_file = NamedTempFile::new_in(parent)?;
        let encoded = bincode::serde::encode_to_vec(&self.package, bincode::config::standard())
            .map_err(io::Error::other)?;
        temp_file.write_all(&encoded)?;

        match temp_file.persist(&self.path) {
            Ok(_) => Ok(()),
            Err(err) => Err(err.error),
        }
    }

    fn empty(path: PathBuf, canonical_root: PathBuf) -> Self {
        Self {
            path,
            package: StoredPackage {
                project_root: canonical_root,
                files: BTreeMap::new(),
            },
            seen_paths: BTreeSet::new(),
            changes: BTreeMap::new(),
            last_seen_ms: current_time_ms(),
        }
    }

    fn save(&mut self) -> bool {
        if self.seen_paths.is_empty() && self.changes.is_empty() {
            return false;
        }

        let max_age_ms = MAX_LAST_SEEN_AGE.as_millis() as u64;
        let now = self.last_seen_ms;

        self.package
            .files
            .retain(|_, file| now.saturating_sub(file.last_seen_ms) <= max_age_ms);

        for path in &self.seen_paths {
            if let Some(change) = self.changes.remove(path) {
                self.package.files.insert(
                    path.clone(),
                    CachedFile {
                        key: change.key,
                        last_seen_ms: now,
                        data: change.data,
                    },
                );
            } else if let Some(existing) = self.package.files.get_mut(path) {
                existing.last_seen_ms = now;
            }
        }

        for (path, change) in std::mem::take(&mut self.changes) {
            self.package.files.insert(
                path,
                CachedFile {
                    key: change.key,
                    last_seen_ms: now,
                    data: change.data,
                },
            );
        }

        true
    }
}

fn current_time_ms() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or(Duration::ZERO)
        .as_millis() as u64
}

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

    #[derive(Debug, Clone)]
    struct TestSettings {
        strict: bool,
        label: String,
    }

    impl CacheKey for TestSettings {
        fn cache_key(&self, state: &mut CacheKeyHasher) {
            state.write_tag(b"test-settings");
            self.strict.cache_key(state);
            self.label.cache_key(state);
        }
    }

    fn test_file_key(file_last_modified_ns: u128, file_size_bytes: u64) -> FileCacheKey {
        FileCacheKey {
            file_last_modified_ns,
            file_created_ns: Some(100),
            file_status_changed_ns: Some(200),
            file_device_id: Some(300),
            file_id: Some(400),
            file_permissions_mode: 0o644,
            file_size_bytes,
        }
    }

    #[test]
    fn cache_key_hashing_is_deterministic() {
        let settings = TestSettings {
            strict: true,
            label: "alpha".to_string(),
        };

        let first = cache_key_hex(&settings);
        let second = cache_key_hex(&settings);

        assert_eq!(first, second);
    }

    #[test]
    fn cache_key_changes_when_settings_change() {
        let first = TestSettings {
            strict: true,
            label: "alpha".to_string(),
        };
        let second = TestSettings {
            strict: false,
            label: "alpha".to_string(),
        };

        assert_ne!(cache_key_hex(&first), cache_key_hex(&second));
    }

    #[test]
    fn package_cache_persists_and_reloads() {
        let tempdir = tempfile::tempdir().unwrap();
        let cache_root = tempdir.path().join("cache");
        let storage_root = tempdir.path().join("project");
        fs::create_dir_all(&storage_root).unwrap();
        let canonical_root = fs::canonicalize(&storage_root).unwrap();

        let settings = TestSettings {
            strict: true,
            label: "alpha".to_string(),
        };

        let mut cache =
            PackageCache::<String>::open(&cache_root, canonical_root.clone(), "0.1.0", &settings)
                .unwrap();
        cache.insert(
            PathBuf::from("script.sh"),
            test_file_key(1, 2),
            "ok".to_string(),
        );
        let cache_path = cache.path().to_path_buf();
        cache.persist().unwrap();

        assert!(cache_path.is_file());

        let mut reopened =
            PackageCache::<String>::open(&cache_root, canonical_root, "0.1.0", &settings).unwrap();
        let value = reopened.get(Path::new("script.sh"), &test_file_key(1, 2));

        assert_eq!(value.as_deref(), Some("ok"));
    }

    #[test]
    fn persist_prunes_stale_entries() {
        let tempdir = tempfile::tempdir().unwrap();
        let cache_root = tempdir.path().join("cache");
        let storage_root = tempdir.path().join("project");
        fs::create_dir_all(&storage_root).unwrap();
        let canonical_root = fs::canonicalize(&storage_root).unwrap();
        let settings = TestSettings {
            strict: true,
            label: "alpha".to_string(),
        };

        let mut cache =
            PackageCache::<String>::open(&cache_root, canonical_root.clone(), "0.1.0", &settings)
                .unwrap();
        cache.insert(
            PathBuf::from("stale.sh"),
            test_file_key(1, 5),
            "stale".to_string(),
        );
        let cache_path = cache.path().to_path_buf();
        cache.persist().unwrap();

        let mut stored: StoredPackage<String> = {
            let mut reader = BufReader::new(File::open(&cache_path).unwrap());
            bincode::serde::decode_from_std_read(&mut reader, bincode::config::standard()).unwrap()
        };
        stored
            .files
            .get_mut(Path::new("stale.sh"))
            .unwrap()
            .last_seen_ms = 0;
        let encoded = bincode::serde::encode_to_vec(&stored, bincode::config::standard()).unwrap();
        fs::write(&cache_path, encoded).unwrap();

        let mut reopened =
            PackageCache::<String>::open(&cache_root, canonical_root, "0.1.0", &settings).unwrap();
        reopened.insert(
            PathBuf::from("fresh.sh"),
            test_file_key(2, 5),
            "fresh".to_string(),
        );
        reopened.persist().unwrap();

        let mut reader = BufReader::new(File::open(&cache_path).unwrap());
        let stored: StoredPackage<String> =
            bincode::serde::decode_from_std_read(&mut reader, bincode::config::standard()).unwrap();

        assert!(!stored.files.contains_key(Path::new("stale.sh")));
        assert!(stored.files.contains_key(Path::new("fresh.sh")));
    }

    #[test]
    fn cache_key_miss_when_only_file_size_changes() {
        let tempdir = tempfile::tempdir().unwrap();
        let cache_root = tempdir.path().join("cache");
        let storage_root = tempdir.path().join("project");
        fs::create_dir_all(&storage_root).unwrap();
        let canonical_root = fs::canonicalize(&storage_root).unwrap();
        let settings = TestSettings {
            strict: true,
            label: "alpha".to_string(),
        };

        let mut cache =
            PackageCache::<String>::open(&cache_root, canonical_root.clone(), "0.1.0", &settings)
                .unwrap();
        cache.insert(
            PathBuf::from("script.sh"),
            test_file_key(1, 2),
            "ok".to_string(),
        );
        cache.persist().unwrap();

        let mut reopened =
            PackageCache::<String>::open(&cache_root, canonical_root, "0.1.0", &settings).unwrap();
        let value = reopened.get(Path::new("script.sh"), &test_file_key(1, 3));

        assert!(value.is_none());
    }

    #[test]
    fn cache_key_miss_when_only_submillisecond_mtime_changes() {
        let tempdir = tempfile::tempdir().unwrap();
        let cache_root = tempdir.path().join("cache");
        let storage_root = tempdir.path().join("project");
        fs::create_dir_all(&storage_root).unwrap();
        let canonical_root = fs::canonicalize(&storage_root).unwrap();
        let settings = TestSettings {
            strict: true,
            label: "alpha".to_string(),
        };

        let mut cache =
            PackageCache::<String>::open(&cache_root, canonical_root.clone(), "0.1.0", &settings)
                .unwrap();
        cache.insert(
            PathBuf::from("script.sh"),
            test_file_key(1_000_000, 2),
            "ok".to_string(),
        );
        cache.persist().unwrap();

        let mut reopened =
            PackageCache::<String>::open(&cache_root, canonical_root, "0.1.0", &settings).unwrap();
        let value = reopened.get(Path::new("script.sh"), &test_file_key(1_000_001, 2));

        assert!(value.is_none());
    }

    #[test]
    fn reads_project_root_from_cache_file_without_knowing_payload_type() {
        let tempdir = tempfile::tempdir().unwrap();
        let cache_root = tempdir.path().join("cache");
        let storage_root = tempdir.path().join("project");
        fs::create_dir_all(&storage_root).unwrap();
        let canonical_root = fs::canonicalize(&storage_root).unwrap();
        let settings = TestSettings {
            strict: true,
            label: "alpha".to_string(),
        };

        let mut cache =
            PackageCache::<String>::open(&cache_root, canonical_root.clone(), "0.1.0", &settings)
                .unwrap();
        cache.insert(
            PathBuf::from("script.sh"),
            test_file_key(1, 2),
            "ok".to_string(),
        );
        let cache_path = cache.path().to_path_buf();
        cache.persist().unwrap();

        let project_root = read_project_root_from_cache_file(&cache_path).unwrap();
        assert_eq!(project_root, Some(canonical_root));
    }
}
