#![cfg(feature = "block-cache")]

use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;

use sqpack_rs::{BlockCache, SqPack, hash::hash_index1_key};

const SQPACK_HEADER_SIZE: u32 = 1024;
const INDEX_ENTRY_SIZE: u32 = 16;
const BLOCK_HEADER_SIZE: u32 = 16;
const BLOCK_TYPE_UNCOMPRESSED: u32 = 32_000;
const FIXTURE_ENTRIES: usize = 24;
const ARCHIVE_STEM: &str = "0a0000.win32";

const SQPACK_MAGIC: [u8; 8] = [b'S', b'q', b'P', b'a', b'c', b'k', 0, 0];

fn make_sqpack_header(file_type: u32) -> Vec<u8> {
    let mut buf = Vec::with_capacity(SQPACK_HEADER_SIZE as usize);
    buf.extend_from_slice(&SQPACK_MAGIC);
    buf.push(0);
    buf.extend_from_slice(&[0, 0, 0]);
    buf.extend_from_slice(&SQPACK_HEADER_SIZE.to_le_bytes());
    buf.extend_from_slice(&1u32.to_le_bytes());
    buf.extend_from_slice(&file_type.to_le_bytes());
    buf.resize(SQPACK_HEADER_SIZE as usize, 0);
    buf
}

fn make_index_header(
    index_data_offset: u32,
    index_data_size: u32,
    number_of_data_file: u32,
) -> Vec<u8> {
    let mut buf = Vec::with_capacity(1024);
    buf.extend_from_slice(&1024u32.to_le_bytes());
    buf.extend_from_slice(&1u32.to_le_bytes());
    buf.extend_from_slice(&index_data_offset.to_le_bytes());
    buf.extend_from_slice(&index_data_size.to_le_bytes());
    buf.resize(buf.len() + 64, 0);
    buf.extend_from_slice(&number_of_data_file.to_le_bytes());
    buf.extend_from_slice(&0u32.to_le_bytes());
    buf.extend_from_slice(&0u32.to_le_bytes());
    buf.resize(buf.len() + 64, 0);
    buf.extend_from_slice(&0u32.to_le_bytes());
    buf.extend_from_slice(&0u32.to_le_bytes());
    buf.resize(buf.len() + 64, 0);
    buf.extend_from_slice(&0u32.to_le_bytes());
    buf.extend_from_slice(&0u32.to_le_bytes());
    buf.resize(buf.len() + 64, 0);
    buf.extend_from_slice(&0u32.to_le_bytes());
    buf.resize(buf.len() + 656, 0);
    buf.resize(buf.len() + 64, 0);
    assert_eq!(buf.len(), 1024);
    buf
}

fn append_standard_entry(buf: &mut Vec<u8>, payload: &[u8]) -> u64 {
    let aligned = (buf.len() + 127) & !127;
    buf.resize(aligned, 0);
    let info_off = u64::try_from(buf.len()).unwrap();

    let raw_file_size = u32::try_from(payload.len()).unwrap();
    buf.extend_from_slice(&32u32.to_le_bytes());
    buf.extend_from_slice(&2u32.to_le_bytes());
    buf.extend_from_slice(&raw_file_size.to_le_bytes());
    buf.extend_from_slice(&0u32.to_le_bytes());
    buf.extend_from_slice(&0u32.to_le_bytes());
    buf.extend_from_slice(&1u32.to_le_bytes());

    let block_total = u16::try_from(BLOCK_HEADER_SIZE as usize + payload.len()).expect("fits u16");
    let uncompressed = u16::try_from(payload.len()).expect("fits u16");
    buf.extend_from_slice(&0u32.to_le_bytes());
    buf.extend_from_slice(&block_total.to_le_bytes());
    buf.extend_from_slice(&uncompressed.to_le_bytes());

    buf.extend_from_slice(&BLOCK_HEADER_SIZE.to_le_bytes());
    buf.extend_from_slice(&0u32.to_le_bytes());
    buf.extend_from_slice(&BLOCK_TYPE_UNCOMPRESSED.to_le_bytes());
    buf.extend_from_slice(&u32::from(uncompressed).to_le_bytes());
    buf.extend_from_slice(payload);

    info_off
}

fn data_field(info_off: u64) -> u32 {
    u32::try_from(info_off / 8).expect("fits in u32")
}

