//! LRU cache for decompressed SqPack blocks.
//!
//! Enabled by the `block-cache` feature flag. Re-reading and re-decompressing
//! the same block is expensive; this cache short-circuits that for blocks that
//! are accessed repeatedly (e.g. during sheet iteration).
//!
//! Keys are [`BlockKey`]: a tuple of `(install_id, triple, dat_id, byte_offset)`.
//! The `install_id` component disambiguates installs that are open simultaneously.
//! Values are `Arc<Vec<u8>>` so cache hits avoid copying the payload.
//!
//! The cache is internally `Mutex`-protected and therefore `Send + Sync`; it
//! can be shared freely across threads when the `parallel` feature is active.

use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::num::NonZeroUsize;
use std::path::Path;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::{AtomicU64, Ordering};

use lru::LruCache;
use tracing::{trace, warn};

use crate::route::ArchiveTriple;
use crate::tracing_schema::event_names;

/// Default cache capacity (number of decompressed blocks retained).
pub const BLOCK_CACHE_DEFAULT_CAPACITY: usize = 256;

/// Cache key: uniquely identifies one decompressed block within a dat file.
///
/// The four fields together pinpoint `(install, archive, .datN, offset)`.
/// Use [`BlockKey::new`] to construct and [`install_id_from_path`] to derive
/// a stable `install_id` from an install root path.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[cfg_attr(docsrs, doc(cfg(feature = "block-cache")))]
pub struct BlockKey {
    /// Identifies the install so caches can be shared across multiple opens.
    pub install_id: u64,
    /// Archive triple (category, expansion, chunk).
    pub triple: ArchiveTriple,
    /// `.datN` file index within the archive.
    pub dat_id: u8,
    /// Byte offset of the block within the dat file.
    pub byte_offset: u64,
}

impl BlockKey {
    /// Construct a new key.
    #[must_use]
    pub fn new(install_id: u64, triple: ArchiveTriple, dat_id: u8, byte_offset: u64) -> Self {
        Self {
            install_id,
            triple,
            dat_id,
            byte_offset,
        }
    }
}

/// Derive a stable `install_id` from the install root `path`.
///
/// Canonicalises the path with `fs::canonicalize` if possible, then hashes
/// it with the default hasher. The result is deterministic for the same path
/// within one process but not guaranteed to be stable across processes or OS
/// versions.
#[must_use]
pub fn install_id_from_path(path: &Path) -> u64 {
    let canonical = std::fs::canonicalize(path).ok();
    let target: &Path = canonical.as_deref().unwrap_or(path);
    let mut hasher = DefaultHasher::new();
    target.hash(&mut hasher);
    hasher.finish()
}

/// A snapshot of [`BlockCache`] counters at a point in time.
///
/// Returned by [`BlockCache::stats`]. All fields are `pub` and the struct is
/// `#[non_exhaustive]` so additional counters can be added without a breaking
/// change.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(docsrs, doc(cfg(feature = "block-cache")))]
#[non_exhaustive]
pub struct CacheStats {
    /// Number of successful cache lookups since the cache was created.
    pub hits: u64,
    /// Number of unsuccessful cache lookups since the cache was created.
    pub misses: u64,
    /// Number of blocks currently held in the cache.
    pub fill: usize,
    /// Maximum number of blocks the cache will hold before evicting.
    pub capacity: usize,
}

/// Thread-safe LRU cache for decompressed SqPack blocks.
///
/// Wrap a `BlockCache` in an `Arc` and share it across [`crate::SqPack`]
/// instances (and across threads when the `parallel` feature is active) to
/// avoid redundant decompression of hot blocks.
///
/// The cache evicts the least-recently-used entry once [`BlockCache::capacity`]
/// is reached. Hit/miss counters accumulate for the lifetime of the cache and
/// are exposed through [`BlockCache::stats`].
///
/// `BlockCache` is `Send + Sync`; its internal [`LruCache`] is guarded by a
/// `Mutex` so concurrent access is safe but serialised.
#[derive(Debug)]
#[cfg_attr(docsrs, doc(cfg(feature = "block-cache")))]
#[non_exhaustive]
pub struct BlockCache {
    inner: Mutex<LruCache<BlockKey, Arc<Vec<u8>>>>,
    capacity: NonZeroUsize,
    hits: AtomicU64,
    misses: AtomicU64,
}