fn make_v1_index(entries: &[(u64, u32)]) -> Vec<u8> {
    let mut buf = Vec::new();
    buf.extend_from_slice(&make_sqpack_header(2));
    let index_data_offset = SQPACK_HEADER_SIZE + 1024;
    let index_data_size = u32::try_from(entries.len()).unwrap() * INDEX_ENTRY_SIZE;
    buf.extend_from_slice(&make_index_header(index_data_offset, index_data_size, 1));
    for (hash, data) in entries {
        buf.extend_from_slice(&hash.to_le_bytes());
        buf.extend_from_slice(&data.to_le_bytes());
        buf.extend_from_slice(&0u32.to_le_bytes());
    }
    buf
}

fn unique_tempdir(tag: &str) -> PathBuf {
    let path = std::env::temp_dir().join(format!(
        "sqpack-rs-test-{tag}-{}-{}",
        std::process::id(),
        std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map_or(0, |d| d.as_nanos())
    ));
    let _ = fs::remove_dir_all(&path);
    path
}

fn build_fixture() -> (PathBuf, Vec<String>) {
    let root = unique_tempdir("block_cache");
    let sqpack_ffxiv = root.join("sqpack").join("ffxiv");
    fs::create_dir_all(&sqpack_ffxiv).expect("mkdir sqpack/ffxiv");

    let mut dat = make_sqpack_header(1);
    let mut entries: Vec<(u64, u32)> = Vec::with_capacity(FIXTURE_ENTRIES);
    let mut paths: Vec<String> = Vec::with_capacity(FIXTURE_ENTRIES);

    for i in 0..FIXTURE_ENTRIES {
        let path = format!("exd/block_cache_{i:04}.dat");
        let payload: Vec<u8> = (0..(128 + (i % 4) * 32))
            .map(|j| u8::try_from((i * 11 + j) & 0xff).expect("masked"))
            .collect();
        let off = append_standard_entry(&mut dat, &payload);
        entries.push((hash_index1_key(&path), data_field(off)));
        paths.push(path);
    }
    entries.sort_by_key(|&(h, _)| h);

    let index_bytes = make_v1_index(&entries);
    fs::write(
        sqpack_ffxiv.join(format!("{ARCHIVE_STEM}.index")),
        &index_bytes,
    )
    .expect("write index");
    fs::write(sqpack_ffxiv.join(format!("{ARCHIVE_STEM}.dat0")), &dat).expect("write dat");

    (root, paths)
}

fn cleanup(root: &Path) {
    let _ = fs::remove_dir_all(root);
}

#[test]
fn read_through_cache_matches_no_cache() {
    let (root, paths) = build_fixture();
    let sq_nocache = SqPack::open(&root).expect("open no-cache");
    let cache = Arc::new(BlockCache::new());
    let sq_cached = SqPack::open(&root)
        .expect("open cached")
        .with_block_cache(Arc::clone(&cache));

    for p in &paths {
        let baseline = sq_nocache.read_file(p).expect("baseline read");
        let cached = sq_cached.read_file(p).expect("cached read");
        assert_eq!(
            baseline, cached,
            "cached read of `{p}` must match no-cache baseline",
        );
    }
    cleanup(&root);
}

#[test]
fn cache_populates_on_read() {
    let (root, paths) = build_fixture();
    let cache = Arc::new(BlockCache::new());
    let sq = SqPack::open(&root)
        .expect("open")
        .with_block_cache(Arc::clone(&cache));

    assert!(cache.is_empty(), "cache must start empty");
    let _ = sq.read_file(&paths[0]).expect("first read");
    assert!(
        !cache.is_empty(),
        "first read must populate the cache (one entry per block)",
    );
    let after_first = cache.len();
    for p in paths.iter().skip(1).take(4) {
        let _ = sq.read_file(p).expect("subsequent read");
    }
    assert!(
        cache.len() > after_first,
        "subsequent distinct-file reads must grow the cache; {after_first} -> {}",
        cache.len(),
    );
    cleanup(&root);
}

#[test]
fn second_read_serves_from_cache() {
    let (root, paths) = build_fixture();
    let cache = Arc::new(BlockCache::new());
    let sq = SqPack::open(&root)
        .expect("open")
        .with_block_cache(Arc::clone(&cache));

    let path = &paths[0];
    let baseline = sq.read_file(path).expect("first read");
    let stats_after_first = cache.stats();
    let cache_len_after_first = cache.len();
    assert!(
        stats_after_first.misses > 0,
        "first read must record at least one miss before populating the cache",
    );

    let again = sq.read_file(path).expect("second read");
    assert_eq!(again, baseline);
    assert_eq!(
        cache.len(),
        cache_len_after_first,
        "second read of the same path must hit the cache, not grow it",
    );
    let stats_after_second = cache.stats();
    assert!(
        stats_after_second.hits > stats_after_first.hits,
        "second read of the same path must register at least one new cache hit \
         (hits {} -> {})",
        stats_after_first.hits,
        stats_after_second.hits,
    );
    cleanup(&root);
}

#[test]
fn read_evict_reread_matches_bytes() {
    let (root, paths) = build_fixture();
    let cache = Arc::new(BlockCache::new());
    let sq = SqPack::open(&root)
        .expect("open")
        .with_block_cache(Arc::clone(&cache));

    let mut first_pass: Vec<Vec<u8>> = Vec::with_capacity(paths.len());
    for p in &paths {
        first_pass.push(sq.read_file(p).expect("first read"));
    }
    assert!(!cache.is_empty());
    let dropped = cache.clear();
    assert!(
        dropped > 0,
        "clear() must report a non-zero drop count after population",
    );
    assert!(cache.is_empty());

    for (p, expected) in paths.iter().zip(first_pass.iter()) {
        let after_clear = sq.read_file(p).expect("read after clear");
        assert_eq!(
            &after_clear, expected,
            "byte view after clear/re-read must match the pre-clear read",
        );
    }
    cleanup(&root);
}

const RACE_THREAD_COUNT: usize = 8;

#[test]
fn concurrent_open_read_race_converges_to_one_entry() {
    use std::thread;
    let (root, paths) = build_fixture();
    let cache = Arc::new(BlockCache::new());
    let sq = SqPack::open(&root)
        .expect("open")
        .with_block_cache(Arc::clone(&cache));
    let sq = Arc::new(sq);

    let path = paths[0].clone();
    let expected = sq.read_file(&path).expect("baseline read");
    cache.clear();

    let mut handles = Vec::with_capacity(RACE_THREAD_COUNT);
    for _ in 0..RACE_THREAD_COUNT {
        let sq_c = Arc::clone(&sq);
        let path_c = path.clone();
        let expected_c = expected.clone();
        handles.push(thread::spawn(move || {
            let got = sq_c.read_file(&path_c).expect("racing read");
            assert_eq!(
                got, expected_c,
                "every racing reader must observe identical bytes"
            );
        }));
    }
    for h in handles {
        h.join().expect("worker join");
    }

    assert_eq!(
        cache.len(),
        1,
        "duplicate-insert race must converge to a single entry; got {}",
        cache.len(),
    );
    cleanup(&root);
}

#[test]
fn clear_drops_cached_entries() {
    let (root, paths) = build_fixture();
    let cache = Arc::new(BlockCache::new());
    let sq = SqPack::open(&root)
        .expect("open")
        .with_block_cache(Arc::clone(&cache));

    let _ = sq.read_file(&paths[0]).expect("read");
    assert!(!cache.is_empty());
    cache.clear();
    assert!(cache.is_empty());
    let _ = sq.read_file(&paths[0]).expect("read after clear");
    assert!(!cache.is_empty());
    cleanup(&root);
}

#[test]
fn sqpack_handles_share_one_cache() {
    let (root, paths) = build_fixture();
    let cache = Arc::new(BlockCache::new());
    let sq_a = SqPack::open(&root)
        .expect("open a")
        .with_block_cache(Arc::clone(&cache));
    let sq_b = SqPack::open(&root)
        .expect("open b")
        .with_block_cache(Arc::clone(&cache));

    let _ = sq_a.read_file(&paths[0]).expect("read via a");
    let fill_after_a = cache.len();
    let _ = sq_b.read_file(&paths[0]).expect("read via b");
    assert_eq!(
        cache.len(),
        fill_after_a,
        "two SqPack handles sharing one Arc<BlockCache> must serve one cache",
    );
    cleanup(&root);
}

#[test]
fn block_cache_accessor_round_trips() {
    let (root, _paths) = build_fixture();
    let cache = Arc::new(BlockCache::new());
    let sq = SqPack::open(&root)
        .expect("open")
        .with_block_cache(Arc::clone(&cache));
    assert!(sq.block_cache().is_some());
    assert!(Arc::ptr_eq(sq.block_cache().expect("attached"), &cache));
    cleanup(&root);
}