impl BlockCache {
    /// Create a new cache with [`BLOCK_CACHE_DEFAULT_CAPACITY`] slots.
    #[must_use]
    pub fn new() -> Self {
        Self::with_capacity(BLOCK_CACHE_DEFAULT_CAPACITY)
    }

    /// Create a new cache with `capacity` slots.
    ///
    /// A `capacity` of `0` is clamped to `1` with a `warn!` event; prefer
    /// not attaching a cache at all for the no-cache case.
    #[must_use]
    pub fn with_capacity(capacity: usize) -> Self {
        if capacity == 0 {
            warn!(
                "BlockCache::with_capacity(0) clamped to 1; a zero-capacity LRU is meaningless, \
                 prefer leaving the cache unattached for the no-cache case"
            );
        }
        let cap = NonZeroUsize::new(capacity.max(1)).expect("capacity clamped to >= 1");
        Self {
            inner: Mutex::new(LruCache::new(cap)),
            capacity: cap,
            hits: AtomicU64::new(0),
            misses: AtomicU64::new(0),
        }
    }

    /// Look up `key` and return a shared reference to its payload if present.
    ///
    /// A cache hit promotes the entry to most-recently-used. The returned
    /// `Arc` lets callers hold onto the payload without preventing eviction
    /// of other entries.
    #[must_use]
    pub fn get(&self, key: BlockKey) -> Option<Arc<Vec<u8>>> {
        let (hit, len) = {
            let mut guard = self.inner.lock().expect("BlockCache mutex poisoned");
            let hit = guard.get(&key).map(Arc::clone);
            let len = guard.len();
            (hit, len)
        };
        if hit.is_some() {
            self.hits.fetch_add(1, Ordering::Relaxed);
            debug_assert_eq!(event_names::BLOCK_CACHE_HIT, "block_cache.hit");
            trace!(
                install_id = key.install_id,
                triple = ?key.triple,
                dat_id = key.dat_id,
                byte_offset = key.byte_offset,
                len,
                "block_cache.hit",
            );
        } else {
            self.misses.fetch_add(1, Ordering::Relaxed);
            debug_assert_eq!(event_names::BLOCK_CACHE_MISS, "block_cache.miss");
            trace!(
                install_id = key.install_id,
                triple = ?key.triple,
                dat_id = key.dat_id,
                byte_offset = key.byte_offset,
                len,
                "block_cache.miss",
            );
        }
        hit
    }

    pub(crate) fn put(&self, key: BlockKey, value: Arc<Vec<u8>>) -> Option<Arc<Vec<u8>>> {
        let mut guard = self.inner.lock().expect("BlockCache mutex poisoned");
        guard.put(key, value)
    }

    /// Evict every block from the cache and return the number of entries dropped.
    pub fn clear(&self) -> usize {
        let mut guard = self.inner.lock().expect("BlockCache mutex poisoned");
        let dropped = guard.len();
        guard.clear();
        dropped
    }

    /// Return the number of blocks currently in the cache.
    #[must_use]
    pub fn len(&self) -> usize {
        let guard = self.inner.lock().expect("BlockCache mutex poisoned");
        guard.len()
    }