#[test]
fn no_cache_attached_returns_none() {
    let (root, _paths) = build_fixture();
    let sq = SqPack::open(&root).expect("open");
    assert!(sq.block_cache().is_none());
    cleanup(&root);
}

#[test]
fn set_and_unset_block_cache_round_trip() {
    let (root, _paths) = build_fixture();
    let mut sq = SqPack::open(&root).expect("open");
    assert!(sq.block_cache().is_none(), "starts empty");
    let cache = Arc::new(BlockCache::new());
    sq.set_block_cache(Arc::clone(&cache));
    assert!(sq.block_cache().is_some(), "after set");
    let taken = sq.take_block_cache().expect("take returns the cache");
    assert!(Arc::ptr_eq(&taken, &cache), "take returns the same Arc");
    assert!(sq.block_cache().is_none(), "after take");
    sq.set_block_cache(Arc::clone(&cache));
    sq.unset_block_cache();
    assert!(sq.block_cache().is_none(), "after unset");
    cleanup(&root);
}

#[test]
fn cross_install_cache_separation() {
    let (root_a, paths_a) = build_fixture();
    let (root_b, paths_b) = build_fixture();
    let cache = Arc::new(BlockCache::new());
    let sq_a = SqPack::open(&root_a)
        .expect("open a")
        .with_block_cache(Arc::clone(&cache));
    let sq_b = SqPack::open(&root_b)
        .expect("open b")
        .with_block_cache(Arc::clone(&cache));

    let baseline_first = SqPack::open(&root_a).expect("baseline a");
    let baseline_second = SqPack::open(&root_b).expect("baseline b");

    for (pa, pb) in paths_a.iter().zip(paths_b.iter()) {
        let from_a = sq_a.read_file(pa).expect("read via a");
        let from_b = sq_b.read_file(pb).expect("read via b");
        let truth_a = baseline_first.read_file(pa).expect("baseline a");
        let truth_b = baseline_second.read_file(pb).expect("baseline b");
        assert_eq!(from_a, truth_a, "install A byte view must match baseline");
        assert_eq!(from_b, truth_b, "install B byte view must match baseline");
    }
    cleanup(&root_a);
    cleanup(&root_b);
}

#[test]
fn evict_dat_removes_only_targeted_archive() {
    use sqpack_rs::route::ArchiveTriple;
    let (root, paths) = build_fixture();
    let cache = Arc::new(BlockCache::new());
    let sq = SqPack::open(&root)
        .expect("open")
        .with_block_cache(Arc::clone(&cache));

    for p in &paths {
        let _ = sq.read_file(p).expect("read");
    }
    let len_before = cache.len();
    assert!(len_before > 0);

    let triple = ArchiveTriple::from_game_path(&paths[0]).expect("known category");
    let install_id = sqpack_rs::block_cache::install_id_from_path(&root);
    let dropped = cache.evict_dat(install_id, triple, 0);
    assert_eq!(
        dropped, len_before,
        "every entry in this fixture should match the eviction filter",
    );
    assert!(cache.is_empty());

    let _ = sq.read_file(&paths[0]).expect("repopulate");
    let repopulated = cache.len();
    let dropped_none = cache.evict_dat(install_id.wrapping_add(1), triple, 0);
    assert_eq!(dropped_none, 0);
    assert_eq!(cache.len(), repopulated);
    cleanup(&root);
}

#[test]
fn streaming_read_populates_and_hits_cache() {
    let (root, paths) = build_fixture();
    let cache = Arc::new(BlockCache::new());
    let sq = SqPack::open(&root)
        .expect("open")
        .with_block_cache(Arc::clone(&cache));

    let path = &paths[0];
    let mut streamed_bytes: Vec<u8> = Vec::new();
    let stream = sq.read_file_streaming(path).expect("open stream");
    for block in stream {
        let chunk = block.expect("block");
        streamed_bytes.extend_from_slice(&chunk.bytes);
    }
    assert!(!streamed_bytes.is_empty(), "stream must yield bytes");

    let direct_bytes = sq.read_file(path).expect("direct read");
    assert_eq!(
        streamed_bytes, direct_bytes,
        "streamed bytes must match direct read",
    );

    let stats_after_first = cache.stats();
    assert!(
        stats_after_first.misses > 0,
        "first streaming pass must record at least one miss",
    );
    let len_after_first = cache.len();
    assert!(
        len_after_first > 0,
        "first streaming pass must populate cache"
    );

    let _ = sq.read_file(path).expect("second direct read");
    let stats_after_second = cache.stats();
    assert_eq!(
        cache.len(),
        len_after_first,
        "second read must not grow cache (all blocks already cached)",
    );
    assert!(
        stats_after_second.hits > stats_after_first.hits,
        "second read must register cache hits (hits {} -> {})",
        stats_after_first.hits,
        stats_after_second.hits,
    );
    cleanup(&root);
}

#[test]
fn streaming_bytes_match_no_cache_baseline() {
    let (root, paths) = build_fixture();
    let sq_baseline = SqPack::open(&root).expect("open baseline");
    let cache = Arc::new(BlockCache::new());
    let sq_cached = SqPack::open(&root)
        .expect("open cached")
        .with_block_cache(Arc::clone(&cache));

    for p in &paths {
        let baseline = sq_baseline.read_file(p).expect("baseline read");
        let stream = sq_cached.read_file_streaming(p).expect("open stream");
        let mut streamed: Vec<u8> = Vec::new();
        for block in stream {
            streamed.extend_from_slice(&block.expect("block").bytes);
        }
        assert_eq!(
            streamed, baseline,
            "streaming+cache bytes for `{p}` must match baseline",
        );
    }
    cleanup(&root);
}

#[test]
fn read_file_typed_populates_and_hits_cache() {
    let (root, paths) = build_fixture();
    let cache = Arc::new(BlockCache::new());
    let sq = SqPack::open(&root)
        .expect("open")
        .with_block_cache(Arc::clone(&cache));

    let path = &paths[0];

    let typed = sq.read_file_typed(path).expect("typed read");
    let typed_bytes = typed.bytes().to_vec();
    assert!(!typed_bytes.is_empty());
    let stats_after_typed = cache.stats();
    assert!(stats_after_typed.misses > 0, "first typed read must miss");
    let len_after_typed = cache.len();
    assert!(len_after_typed > 0, "typed read must populate cache");

    let plain_bytes = sq.read_file(path).expect("plain read after typed");
    assert_eq!(
        plain_bytes, typed_bytes,
        "plain read must return the same bytes as typed read",
    );
    let stats_after_plain = cache.stats();
    assert!(
        stats_after_plain.hits > stats_after_typed.hits,
        "plain read after typed must hit cache (hits {} -> {})",
        stats_after_typed.hits,
        stats_after_plain.hits,
    );
    cleanup(&root);
}

#[test]
fn take_block_cache_second_call_returns_none() {
    let (root, _paths) = build_fixture();
    let mut sq = SqPack::open(&root).expect("open");
    let cache = Arc::new(BlockCache::new());
    sq.set_block_cache(Arc::clone(&cache));
    let first = sq.take_block_cache();
    assert!(first.is_some(), "first take must return Some");
    let second = sq.take_block_cache();
    assert!(
        second.is_none(),
        "second take on empty handle must return None"
    );
    cleanup(&root);
}

#[test]
fn set_block_cache_replaces_existing_cache() {
    let (root, paths) = build_fixture();
    let cache_a = Arc::new(BlockCache::new());
    let cache_b = Arc::new(BlockCache::new());
    let mut sq = SqPack::open(&root)
        .expect("open")
        .with_block_cache(Arc::clone(&cache_a));

    let _ = sq.read_file(&paths[0]).expect("read via a");
    assert!(!cache_a.is_empty(), "cache_a must have entries after read");

    sq.set_block_cache(Arc::clone(&cache_b));
    assert!(
        sq.block_cache().is_some_and(|c| Arc::ptr_eq(c, &cache_b)),
        "after set_block_cache the attached cache must be cache_b",
    );

    let before_b = cache_b.len();
    let _ = sq.read_file(&paths[1]).expect("read via b");
    assert!(
        cache_b.len() > before_b,
        "read after set_block_cache must populate the new cache",
    );
    let cache_a_len_after_swap = cache_a.len();
    let _ = sq.read_file(&paths[1]).expect("second read via b");
    assert_eq!(
        cache_a.len(),
        cache_a_len_after_swap,
        "old cache must not receive new entries after replacement",
    );
    cleanup(&root);
}