    /// Return `true` if the cache holds no blocks.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        let guard = self.inner.lock().expect("BlockCache mutex poisoned");
        guard.len() == 0
    }

    /// Return the maximum number of blocks the cache will retain.
    #[must_use]
    pub fn capacity(&self) -> usize {
        self.capacity.get()
    }

    /// Evict all entries whose key matches `(install_id, triple, dat_id)`.
    ///
    /// Returns the number of entries removed. Use this after closing or
    /// replacing a single dat file to release stale entries without clearing
    /// the entire cache.
    pub fn evict_dat(&self, install_id: u64, triple: ArchiveTriple, dat_id: u8) -> usize {
        let mut guard = self.inner.lock().expect("BlockCache mutex poisoned");
        let victims: Vec<BlockKey> = guard
            .iter()
            .filter_map(|(k, _)| {
                if k.install_id == install_id && k.triple == triple && k.dat_id == dat_id {
                    Some(*k)
                } else {
                    None
                }
            })
            .collect();
        let count = victims.len();
        for k in victims {
            guard.pop(&k);
        }
        count
    }

    /// Return a snapshot of current hit/miss counters and fill level.
    ///
    /// Counters are read with [`Ordering::Relaxed`] and may not reflect the
    /// very latest concurrent operations, but are consistent enough for
    /// monitoring and diagnostics.
    #[must_use]
    pub fn stats(&self) -> CacheStats {
        let fill = {
            let guard = self.inner.lock().expect("BlockCache mutex poisoned");
            guard.len()
        };
        CacheStats {
            hits: self.hits.load(Ordering::Relaxed),
            misses: self.misses.load(Ordering::Relaxed),
            fill,
            capacity: self.capacity.get(),
        }
    }
}

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

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

    fn assert_send_sync<T: Send + Sync>() {}

    const TEST_INSTALL_ID: u64 = 0xdead_beef_cafe_babe;

    fn fixture_key(offset: u64) -> BlockKey {
        BlockKey::new(
            TEST_INSTALL_ID,
            ArchiveTriple {
                category: Category::from_known_id(0x0a),
                expansion: 0,
                chunk: 0,
            },
            0,
            offset,
        )
    }

    #[test]
    fn block_cache_is_send_sync() {
        assert_send_sync::<BlockCache>();
    }

    #[test]
    fn new_starts_empty() {
        let c = BlockCache::new();
        assert!(c.is_empty());
        assert_eq!(c.len(), 0);
        assert_eq!(c.capacity(), BLOCK_CACHE_DEFAULT_CAPACITY);
    }

    #[test]
    fn put_then_get_round_trips() {
        let c = BlockCache::new();
        let key = fixture_key(0x1000);
        assert!(c.get(key).is_none());
        c.put(key, Arc::new(vec![1, 2, 3]));
        let got = c.get(key).expect("hit");
        assert_eq!(got.as_slice(), &[1, 2, 3]);
        assert_eq!(c.len(), 1);
    }

    #[test]
    fn put_returns_previous_value_on_overwrite() {
        let c = BlockCache::new();
        let key = fixture_key(0x1000);
        assert!(c.put(key, Arc::new(vec![1])).is_none());
        let prev = c.put(key, Arc::new(vec![2])).expect("previous value");
        assert_eq!(prev.as_slice(), &[1]);
    }

    #[test]
    fn lru_evicts_oldest_at_capacity() {
        let c = BlockCache::with_capacity(2);
        let a = fixture_key(0x1000);
        let b = fixture_key(0x2000);
        let d = fixture_key(0x3000);
        c.put(a, Arc::new(vec![1]));
        c.put(b, Arc::new(vec![2]));
        c.put(d, Arc::new(vec![3]));
        assert!(c.get(a).is_none(), "least-recently-used must be evicted");
        assert!(c.get(b).is_some());
        assert!(c.get(d).is_some());
        assert_eq!(c.len(), 2);
    }

    #[test]
    fn get_bumps_entry_to_mru() {
        let c = BlockCache::with_capacity(2);
        let a = fixture_key(0x1000);
        let b = fixture_key(0x2000);
        let d = fixture_key(0x3000);
        c.put(a, Arc::new(vec![1]));
        c.put(b, Arc::new(vec![2]));
        let _ = c.get(a).expect("hit");
        c.put(d, Arc::new(vec![3]));
        assert!(c.get(a).is_some(), "touched entry must survive eviction");
        assert!(c.get(b).is_none(), "non-touched entry must be evicted");
    }

    #[test]
    fn clear_drops_every_entry_and_returns_count() {
        let c = BlockCache::new();
        c.put(fixture_key(0x1000), Arc::new(vec![1]));
        c.put(fixture_key(0x2000), Arc::new(vec![2]));
        assert_eq!(c.len(), 2);
        let dropped = c.clear();
        assert_eq!(dropped, 2);
        assert!(c.is_empty());
        assert_eq!(c.clear(), 0);
    }

    #[test]
    fn evict_dat_removes_only_matching_entries() {
        let c = BlockCache::new();
        let triple_a = ArchiveTriple {
            category: Category::from_known_id(0x0a),
            expansion: 0,
            chunk: 0,
        };
        let triple_b = ArchiveTriple {
            category: Category::from_known_id(0x0a),
            expansion: 1,
            chunk: 0,
        };
        c.put(
            BlockKey::new(TEST_INSTALL_ID, triple_a, 0, 0x1000),
            Arc::new(vec![1]),
        );
        c.put(
            BlockKey::new(TEST_INSTALL_ID, triple_a, 0, 0x2000),
            Arc::new(vec![2]),
        );
        c.put(
            BlockKey::new(TEST_INSTALL_ID, triple_a, 1, 0x1000),
            Arc::new(vec![3]),
        );
        c.put(
            BlockKey::new(TEST_INSTALL_ID, triple_b, 0, 0x1000),
            Arc::new(vec![4]),
        );
        assert_eq!(c.len(), 4);

        let dropped = c.evict_dat(TEST_INSTALL_ID, triple_a, 0);
        assert_eq!(dropped, 2, "exactly the two (triple_a, dat 0) blocks");
        assert_eq!(c.len(), 2);
        assert!(
            c.get(BlockKey::new(TEST_INSTALL_ID, triple_a, 1, 0x1000))
                .is_some()
        );
        assert!(
            c.get(BlockKey::new(TEST_INSTALL_ID, triple_b, 0, 0x1000))
                .is_some()
        );
    }

    #[test]
    fn evict_dat_returns_zero_when_nothing_matches() {
        let c = BlockCache::new();
        c.put(fixture_key(0x1000), Arc::new(vec![1]));
        let triple = ArchiveTriple {
            category: Category::from_known_id(0x0a),
            expansion: 0,
            chunk: 0,
        };
        assert_eq!(c.evict_dat(TEST_INSTALL_ID.wrapping_add(1), triple, 0), 0);
        assert_eq!(c.len(), 1);
    }

    #[test]
    fn with_capacity_zero_clamps_to_one() {
        let c = BlockCache::with_capacity(0);
        assert_eq!(c.capacity(), 1);
        c.put(fixture_key(0x1000), Arc::new(vec![1]));
        assert_eq!(c.len(), 1);
    }

    #[test]
    fn different_dat_ids_are_distinct_keys() {
        let triple = ArchiveTriple {
            category: Category::from_known_id(0x0a),
            expansion: 0,
            chunk: 0,
        };
        let k0 = BlockKey::new(TEST_INSTALL_ID, triple, 0, 0x1000);
        let k1 = BlockKey::new(TEST_INSTALL_ID, triple, 1, 0x1000);
        assert_ne!(k0, k1);
        let c = BlockCache::new();
        c.put(k0, Arc::new(vec![0xAA]));
        c.put(k1, Arc::new(vec![0xBB]));
        assert_eq!(c.get(k0).expect("hit k0").as_slice(), &[0xAA]);
        assert_eq!(c.get(k1).expect("hit k1").as_slice(), &[0xBB]);
    }

    #[test]
    fn different_triples_are_distinct_keys() {
        let t0 = ArchiveTriple {
            category: Category::from_known_id(0x0a),
            expansion: 0,
            chunk: 0,
        };
        let t1 = ArchiveTriple {
            category: Category::from_known_id(0x0a),
            expansion: 1,
            chunk: 0,
        };
        let k0 = BlockKey::new(TEST_INSTALL_ID, t0, 0, 0x1000);
        let k1 = BlockKey::new(TEST_INSTALL_ID, t1, 0, 0x1000);
        assert_ne!(k0, k1);
    }

    #[test]
    fn different_install_ids_are_distinct_keys() {
        let triple = ArchiveTriple {
            category: Category::from_known_id(0x0a),
            expansion: 0,
            chunk: 0,
        };
        let k0 = BlockKey::new(TEST_INSTALL_ID, triple, 0, 0x1000);
        let k1 = BlockKey::new(TEST_INSTALL_ID ^ 0x1, triple, 0, 0x1000);
        assert_ne!(k0, k1);
        let c = BlockCache::new();
        c.put(k0, Arc::new(vec![0xAA]));
        c.put(k1, Arc::new(vec![0xBB]));
        assert_eq!(c.get(k0).expect("hit").as_slice(), &[0xAA]);
        assert_eq!(c.get(k1).expect("hit").as_slice(), &[0xBB]);
    }

    #[test]
    fn shared_arc_clones_dont_inflate_cache_len() {
        let c = BlockCache::new();
        let key = fixture_key(0x1000);
        let payload = Arc::new(vec![1, 2, 3, 4]);
        c.put(key, Arc::clone(&payload));
        let got1 = c.get(key).expect("hit");
        let got2 = c.get(key).expect("hit");
        assert!(Arc::ptr_eq(&payload, &got1));
        assert!(Arc::ptr_eq(&got1, &got2));
        assert_eq!(c.len(), 1);
    }

    #[test]
    fn default_constructor_matches_new() {
        let a = BlockCache::default();
        let b = BlockCache::new();
        assert_eq!(a.capacity(), b.capacity());
        assert_eq!(a.is_empty(), b.is_empty());
    }

    #[test]
    fn stats_track_hits_and_misses() {
        let c = BlockCache::with_capacity(4);
        let key = fixture_key(0x1000);

        let s0 = c.stats();
        assert_eq!(s0.hits, 0);
        assert_eq!(s0.misses, 0);
        assert_eq!(s0.fill, 0);
        assert_eq!(s0.capacity, 4);

        assert!(c.get(key).is_none());
        let s1 = c.stats();
        assert_eq!(s1.misses, 1);
        assert_eq!(s1.hits, 0);

        c.put(key, Arc::new(vec![1]));
        assert!(c.get(key).is_some());
        let s2 = c.stats();
        assert_eq!(s2.hits, 1);
        assert_eq!(s2.misses, 1);
        assert_eq!(s2.fill, 1);

        c.clear();
        let s3 = c.stats();
        assert_eq!(s3.hits, 1);
        assert_eq!(s3.misses, 1);
        assert_eq!(s3.fill, 0);
    }

    #[test]
    fn install_id_from_path_is_deterministic() {
        let tmp = std::env::temp_dir();
        let a = install_id_from_path(&tmp);
        let b = install_id_from_path(&tmp);
        assert_eq!(a, b, "install_id must be deterministic for the same path");
    }

    #[test]
    fn install_id_from_path_handles_nonexistent_path() {
        let bogus = std::path::PathBuf::from("/this/path/does/not/exist/sqpack-rs-test");
        let a = install_id_from_path(&bogus);
        let b = install_id_from_path(&bogus);
        assert_eq!(a, b);
    }

    #[test]
    fn event_names_match_schema() {
        assert_eq!(event_names::BLOCK_CACHE_HIT, "block_cache.hit");
        assert_eq!(event_names::BLOCK_CACHE_MISS, "block_cache.miss");
    }
}