#[test]
fn set_block_cache_chains_with_and_returns_mut_self() {
    let (root, _paths) = build_fixture();
    let cache_a = Arc::new(BlockCache::new());
    let cache_b = Arc::new(BlockCache::new());
    let mut sq = SqPack::open(&root).expect("open");
    sq.set_block_cache(Arc::clone(&cache_a))
        .set_block_cache(Arc::clone(&cache_b));
    assert!(
        Arc::ptr_eq(sq.block_cache().expect("attached"), &cache_b),
        "after chained set_block_cache calls the last one wins",
    );
    cleanup(&root);
}

#[test]
fn unset_block_cache_then_read_no_cache_populated() {
    let (root, paths) = build_fixture();
    let cache = Arc::new(BlockCache::new());
    let mut sq = SqPack::open(&root)
        .expect("open")
        .with_block_cache(Arc::clone(&cache));

    let _ = sq.read_file(&paths[0]).expect("populate");
    let len_before_unset = cache.len();
    assert!(len_before_unset > 0);

    sq.unset_block_cache();
    assert!(
        sq.block_cache().is_none(),
        "handle must have no cache after unset"
    );

    let _ = sq.read_file(&paths[1]).expect("read after unset");
    assert_eq!(
        cache.len(),
        len_before_unset,
        "reads after unset_block_cache must not populate the detached cache",
    );
    cleanup(&root);
}

#[test]
fn stats_counters_survive_clear() {
    let (root, paths) = build_fixture();
    let cache = Arc::new(BlockCache::new());
    let sq = SqPack::open(&root)
        .expect("open")
        .with_block_cache(Arc::clone(&cache));

    let _ = sq.read_file(&paths[0]).expect("first read (misses)");
    let _ = sq.read_file(&paths[0]).expect("second read (hits)");
    let stats_before = cache.stats();
    assert!(stats_before.misses > 0);
    assert!(stats_before.hits > 0);

    cache.clear();
    let stats_after_clear = cache.stats();
    assert_eq!(
        stats_after_clear.hits, stats_before.hits,
        "clear() must not reset hit counter",
    );
    assert_eq!(
        stats_after_clear.misses, stats_before.misses,
        "clear() must not reset miss counter",
    );
    assert_eq!(stats_after_clear.fill, 0, "clear() must reset fill to 0");
    cleanup(&root);
}

#[test]
fn stats_counters_survive_evict_dat() {
    use sqpack_rs::route::ArchiveTriple;
    let (root, paths) = build_fixture();
    let cache = Arc::new(BlockCache::new());
    let sq = SqPack::open(&root)
        .expect("open")
        .with_block_cache(Arc::clone(&cache));

    let _ = sq.read_file(&paths[0]).expect("first read (misses)");
    let _ = sq.read_file(&paths[0]).expect("second read (hits)");
    let stats_before = cache.stats();
    assert!(stats_before.hits > 0);
    assert!(stats_before.misses > 0);

    let triple = ArchiveTriple::from_game_path(&paths[0]).expect("known category");
    let install_id = sqpack_rs::block_cache::install_id_from_path(&root);
    cache.evict_dat(install_id, triple, 0);

    let stats_after = cache.stats();
    assert_eq!(
        stats_after.hits, stats_before.hits,
        "evict_dat() must not reset hit counter",
    );
    assert_eq!(
        stats_after.misses, stats_before.misses,
        "evict_dat() must not reset miss counter",
    );
    cleanup(&root);
}

#[test]
fn stats_counters_are_per_instance() {
    let (root, paths) = build_fixture();
    let cache_a = Arc::new(BlockCache::new());
    let cache_b = Arc::new(BlockCache::new());
    let sq_a = SqPack::open(&root)
        .expect("open a")
        .with_block_cache(Arc::clone(&cache_a));
    let sq_b = SqPack::open(&root)
        .expect("open b")
        .with_block_cache(Arc::clone(&cache_b));

    let _ = sq_a.read_file(&paths[0]).expect("read via a");
    let stats_b = cache_b.stats();
    assert_eq!(stats_b.hits, 0, "cache_b must not see cache_a's hits");
    assert_eq!(stats_b.misses, 0, "cache_b must not see cache_a's misses");

    let misses_a_before = cache_a.stats().misses;
    let _ = sq_b.read_file(&paths[0]).expect("read via b (miss)");
    assert_eq!(
        cache_a.stats().misses,
        misses_a_before,
        "cache_a miss count must be unchanged after cache_b read",
    );
    assert!(
        cache_b.stats().misses > 0,
        "cache_b must record its own misses"
    );
    cleanup(&root);
}

#[test]
fn with_capacity_one_stays_bounded_at_one_entry() {
    let (root, paths) = build_fixture();
    let cache = Arc::new(sqpack_rs::BlockCache::with_capacity(1));
    let sq = SqPack::open(&root)
        .expect("open")
        .with_block_cache(Arc::clone(&cache));

    let _ = sq.read_file(&paths[0]).expect("first read");
    assert_eq!(cache.len(), 1, "capacity-1 cache must have at most 1 entry");
    let _ = sq.read_file(&paths[1]).expect("second read");
    assert_eq!(
        cache.len(),
        1,
        "capacity-1 cache must stay at 1 entry after second distinct read",
    );
    assert_eq!(
        cache.capacity(),
        1,
        "capacity accessor must reflect the configured value",
    );
    cleanup(&root);
}

#[test]
fn install_id_differs_across_distinct_install_roots() {
    let (root_a, _) = build_fixture();
    let (root_b, _) = build_fixture();
    let id_a = sqpack_rs::block_cache::install_id_from_path(&root_a);
    let id_b = sqpack_rs::block_cache::install_id_from_path(&root_b);
    assert_ne!(
        id_a, id_b,
        "two distinct install roots must produce different install_ids",
    );
    cleanup(&root_a);
    cleanup(&root_b);
}

#[test]
fn install_id_is_stable_across_calls() {
    let (root, _) = build_fixture();
    let a = sqpack_rs::block_cache::install_id_from_path(&root);
    let b = sqpack_rs::block_cache::install_id_from_path(&root);
    assert_eq!(a, b, "install_id must be deterministic for the same path");
    cleanup(&root);
}

#[test]
fn new_uses_default_capacity_constant() {
    use sqpack_rs::block_cache::BLOCK_CACHE_DEFAULT_CAPACITY;
    let c = sqpack_rs::BlockCache::new();
    assert_eq!(
        c.capacity(),
        BLOCK_CACHE_DEFAULT_CAPACITY,
        "BlockCache::new() must use BLOCK_CACHE_DEFAULT_CAPACITY as the entry cap",
    );
}

#[test]
fn evict_dat_leaves_different_install_entries_intact() {
    use sqpack_rs::route::ArchiveTriple;
    let (root_a, paths_a) = build_fixture();
    let (root_b, paths_b) = build_fixture();
    let cache = Arc::new(BlockCache::new());
    let sq_a = SqPack::open(&root_a)
        .expect("open a")
        .with_block_cache(Arc::clone(&cache));
    let sq_b = SqPack::open(&root_b)
        .expect("open b")
        .with_block_cache(Arc::clone(&cache));

    let _ = sq_a.read_file(&paths_a[0]).expect("read via a");
    let _ = sq_b.read_file(&paths_b[0]).expect("read via b");

    let len_before = cache.len();
    assert!(len_before >= 1, "cache must have been populated");

    let triple = ArchiveTriple::from_game_path(&paths_a[0]).expect("known category");
    let id_a = sqpack_rs::block_cache::install_id_from_path(&root_a);
    let id_b = sqpack_rs::block_cache::install_id_from_path(&root_b);
    assert_ne!(id_a, id_b, "test pre-condition: distinct install roots");

    let dropped = cache.evict_dat(id_a, triple, 0);
    assert!(
        dropped >= 1,
        "evict_dat for install_A must drop at least one entry",
    );

    let hits_before = cache.stats().hits;
    let _ = sq_b.read_file(&paths_b[0]).expect("reread via b");
    assert!(
        cache.stats().hits > hits_before,
        "install_B's entry must survive install_A eviction (re-read must hit)",
    );
    cleanup(&root_a);
    cleanup(&root_b);
}

#[cfg(feature = "parallel")]
mod with_parallel {
    use super::{Arc, BlockCache, SqPack, build_fixture, cleanup};
    use sqpack_rs::{SqPackHashManifestBuilder, SqPackHashVerifier};

    #[test]
    fn hash_verify_parallel_with_shared_cache() {
        let (root, _paths) = build_fixture();
        let sq_baseline = SqPack::open(&root).expect("open baseline");
        let manifest = SqPackHashManifestBuilder::new(&sq_baseline)
            .build()
            .expect("build manifest")
            .manifest;

        let cache = Arc::new(BlockCache::new());
        let sq_cached = SqPack::open(&root)
            .expect("open cached")
            .with_block_cache(Arc::clone(&cache));

        let baseline_report = SqPackHashVerifier::new(&sq_baseline).verify_parallel(&manifest);
        let cached_report = SqPackHashVerifier::new(&sq_cached).verify_parallel(&manifest);
        assert_eq!(
            baseline_report, cached_report,
            "hash_verify_parallel + block_cache must produce the no-cache report bit-for-bit",
        );
        assert!(
            !cache.is_empty(),
            "parallel workers must have populated the shared cache",
        );
        cleanup(&root);
    }
}

#[cfg(feature = "mmap")]
mod with_mmap {
    use super::{Arc, BlockCache, SqPack, build_fixture, cleanup};
    use sqpack_rs::{MmapFs, Vfs};

    #[test]
    fn mmap_and_block_cache_compose_to_correct_bytes() {
        let (root, paths) = build_fixture();
        let sq_baseline = SqPack::open(&root).expect("open baseline");
        let cache = Arc::new(BlockCache::new());
        let vfs: Arc<dyn Vfs> = Arc::new(MmapFs::new());
        let sq_combo = SqPack::open_with_vfs(&root, vfs)
            .expect("open mmap")
            .with_block_cache(Arc::clone(&cache));

        for p in &paths {
            let baseline = sq_baseline.read_file(p).expect("baseline read");
            let combo = sq_combo.read_file(p).expect("mmap+cache read");
            assert_eq!(
                baseline, combo,
                "mmap+cache read of `{p}` must match baseline"
            );
        }
        assert!(
            !cache.is_empty(),
            "cache must populate under the mmap backend"
        );
        let stats_after_first = cache.stats();
        let len_after_first = cache.len();
        assert!(
            stats_after_first.misses > 0,
            "first pass must record at least one miss while populating"
        );

        for p in &paths {
            let combo = sq_combo.read_file(p).expect("mmap+cache reread");
            let baseline = sq_baseline.read_file(p).expect("baseline reread");
            assert_eq!(combo, baseline);
        }
        let stats_after_second = cache.stats();
        assert_eq!(
            cache.len(),
            len_after_first,
            "second pass must not grow the cache (all blocks already cached)",
        );
        assert!(
            stats_after_second.hits > stats_after_first.hits,
            "second pass must register additional hits (hits {} -> {})",
            stats_after_first.hits,
            stats_after_second.hits,
        );
        cleanup(&root);
    }
}

#[cfg(all(feature = "mmap", feature = "parallel"))]
mod with_mmap_and_parallel {
    use super::{Arc, BlockCache, SqPack, build_fixture, cleanup};
    use sqpack_rs::{MmapFs, SqPackHashManifestBuilder, SqPackHashVerifier, Vfs};

    #[test]
    fn block_cache_mmap_parallel_all_three_compose() {
        let (root, _paths) = build_fixture();

        let sq_baseline = SqPack::open(&root).expect("open baseline");
        let manifest = SqPackHashManifestBuilder::new(&sq_baseline)
            .build()
            .expect("build manifest")
            .manifest;
        let baseline_report = SqPackHashVerifier::new(&sq_baseline).verify_parallel(&manifest);

        let cache = Arc::new(BlockCache::new());
        let vfs: Arc<dyn Vfs> = Arc::new(MmapFs::new());
        let sq_combo = SqPack::open_with_vfs(&root, vfs)
            .expect("open mmap+cache")
            .with_block_cache(Arc::clone(&cache));

        let combo_report = SqPackHashVerifier::new(&sq_combo).verify_parallel(&manifest);

        assert_eq!(
            combo_report, baseline_report,
            "mmap+block_cache+parallel verify report must match the baseline",
        );
        assert!(
            !cache.is_empty(),
            "parallel workers must have populated the shared cache under mmap",
        );

        let stats_after_first = cache.stats();
        let _combo_report2 = SqPackHashVerifier::new(&sq_combo).verify_parallel(&manifest);
        let stats_after_second = cache.stats();
        assert!(
            stats_after_second.hits > stats_after_first.hits,
            "second parallel pass must register cache hits (hits {} -> {})",
            stats_after_first.hits,
            stats_after_second.hits,
        );
        cleanup(&root);
    }
}
