use std::sync::Arc;

use sha2::{Digest, Sha256};
use sqpack_rs::dat::BLOCK_HEADER_SIZE;
use sqpack_rs::index::INDEX_ENTRY_SIZE;
use sqpack_rs::index2::INDEX2_ENTRY_SIZE;
use sqpack_rs::route::ArchiveTriple;
use sqpack_rs::verify::{
    HashManifestBuildIssueKind, HashManifestEntry, HashMismatch, HashVerifyEvent, IndexHashKey,
    PathManifestAddOutcome, PathManifestEntry, PathMismatch, PathMismatchKind,
};
use sqpack_rs::vfs::InMemoryFs;
use sqpack_rs::{
    Category, FileKind, HashScanObserver, PathVerifyObserver, SqPack, SqPackHashManifest,
    SqPackHashManifestBuilder, SqPackHashVerifier, SqPackPathManifest, SqPackPathManifestBuilder,
    SqPackPathVerifier,
};

const SQPACK_HEADER_SIZE: u32 = 1024;
const BLOCK_TYPE_UNCOMPRESSED: u32 = 32000;
const SQPACK_MAGIC: [u8; 8] = [0x53, 0x71, 0x50, 0x61, 0x63, 0x6b, 0x00, 0x00];

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(&[0u8; 3]);
    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) -> 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(&1u32.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 make_index_file(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));
    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
}

struct DatBuilder {
    buf: Vec<u8>,
}

impl DatBuilder {
    fn new() -> Self {
        Self {
            buf: make_sqpack_header(1),
        }
    }

    fn append_standard(&mut self, payload: &[u8]) -> u64 {
        let aligned = (self.buf.len() + 127) & !127;
        self.buf.resize(aligned, 0);
        let info_off = u64::try_from(self.buf.len()).unwrap();
        let raw_file_size = u32::try_from(payload.len()).unwrap();
        self.buf.extend_from_slice(&32u32.to_le_bytes());
        self.buf.extend_from_slice(&2u32.to_le_bytes());
        self.buf.extend_from_slice(&raw_file_size.to_le_bytes());
        self.buf.extend_from_slice(&0u32.to_le_bytes());
        self.buf.extend_from_slice(&0u32.to_le_bytes());
        self.buf.extend_from_slice(&1u32.to_le_bytes());
        let compressed_size = u16::try_from(BLOCK_HEADER_SIZE as usize + payload.len()).unwrap();
        let uncompressed_size = u16::try_from(payload.len()).unwrap();
        self.buf.extend_from_slice(&0u32.to_le_bytes());
        self.buf.extend_from_slice(&compressed_size.to_le_bytes());
        self.buf.extend_from_slice(&uncompressed_size.to_le_bytes());
        self.buf.extend_from_slice(&BLOCK_HEADER_SIZE.to_le_bytes());
        self.buf.extend_from_slice(&0u32.to_le_bytes());
        self.buf
            .extend_from_slice(&BLOCK_TYPE_UNCOMPRESSED.to_le_bytes());
        self.buf
            .extend_from_slice(&u32::from(uncompressed_size).to_le_bytes());
        self.buf.extend_from_slice(payload);
        info_off
    }

    fn append_empty_stub(&mut self) -> u64 {
        let aligned = (self.buf.len() + 127) & !127;
        self.buf.resize(aligned, 0);
        let info_off = u64::try_from(self.buf.len()).unwrap();
        self.buf.extend_from_slice(&24u32.to_le_bytes());
        self.buf.extend_from_slice(&1u32.to_le_bytes());
        self.buf.extend_from_slice(&0u32.to_le_bytes());
        self.buf.extend_from_slice(&0u32.to_le_bytes());
        self.buf.extend_from_slice(&0u32.to_le_bytes());
        self.buf.extend_from_slice(&0u32.to_le_bytes());
        info_off
    }

    fn build_model_payload(&mut self, payload: &[u8]) -> u64 {
        const MODEL_HEADER_SIZE: u32 = 208;
        let aligned = (self.buf.len() + 127) & !127;
        self.buf.resize(aligned, 0);
        let info_off = u64::try_from(self.buf.len()).unwrap();
        let header_size: u32 = MODEL_HEADER_SIZE + 2;
        let payload_len = u32::try_from(payload.len()).unwrap();

        self.buf.extend_from_slice(&header_size.to_le_bytes());
        self.buf.extend_from_slice(&3u32.to_le_bytes());
        self.buf.extend_from_slice(&payload_len.to_le_bytes());
        self.buf.extend_from_slice(&1u32.to_le_bytes());
        self.buf.extend_from_slice(&1u32.to_le_bytes());
        self.buf.extend_from_slice(&0u32.to_le_bytes());
        self.buf.extend_from_slice(&0u32.to_le_bytes());
        self.buf.extend_from_slice(&0u32.to_le_bytes());
        for _ in 0..9 {
            self.buf.extend_from_slice(&0u32.to_le_bytes());
        }
        self.buf.extend_from_slice(&0u32.to_le_bytes());
        self.buf.extend_from_slice(&0u32.to_le_bytes());
        for _ in 0..9 {
            self.buf.extend_from_slice(&0u32.to_le_bytes());
        }
        self.buf.extend_from_slice(&0u32.to_le_bytes());
        self.buf.extend_from_slice(&0u32.to_le_bytes());
        for _ in 0..9 {
            self.buf.extend_from_slice(&0u32.to_le_bytes());
        }
        self.buf.extend_from_slice(&0u16.to_le_bytes());
        self.buf.extend_from_slice(&0u16.to_le_bytes());
        for _ in 0..9 {
            self.buf.extend_from_slice(&0u16.to_le_bytes());
        }
        self.buf.extend_from_slice(&1u16.to_le_bytes());
        self.buf.extend_from_slice(&0u16.to_le_bytes());
        for _ in 0..9 {
            self.buf.extend_from_slice(&0u16.to_le_bytes());
        }
        self.buf.extend_from_slice(&0u16.to_le_bytes());
        self.buf.extend_from_slice(&0u16.to_le_bytes());
        self.buf.push(0);
        self.buf.push(0);
        self.buf.push(0);
        self.buf.push(0);

        let all_in = u16::try_from(BLOCK_HEADER_SIZE as usize + payload.len()).unwrap();
        self.buf.extend_from_slice(&all_in.to_le_bytes());

        self.buf.extend_from_slice(&BLOCK_HEADER_SIZE.to_le_bytes());
        self.buf.extend_from_slice(&0u32.to_le_bytes());
        self.buf
            .extend_from_slice(&BLOCK_TYPE_UNCOMPRESSED.to_le_bytes());
        self.buf.extend_from_slice(&payload_len.to_le_bytes());
        self.buf.extend_from_slice(payload);

        info_off
    }

    fn append_model_stub_invalid_header(&mut self) -> u64 {
        let aligned = (self.buf.len() + 127) & !127;
        self.buf.resize(aligned, 0);
        let info_off = u64::try_from(self.buf.len()).unwrap();
        self.buf.extend_from_slice(&24u32.to_le_bytes());
        self.buf.extend_from_slice(&3u32.to_le_bytes());
        self.buf.extend_from_slice(&0u32.to_le_bytes());
        self.buf.extend_from_slice(&0u32.to_le_bytes());
        self.buf.extend_from_slice(&0u32.to_le_bytes());
        self.buf.extend_from_slice(&0u32.to_le_bytes());
        info_off
    }

    fn build_texture_payload(&mut self, preamble: &[u8], mip: &[u8]) -> u64 {
        const LOD_BLOCK_SIZE: u32 = 20;
        let aligned = (self.buf.len() + 127) & !127;
        self.buf.resize(aligned, 0);
        let info_off = u64::try_from(self.buf.len()).unwrap();

        let info_size = 24 + LOD_BLOCK_SIZE + 2;
        let mip_len = u32::try_from(mip.len()).unwrap();
        let preamble_len = u32::try_from(preamble.len()).unwrap();
        let raw_total = preamble_len + mip_len;
        let all_in = u16::try_from(BLOCK_HEADER_SIZE as usize + mip.len()).unwrap();

        self.buf.extend_from_slice(&info_size.to_le_bytes());
        self.buf.extend_from_slice(&4u32.to_le_bytes());
        self.buf.extend_from_slice(&raw_total.to_le_bytes());
        self.buf.extend_from_slice(&0u32.to_le_bytes());
        self.buf.extend_from_slice(&0u32.to_le_bytes());
        self.buf.extend_from_slice(&1u32.to_le_bytes());
        self.buf.extend_from_slice(&preamble_len.to_le_bytes());
        self.buf.extend_from_slice(&u32::from(all_in).to_le_bytes());
        self.buf.extend_from_slice(&mip_len.to_le_bytes());
        self.buf.extend_from_slice(&0u32.to_le_bytes());
        self.buf.extend_from_slice(&1u32.to_le_bytes());
        self.buf.extend_from_slice(&all_in.to_le_bytes());
        self.buf.extend_from_slice(preamble);
        self.buf.extend_from_slice(&BLOCK_HEADER_SIZE.to_le_bytes());
        self.buf.extend_from_slice(&0u32.to_le_bytes());
        self.buf
            .extend_from_slice(&BLOCK_TYPE_UNCOMPRESSED.to_le_bytes());
        self.buf.extend_from_slice(&mip_len.to_le_bytes());
        self.buf.extend_from_slice(mip);

        info_off
    }

    fn into_bytes(self) -> Vec<u8> {
        self.buf
    }
}

fn hash_index1_key_for_path(path: &str) -> u64 {
    sqpack_rs::hash::hash_index1_key(path)
}

fn data_field_for_offset(info_off: u64) -> u32 {
    u32::try_from(info_off / 8).unwrap()
}

#[allow(clippy::type_complexity)]
fn build_install(
    paths_with_payloads: &[(&str, &[u8])],
) -> (InMemoryFs, std::collections::BTreeMap<String, [u8; 32]>) {
    let fs = InMemoryFs::new();
    let mut dat = DatBuilder::new();
    let mut entries: Vec<(u64, u32)> = Vec::new();
    let mut expected_hashes = std::collections::BTreeMap::new();

    for (path, payload) in paths_with_payloads {
        let info_off = dat.append_standard(payload);
        let v1_key = hash_index1_key_for_path(path);
        entries.push((v1_key, data_field_for_offset(info_off)));
        let mut h = Sha256::new();
        h.update(payload);
        let arr: [u8; 32] = h.finalize().into();
        expected_hashes.insert((*path).to_string(), arr);
    }

    let dat_bytes = dat.into_bytes();
    fs.insert(
        "/install/sqpack/ffxiv/0a0000.win32.index",
        make_index_file(&entries),
    );
    fs.insert("/install/sqpack/ffxiv/0a0000.win32.dat0", dat_bytes);

    (fs, expected_hashes)
}

#[test]
fn builder_adds_standard_files_and_records_correct_hashes() {
    let payload_a = b"hello-manifest".to_vec();
    let payload_b = b"second-payload-for-manifest".to_vec();
    let (fs, expected) =
        build_install(&[("exd/root.exl", &payload_a), ("exd/item.exh", &payload_b)]);

    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");
    let mut builder = SqPackPathManifestBuilder::new(&sq);

    let outcome_a = builder.add("exd/root.exl").expect("add A");
    let outcome_b = builder.add("exd/item.exh").expect("add B");

    match outcome_a {
        PathManifestAddOutcome::Added(e) => {
            assert_eq!(e.hash, expected["exd/root.exl"]);
            assert_eq!(e.size, u32::try_from(payload_a.len()).unwrap());
        }
        other => panic!("expected Added for Standard payload, got {other:?}"),
    }
    match outcome_b {
        PathManifestAddOutcome::Added(e) => {
            assert_eq!(e.hash, expected["exd/item.exh"]);
            assert_eq!(e.size, u32::try_from(payload_b.len()).unwrap());
        }
        other => panic!("expected Added for Standard payload, got {other:?}"),
    }

    let manifest = builder.finish();
    assert_eq!(manifest.len(), 2);
    let e = manifest.get("exd/root.exl").unwrap();
    assert_eq!(e.hash, expected["exd/root.exl"]);
}

#[test]
fn builder_missing_path_errors() {
    let (fs, _) = build_install(&[("exd/root.exl", b"x")]);
    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");
    let mut builder = SqPackPathManifestBuilder::new(&sq);
    let err = builder.add("exd/missing.bin").unwrap_err();
    assert!(matches!(err, sqpack_rs::Error::PathNotFound { .. }));
}

#[test]
fn builder_records_model_payload_via_typed_decoder() {
    let fs = InMemoryFs::new();
    let mut dat = DatBuilder::new();
    let model_payload = b"the model byte view".to_vec();
    let model_off = dat.build_model_payload(&model_payload);
    let v1_key = hash_index1_key_for_path("bgcommon/foo.mdl");
    let entries = [(v1_key, data_field_for_offset(model_off))];

    fs.insert(
        "/install/sqpack/ffxiv/010000.win32.index",
        make_index_file(&entries),
    );
    fs.insert("/install/sqpack/ffxiv/010000.win32.dat0", dat.into_bytes());

    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");
    let mut builder = SqPackPathManifestBuilder::new(&sq);
    let outcome = builder.add("bgcommon/foo.mdl").expect("add");
    let mut h = Sha256::new();
    h.update(&model_payload);
    let expected_hash: [u8; 32] = h.finalize().into();
    match outcome {
        PathManifestAddOutcome::Added(e) => {
            assert_eq!(e.hash, expected_hash);
            assert_eq!(e.size, u32::try_from(model_payload.len()).unwrap());
        }
        other => panic!("expected Added for Model kind, got {other:?}"),
    }
    let manifest = builder.finish();
    assert!(manifest.get("bgcommon/foo.mdl").is_some());
    assert_eq!(manifest.len(), 1);
}

#[test]
fn builder_surfaces_read_error_for_malformed_model_header() {
    let fs = InMemoryFs::new();
    let mut dat = DatBuilder::new();
    let model_off = dat.append_model_stub_invalid_header();
    let v1_key = hash_index1_key_for_path("bgcommon/bad.mdl");
    let entries = [(v1_key, data_field_for_offset(model_off))];
    fs.insert(
        "/install/sqpack/ffxiv/010000.win32.index",
        make_index_file(&entries),
    );
    fs.insert("/install/sqpack/ffxiv/010000.win32.dat0", dat.into_bytes());

    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");
    let mut builder = SqPackPathManifestBuilder::new(&sq);
    let err = builder.add("bgcommon/bad.mdl").unwrap_err();
    assert!(
        matches!(
            err,
            sqpack_rs::Error::Io { .. } | sqpack_rs::Error::Decode { .. }
        ),
        "expected Io / Decode for truncated model header, got {err:?}"
    );
}

#[test]
fn verifier_clean_install_reports_clean() {
    let payload = b"hello-verify".to_vec();
    let (fs, _) = build_install(&[("exd/root.exl", &payload)]);
    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");

    let mut builder = SqPackPathManifestBuilder::new(&sq);
    builder.add("exd/root.exl").unwrap();
    let manifest = builder.finish();

    let mut verifier = SqPackPathVerifier::new(&sq);
    let report = verifier.verify(&manifest);
    assert!(report.is_clean());
    assert_eq!(report.verified_count, 1);
    assert_eq!(report.skipped_count, 0);
    assert!(report.mismatches.is_empty());
}

#[test]
fn verifier_detects_hash_mismatch_against_modified_install() {
    let payload_orig = b"original-bytes-for-hash".to_vec();
    let (fs_orig, _) = build_install(&[("exd/root.exl", &payload_orig)]);
    let sq_orig = SqPack::open_with_vfs("/install", Arc::new(fs_orig)).expect("open original");
    let mut builder = SqPackPathManifestBuilder::new(&sq_orig);
    builder.add("exd/root.exl").unwrap();
    let manifest = builder.finish();

    let payload_modified = b"modified-bytes-for-hash".to_vec();
    assert_eq!(
        payload_orig.len(),
        payload_modified.len(),
        "fixture invariant"
    );
    let (fs_modified, _) = build_install(&[("exd/root.exl", &payload_modified)]);
    let sq_modified =
        SqPack::open_with_vfs("/install", Arc::new(fs_modified)).expect("open modified");

    let mut verifier = SqPackPathVerifier::new(&sq_modified);
    let report = verifier.verify(&manifest);
    assert!(!report.is_clean());
    assert_eq!(report.mismatches.len(), 1);
    let m = &report.mismatches[0];
    assert_eq!(m.game_path, "exd/root.exl");
    assert_eq!(m.category, Category::try_from_name("exd").unwrap());
    match &m.kind {
        PathMismatchKind::HashMismatch { expected, actual } => {
            assert_ne!(expected, actual);
        }
        other => panic!("expected HashMismatch, got {other:?}"),
    }
}

#[test]
fn verifier_detects_size_mismatch_before_hashing() {
    let payload = b"some-bytes".to_vec();
    let (fs, _) = build_install(&[("exd/root.exl", &payload)]);
    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");

    let mut manifest = SqPackPathManifest::new();
    let payload_size = u32::try_from(payload.len()).unwrap();
    manifest.entries.insert(
        "exd/root.exl".into(),
        PathManifestEntry::new([0u8; 32], payload_size + 99),
    );

    let mut verifier = SqPackPathVerifier::new(&sq);
    let report = verifier.verify(&manifest);
    assert_eq!(report.mismatches.len(), 1);
    match &report.mismatches[0].kind {
        PathMismatchKind::SizeMismatch { expected, actual } => {
            assert_eq!(*expected, payload_size + 99);
            assert_eq!(*actual, payload_size);
        }
        other => panic!("expected SizeMismatch, got {other:?}"),
    }
}

#[test]
fn verifier_detects_missing_file() {
    let payload = b"present-then-gone".to_vec();
    let (fs, _) = build_install(&[("exd/root.exl", &payload)]);
    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");
    let mut builder = SqPackPathManifestBuilder::new(&sq);
    builder.add("exd/root.exl").unwrap();
    let manifest = builder.finish();

    let empty_fs = InMemoryFs::new();
    empty_fs.insert("/install/sqpack/ffxiv/marker", b"x".to_vec());
    let sq_empty = SqPack::open_with_vfs("/install", Arc::new(empty_fs)).expect("open empty");

    let mut verifier = SqPackPathVerifier::new(&sq_empty);
    let report = verifier.verify(&manifest);
    assert_eq!(report.mismatches.len(), 1);
    assert!(matches!(
        report.mismatches[0].kind,
        PathMismatchKind::Missing
    ));
    assert_eq!(report.verified_count, 0);
}

#[test]
fn verifier_model_kind_hashes_against_manifest() {
    let fs = InMemoryFs::new();
    let mut dat = DatBuilder::new();
    let model_payload = b"verifiable model bytes".to_vec();
    let model_off = dat.build_model_payload(&model_payload);
    let v1_key = hash_index1_key_for_path("bgcommon/foo.mdl");
    let entries = [(v1_key, data_field_for_offset(model_off))];
    fs.insert(
        "/install/sqpack/ffxiv/010000.win32.index",
        make_index_file(&entries),
    );
    fs.insert("/install/sqpack/ffxiv/010000.win32.dat0", dat.into_bytes());

    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");

    let mut builder = SqPackPathManifestBuilder::new(&sq);
    builder.add("bgcommon/foo.mdl").expect("add");
    let manifest = builder.finish();
    assert_eq!(manifest.len(), 1);

    let mut verifier = SqPackPathVerifier::new(&sq);
    let report = verifier.verify(&manifest);
    assert!(
        report.is_clean(),
        "Model file should verify clean: {report:?}"
    );
    assert_eq!(report.verified_count, 1);
    assert_eq!(report.skipped_count, 0);
    assert!(report.mismatches.is_empty());
}

#[test]
fn verifier_texture_kind_hashes_against_manifest() {
    let fs = InMemoryFs::new();
    let mut dat = DatBuilder::new();
    let preamble: [u8; 8] = [0xCA, 0xFE, 0xBA, 0xBE, 0x01, 0x02, 0x03, 0x04];
    let mip = b"a mip block".to_vec();
    let tex_off = dat.build_texture_payload(&preamble, &mip);
    let v1_key = hash_index1_key_for_path("bgcommon/foo.tex");
    let entries = [(v1_key, data_field_for_offset(tex_off))];
    fs.insert(
        "/install/sqpack/ffxiv/010000.win32.index",
        make_index_file(&entries),
    );
    fs.insert("/install/sqpack/ffxiv/010000.win32.dat0", dat.into_bytes());

    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");
    let mut builder = SqPackPathManifestBuilder::new(&sq);
    builder.add("bgcommon/foo.tex").expect("add");
    let manifest = builder.finish();
    assert_eq!(manifest.len(), 1);

    let mut verifier = SqPackPathVerifier::new(&sq);
    let report = verifier.verify(&manifest);
    assert!(
        report.is_clean(),
        "Texture file should verify clean: {report:?}"
    );
    assert_eq!(report.verified_count, 1);
    assert_eq!(report.skipped_count, 0);
    assert!(report.mismatches.is_empty());
}

#[test]
fn verifier_model_kind_reports_hash_mismatch_not_unverifiable() {
    let fs = InMemoryFs::new();
    let mut dat = DatBuilder::new();
    let model_payload_orig = b"original model bytes: same length aa".to_vec();
    let model_off = dat.build_model_payload(&model_payload_orig);
    let v1_key = hash_index1_key_for_path("bgcommon/bar.mdl");
    let entries = [(v1_key, data_field_for_offset(model_off))];
    fs.insert(
        "/install/sqpack/ffxiv/010000.win32.index",
        make_index_file(&entries),
    );
    fs.insert("/install/sqpack/ffxiv/010000.win32.dat0", dat.into_bytes());

    let sq_orig = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");
    let mut builder = SqPackPathManifestBuilder::new(&sq_orig);
    builder.add("bgcommon/bar.mdl").expect("add");
    let manifest = builder.finish();

    let fs2 = InMemoryFs::new();
    let mut dat2 = DatBuilder::new();
    let model_payload_mod = b"modified model bytes: same length bb".to_vec();
    assert_eq!(model_payload_orig.len(), model_payload_mod.len());
    let model_off2 = dat2.build_model_payload(&model_payload_mod);
    let entries2 = [(v1_key, data_field_for_offset(model_off2))];
    fs2.insert(
        "/install/sqpack/ffxiv/010000.win32.index",
        make_index_file(&entries2),
    );
    fs2.insert("/install/sqpack/ffxiv/010000.win32.dat0", dat2.into_bytes());

    let sq_mod = SqPack::open_with_vfs("/install", Arc::new(fs2)).expect("open modified");
    let mut verifier = SqPackPathVerifier::new(&sq_mod);
    let report = verifier.verify(&manifest);
    assert!(!report.is_clean());
    assert_eq!(report.mismatches.len(), 1);
    match &report.mismatches[0].kind {
        PathMismatchKind::HashMismatch { expected, actual } => {
            assert_ne!(expected, actual, "hash must differ");
        }
        other => panic!("expected HashMismatch for Model kind, got {other:?}"),
    }
}

#[test]
fn verifier_texture_kind_reports_hash_mismatch_not_unverifiable() {
    let fs = InMemoryFs::new();
    let mut dat = DatBuilder::new();
    let preamble: [u8; 8] = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08];
    let mip_orig = b"original-mip-content".to_vec();
    let tex_off = dat.build_texture_payload(&preamble, &mip_orig);
    let v1_key = hash_index1_key_for_path("bgcommon/bar.tex");
    let entries = [(v1_key, data_field_for_offset(tex_off))];
    fs.insert(
        "/install/sqpack/ffxiv/010000.win32.index",
        make_index_file(&entries),
    );
    fs.insert("/install/sqpack/ffxiv/010000.win32.dat0", dat.into_bytes());

    let sq_orig = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");
    let mut builder = SqPackPathManifestBuilder::new(&sq_orig);
    builder.add("bgcommon/bar.tex").expect("add");
    let manifest = builder.finish();

    let fs2 = InMemoryFs::new();
    let mut dat2 = DatBuilder::new();
    let mip_mod = b"modified-mip-content".to_vec();
    assert_eq!(mip_orig.len(), mip_mod.len());
    let tex_off2 = dat2.build_texture_payload(&preamble, &mip_mod);
    let entries2 = [(v1_key, data_field_for_offset(tex_off2))];
    fs2.insert(
        "/install/sqpack/ffxiv/010000.win32.index",
        make_index_file(&entries2),
    );
    fs2.insert("/install/sqpack/ffxiv/010000.win32.dat0", dat2.into_bytes());

    let sq_mod = SqPack::open_with_vfs("/install", Arc::new(fs2)).expect("open modified");
    let mut verifier = SqPackPathVerifier::new(&sq_mod);
    let report = verifier.verify(&manifest);
    assert!(!report.is_clean());
    assert_eq!(report.mismatches.len(), 1);
    match &report.mismatches[0].kind {
        PathMismatchKind::HashMismatch { expected, actual } => {
            assert_ne!(expected, actual, "hash must differ");
        }
        other => panic!("expected HashMismatch for Texture kind, got {other:?}"),
    }
}

#[test]
fn streaming_verifier_yields_only_mismatches_and_tracks_counts() {
    let p_ok_a = b"clean-a".to_vec();
    let p_ok_b = b"clean-b-with-different-len".to_vec();
    let p_bad = b"will-mismatch".to_vec();
    let (fs, _) = build_install(&[
        ("exd/a.exl", &p_ok_a),
        ("exd/b.exl", &p_ok_b),
        ("exd/c.exl", &p_bad),
    ]);
    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");

    let mut builder = SqPackPathManifestBuilder::new(&sq);
    builder.add("exd/a.exl").unwrap();
    builder.add("exd/b.exl").unwrap();
    builder.add("exd/c.exl").unwrap();
    let mut manifest = builder.finish();
    let c = manifest.entries.get_mut("exd/c.exl").unwrap();
    c.hash = [0xFF; 32];
    manifest.entries.insert(
        "exd/missing.exl".into(),
        PathManifestEntry::new([0u8; 32], 1),
    );

    let mut verifier = SqPackPathVerifier::new(&sq);
    let mut stream = verifier.verify_streaming(&manifest);
    let events: Vec<_> = stream.by_ref().collect();
    assert_eq!(events.len(), 2);

    let paths: Vec<&str> = events.iter().map(|m| m.game_path.as_str()).collect();
    assert_eq!(paths, vec!["exd/c.exl", "exd/missing.exl"]);

    assert_eq!(stream.verified_count(), 2);
    assert_eq!(stream.skipped_count(), 0);
}

#[test]
fn verify_report_by_category_groups_mismatches() {
    let payload_exd = b"exd-payload".to_vec();
    let payload_bg = b"bg-payload-diff-len".to_vec();
    let (fs, _) = build_install(&[("exd/a.exl", &payload_exd), ("bg/foo.bin", &payload_bg)]);
    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");

    let mut manifest = SqPackPathManifest::new();
    manifest.entries.insert(
        "exd/a.exl".into(),
        PathManifestEntry::new([0xAA; 32], u32::try_from(payload_exd.len()).unwrap()),
    );
    manifest.entries.insert(
        "bg/foo.bin".into(),
        PathManifestEntry::new([0xBB; 32], u32::try_from(payload_bg.len()).unwrap()),
    );

    let mut verifier = SqPackPathVerifier::new(&sq);
    let report = verifier.verify(&manifest);
    let grouped = report.by_category();
    assert_eq!(grouped.len(), 2);
    let exd = Category::try_from_name("exd").unwrap();
    let bg = Category::try_from_name("bg").unwrap();
    assert_eq!(grouped[&exd].len(), 1);
    assert_eq!(grouped[&bg].len(), 1);
}

#[cfg(feature = "serde")]
#[test]
fn manifest_serde_round_trips_via_json() {
    let mut manifest = SqPackPathManifest::new();
    manifest.entries.insert(
        "exd/root.exl".into(),
        PathManifestEntry::new([0x12; 32], 1024),
    );
    manifest.entries.insert(
        "bg/foo.bin".into(),
        PathManifestEntry::new([0x34; 32], 2048),
    );

    let serialised = serde_json::to_string(&manifest).expect("serialise");
    let parsed: SqPackPathManifest = serde_json::from_str(&serialised).expect("deserialise");
    assert_eq!(parsed, manifest);
}

#[cfg(feature = "serde")]
#[test]
fn manifest_serialise_order_is_deterministic() {
    let mut a = SqPackPathManifest::new();
    a.entries
        .insert("b".into(), PathManifestEntry::new([1u8; 32], 1));
    a.entries
        .insert("a".into(), PathManifestEntry::new([2u8; 32], 2));
    let mut b = SqPackPathManifest::new();
    b.entries
        .insert("a".into(), PathManifestEntry::new([2u8; 32], 2));
    b.entries
        .insert("b".into(), PathManifestEntry::new([1u8; 32], 1));

    assert_eq!(
        serde_json::to_string(&a).unwrap(),
        serde_json::to_string(&b).unwrap()
    );
}

#[test]
fn unknown_category_routes_through_read_error() {
    let payload = b"root-exl-content".to_vec();
    let (fs, expected_hashes) = build_install(&[("exd/root.exl", &payload)]);
    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");

    let mut manifest = SqPackPathManifest::new();
    manifest.entries.insert(
        "exd/root.exl".into(),
        PathManifestEntry::new(
            expected_hashes["exd/root.exl"],
            u32::try_from(payload.len()).unwrap(),
        ),
    );
    manifest.entries.insert(
        "notacat/foo.bin".into(),
        PathManifestEntry::new([0u8; 32], 0),
    );

    let mut verifier = SqPackPathVerifier::new(&sq);
    let report = verifier.verify(&manifest);

    assert_eq!(
        report.mismatches.len(),
        1,
        "expected exactly one mismatch (the unknown-category path)"
    );
    let m = &report.mismatches[0];
    assert_eq!(m.game_path, "notacat/foo.bin");

    assert_eq!(
        m.category.id(),
        0xFF,
        "unknown-category mismatch must carry the placeholder category (0xFF)"
    );

    match &m.kind {
        PathMismatchKind::ReadError { message, .. } => {
            assert!(
                message.contains("unknown SqPack category"),
                "ReadError message must contain 'unknown SqPack category', got: {message:?}"
            );
            assert!(
                message.contains("notacat"),
                "ReadError message must name the offending segment 'notacat', got: {message:?}"
            );
        }
        other => panic!("expected PathMismatchKind::ReadError for unknown category, got {other:?}"),
    }

    assert_eq!(report.verified_count, 1, "real entry should verify cleanly");
    assert_eq!(report.skipped_count, 0);
}

#[cfg(feature = "serde")]
#[test]
fn builder_insertion_order_does_not_affect_serialised_manifest() {
    let payload_a = b"first-payload-alpha".to_vec();
    let payload_b = b"second-payload-beta-longer".to_vec();
    let (fs, _) = build_install(&[("exd/alpha.exl", &payload_a), ("exd/beta.exh", &payload_b)]);
    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");

    let mut builder_a = SqPackPathManifestBuilder::new(&sq);
    builder_a.add("exd/alpha.exl").expect("add alpha");
    builder_a.add("exd/beta.exh").expect("add beta");
    let manifest_a = builder_a.finish();

    let mut builder_b = SqPackPathManifestBuilder::new(&sq);
    builder_b.add("exd/beta.exh").expect("add beta");
    builder_b.add("exd/alpha.exl").expect("add alpha");
    let manifest_b = builder_b.finish();

    assert_eq!(
        manifest_a, manifest_b,
        "manifests must be structurally equal"
    );

    assert_eq!(
        serde_json::to_string(&manifest_a).unwrap(),
        serde_json::to_string(&manifest_b).unwrap(),
        "serialised JSON must be byte-for-byte identical regardless of insertion order"
    );
}

#[test]
fn manifest_accessors_get_present_and_absent() {
    let mut manifest = SqPackPathManifest::new();
    assert!(manifest.is_empty());
    assert_eq!(manifest.len(), 0);
    assert!(manifest.get("exd/absent.exl").is_none());

    let entry = PathManifestEntry::new([0xAB; 32], 512);
    manifest.entries.insert("exd/present.exl".into(), entry);

    assert!(!manifest.is_empty());
    assert_eq!(manifest.len(), 1);

    let got = manifest
        .get("exd/present.exl")
        .expect("key should be present");
    assert_eq!(got.hash, [0xAB; 32]);
    assert_eq!(got.size, 512);

    assert!(manifest.get("exd/absent.exl").is_none());
}

#[test]
fn by_category_iteration_yields_ascending_wire_id_order() {
    let payload = b"exd-file-bytes".to_vec();
    let (fs, _) = build_install(&[("exd/item.exh", &payload)]);
    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");

    let common = Category::try_from_name("common").unwrap();
    let bg = Category::try_from_name("bg").unwrap();
    let exd = Category::try_from_name("exd").unwrap();
    let music = Category::try_from_name("music").unwrap();
    assert_eq!(common.id(), 0x00);
    assert_eq!(bg.id(), 0x02);
    assert_eq!(exd.id(), 0x0a);
    assert_eq!(music.id(), 0x0c);

    let mut manifest = SqPackPathManifest::new();
    manifest.entries.insert(
        "music/song.scd".into(),
        PathManifestEntry::new([0u8; 32], 0),
    );
    manifest.entries.insert(
        "exd/item.exh".into(),
        PathManifestEntry::new([0xFF; 32], u32::try_from(payload.len()).unwrap()),
    );
    manifest
        .entries
        .insert("bg/zone.lgb".into(), PathManifestEntry::new([0u8; 32], 0));
    manifest.entries.insert(
        "common/font.tex".into(),
        PathManifestEntry::new([0u8; 32], 0),
    );

    let mut verifier = SqPackPathVerifier::new(&sq);
    let report = verifier.verify(&manifest);

    assert_eq!(report.mismatches.len(), 4);
    let grouped = report.by_category();
    assert_eq!(grouped.len(), 4);

    let ids: Vec<u8> = grouped.keys().map(|c| c.id()).collect();
    let mut sorted_ids = ids.clone();
    sorted_ids.sort_unstable();
    assert_eq!(
        ids, sorted_ids,
        "by_category keys must be in ascending wire-id order"
    );
    assert_eq!(ids, vec![0x00, 0x02, 0x0a, 0x0c]);
}

#[test]
fn streaming_early_drop_leaves_verifier_reusable() {
    let p_a = b"entry-a-payload".to_vec();
    let p_b = b"entry-b-payload".to_vec();
    let p_c = b"entry-c-payload".to_vec();
    let (fs, _) = build_install(&[
        ("exd/a.exl", &p_a),
        ("exd/b.exl", &p_b),
        ("exd/c.exl", &p_c),
    ]);
    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");

    let mut manifest = SqPackPathManifest::new();
    let size_a = u32::try_from(p_a.len()).unwrap();
    let size_b = u32::try_from(p_b.len()).unwrap();
    let size_c = u32::try_from(p_c.len()).unwrap();
    manifest.entries.insert(
        "exd/a.exl".into(),
        PathManifestEntry::new([0xFF; 32], size_a),
    );
    manifest.entries.insert(
        "exd/b.exl".into(),
        PathManifestEntry::new([0xFF; 32], size_b),
    );
    manifest.entries.insert(
        "exd/c.exl".into(),
        PathManifestEntry::new([0xFF; 32], size_c),
    );

    let mut verifier = SqPackPathVerifier::new(&sq);

    {
        let mut stream1 = verifier.verify_streaming(&manifest);
        let first = stream1.next();
        assert!(first.is_some(), "should get at least one mismatch");
    }

    let mut stream2 = verifier.verify_streaming(&manifest);
    let all_events: Vec<_> = stream2.by_ref().collect();
    assert_eq!(
        all_events.len(),
        3,
        "second stream must sweep all 3 manifest entries from the start"
    );
    assert_eq!(stream2.verified_count(), 0);
    assert_eq!(stream2.skipped_count(), 0);
}

#[test]
fn streaming_partial_progress_counts_reflect_work_done_before_drop() {
    let p_good_a = b"clean-a-partial".to_vec();
    let p_good_b = b"clean-b-partial".to_vec();
    let p_bad = b"bad-partial".to_vec();
    let (fs, expected) = build_install(&[
        ("exd/aa.exl", &p_good_a),
        ("exd/bb.exl", &p_good_b),
        ("exd/cc.exl", &p_bad),
    ]);
    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");

    let mut manifest = SqPackPathManifest::new();
    manifest.entries.insert(
        "exd/aa.exl".into(),
        PathManifestEntry::new(
            expected["exd/aa.exl"],
            u32::try_from(p_good_a.len()).unwrap(),
        ),
    );
    manifest.entries.insert(
        "exd/bb.exl".into(),
        PathManifestEntry::new(
            expected["exd/bb.exl"],
            u32::try_from(p_good_b.len()).unwrap(),
        ),
    );
    manifest.entries.insert(
        "exd/cc.exl".into(),
        PathManifestEntry::new([0xFF; 32], u32::try_from(p_bad.len()).unwrap()),
    );

    let mut verifier = SqPackPathVerifier::new(&sq);
    let counts = {
        let mut stream = verifier.verify_streaming(&manifest);
        let ev = stream.next();
        assert!(matches!(ev, Some(PathMismatch { .. })));
        (stream.verified_count(), stream.skipped_count())
    };

    assert_eq!(
        counts.0, 2,
        "verified_count must be 2 after consuming one mismatch event"
    );
    assert_eq!(
        counts.1, 0,
        "skipped_count must be 0 (no unverifiable kinds)"
    );
}

#[test]
fn size_mismatch_takes_precedence_over_hash_mismatch() {
    let payload = b"some-sized-content".to_vec();
    let (fs, expected) = build_install(&[("exd/root.exl", &payload)]);
    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");

    let real_size = u32::try_from(payload.len()).unwrap();
    let wrong_size = real_size + 7;
    let wrong_hash = {
        let mut h = expected["exd/root.exl"];
        h[0] ^= 0xFF;
        h
    };
    assert_ne!(wrong_hash, expected["exd/root.exl"]);

    let mut manifest = SqPackPathManifest::new();
    manifest.entries.insert(
        "exd/root.exl".into(),
        PathManifestEntry::new(wrong_hash, wrong_size),
    );

    let mut verifier = SqPackPathVerifier::new(&sq);
    let report = verifier.verify(&manifest);

    assert_eq!(report.mismatches.len(), 1);
    match &report.mismatches[0].kind {
        PathMismatchKind::SizeMismatch {
            expected: exp,
            actual: act,
        } => {
            assert_eq!(*exp, wrong_size);
            assert_eq!(*act, real_size);
        }
        other => panic!("expected SizeMismatch (size checked before hash), got {other:?}"),
    }
}

#[test]
fn builder_skips_empty_kind_file() {
    let fs = InMemoryFs::new();
    let mut dat = DatBuilder::new();
    let empty_off = dat.append_empty_stub();
    let v1_key = hash_index1_key_for_path("exd/empty.exl");
    let entries = [(v1_key, data_field_for_offset(empty_off))];
    fs.insert(
        "/install/sqpack/ffxiv/0a0000.win32.index",
        make_index_file(&entries),
    );
    fs.insert("/install/sqpack/ffxiv/0a0000.win32.dat0", dat.into_bytes());

    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");
    let mut builder = SqPackPathManifestBuilder::new(&sq);
    let outcome = builder.add("exd/empty.exl").expect("add should not error");
    match outcome {
        PathManifestAddOutcome::Skipped { game_path, kind } => {
            assert_eq!(game_path, "exd/empty.exl");
            assert_eq!(kind, FileKind::Empty);
        }
        other => panic!("expected Skipped for Empty kind, got {other:?}"),
    }
    let manifest = builder.finish();
    assert!(manifest.get("exd/empty.exl").is_none());
    assert!(manifest.is_empty());
}

#[test]
fn empty_kind_in_install_surfaces_as_unverifiable_from_verifier() {
    let fs = InMemoryFs::new();
    let mut dat = DatBuilder::new();
    let empty_off = dat.append_empty_stub();
    let v1_key = hash_index1_key_for_path("exd/empty.exl");
    let entries = [(v1_key, data_field_for_offset(empty_off))];
    fs.insert(
        "/install/sqpack/ffxiv/0a0000.win32.index",
        make_index_file(&entries),
    );
    fs.insert("/install/sqpack/ffxiv/0a0000.win32.dat0", dat.into_bytes());

    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");

    let mut manifest = SqPackPathManifest::new();
    manifest
        .entries
        .insert("exd/empty.exl".into(), PathManifestEntry::new([0u8; 32], 0));

    let mut verifier = SqPackPathVerifier::new(&sq);
    let report = verifier.verify(&manifest);

    assert_eq!(report.skipped_count, 1);
    assert_eq!(report.verified_count, 0);
    assert_eq!(report.mismatches.len(), 1);
    match &report.mismatches[0].kind {
        PathMismatchKind::Unverifiable { kind } => assert_eq!(*kind, FileKind::Empty),
        other => panic!("expected Unverifiable for Empty kind, got {other:?}"),
    }
    assert!(!report.is_clean());
}

#[test]
fn verify_stream_against_empty_manifest_yields_nothing() {
    let (fs, _) = build_install(&[("exd/root.exl", b"x")]);
    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");
    let manifest = SqPackPathManifest::new();

    let mut verifier = SqPackPathVerifier::new(&sq);
    let mut stream = verifier.verify_streaming(&manifest);
    assert!(
        stream.next().is_none(),
        "next() on empty manifest must immediately return None"
    );
    assert_eq!(stream.verified_count(), 0);
    assert_eq!(stream.skipped_count(), 0);
}

#[test]
fn verify_empty_manifest_eager_is_clean() {
    let (fs, _) = build_install(&[("exd/root.exl", b"y")]);
    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");
    let manifest = SqPackPathManifest::new();

    let mut verifier = SqPackPathVerifier::new(&sq);
    let report = verifier.verify(&manifest);

    assert!(report.is_clean(), "empty manifest must be clean");
    assert_eq!(report.total(), 0);
    assert_eq!(report.verified_count, 0);
    assert_eq!(report.skipped_count, 0);
    assert!(report.mismatches.is_empty());
    assert!(!report.cancelled);
}

fn build_multi_entry_install(count: usize) -> (InMemoryFs, SqPackPathManifest) {
    let mut pairs: Vec<(String, Vec<u8>)> = Vec::with_capacity(count);
    for i in 0..count {
        pairs.push((
            format!("exd/file{i:02}.dat"),
            format!("payload-{i:02}").into_bytes(),
        ));
    }
    let borrowed: Vec<(&str, &[u8])> = pairs
        .iter()
        .map(|(p, b)| (p.as_str(), b.as_slice()))
        .collect();
    let (fs, _expected) = build_install(&borrowed);

    let sq = SqPack::open_with_vfs("/install", Arc::new(fs.clone())).expect("open for manifest");
    let mut builder = SqPackPathManifestBuilder::new(&sq);
    for (p, _) in &pairs {
        builder.add(p).expect("add");
    }
    let manifest = builder.finish();
    (fs, manifest)
}

#[test]
fn verify_cancel_token_pre_flipped_returns_empty_cancelled_report() {
    let (fs, manifest) = build_multi_entry_install(4);
    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");

    let token = sqpack_rs::CancelToken::new();
    token.cancel();

    let mut verifier = SqPackPathVerifier::new(&sq).with_cancel_token(token);
    let report = verifier.verify(&manifest);

    assert!(report.cancelled, "pre-flipped token must mark cancelled");
    assert_eq!(report.verified_count, 0);
    assert_eq!(report.skipped_count, 0);
    assert!(report.mismatches.is_empty());
}

struct CancelAfterFirstObserver {
    token: sqpack_rs::CancelToken,
}

impl PathVerifyObserver for CancelAfterFirstObserver {
    fn on_file_verified(&mut self, _game_path: &str, _bytes_hashed: u32) {
        self.token.cancel();
    }
}

#[test]
fn verify_cancel_token_mid_scan_returns_partial_report() {
    let (fs, manifest) = build_multi_entry_install(6);
    let total = manifest.len();
    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");

    let token = sqpack_rs::CancelToken::new();
    let observer = CancelAfterFirstObserver {
        token: token.clone(),
    };
    let mut verifier = SqPackPathVerifier::new(&sq)
        .with_cancel_token(token)
        .with_observer(observer);
    let report = verifier.verify(&manifest);

    assert!(
        report.cancelled,
        "token flipped mid-scan must set cancelled"
    );
    assert_eq!(
        report.verified_count, 1,
        "exactly one entry should complete before cancellation"
    );
    assert!(
        report.verified_count < total,
        "verified_count must be strictly less than the manifest size",
    );
    assert!(report.mismatches.is_empty());
}

#[test]
fn verify_streaming_stops_yielding_when_cancelled() {
    let (fs, manifest) = build_multi_entry_install(5);
    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");

    let token = sqpack_rs::CancelToken::new();
    let mut verifier = SqPackPathVerifier::new(&sq).with_cancel_token(token.clone());
    let mut stream = verifier.verify_streaming(&manifest);

    token.cancel();
    assert!(stream.next().is_none(), "cancelled stream must yield None");
    assert!(stream.cancelled(), "stream.cancelled() must be true");

    assert_eq!(stream.verified_count(), 0);
    assert_eq!(stream.skipped_count(), 0);

    assert!(stream.next().is_none());
    assert!(stream.next().is_none());
}

struct CancelAfterTwoObserver {
    seen: usize,
}

impl PathVerifyObserver for CancelAfterTwoObserver {
    fn on_file_verified(&mut self, _game_path: &str, _bytes_hashed: u32) {
        self.seen += 1;
    }
    fn should_cancel(&mut self) -> bool {
        self.seen >= 2
    }
}

#[test]
fn verify_observer_should_cancel_drives_partial_report() {
    let (fs, manifest) = build_multi_entry_install(4);
    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");

    let observer = CancelAfterTwoObserver { seen: 0 };
    let mut verifier = SqPackPathVerifier::new(&sq).with_observer(observer);
    let report = verifier.verify(&manifest);

    assert!(
        report.cancelled,
        "observer-driven cancel must set cancelled"
    );
    assert_eq!(
        report.verified_count, 2,
        "exactly two entries should complete before cancellation"
    );
}

struct ObserverProbe<'a> {
    verified: &'a std::sync::atomic::AtomicUsize,
    mismatches: &'a std::sync::atomic::AtomicUsize,
    skipped: &'a std::sync::atomic::AtomicUsize,
}

impl PathVerifyObserver for ObserverProbe<'_> {
    fn on_file_verified(&mut self, _game_path: &str, _bytes_hashed: u32) {
        self.verified
            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
    }
    fn on_mismatch(&mut self, _m: &sqpack_rs::verify::PathMismatch) {
        self.mismatches
            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
    }
    fn on_skipped(&mut self, _game_path: &str, _kind: sqpack_rs::FileKind) {
        self.skipped
            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
    }
}

#[test]
#[allow(clippy::too_many_lines, clippy::similar_names)]
fn verify_observer_callbacks_fire_with_expected_counts() {
    let fs = InMemoryFs::new();
    let mut dat = DatBuilder::new();

    let off_clean1 = dat.append_standard(b"clean-1");
    let off_clean2 = dat.append_standard(b"clean-2");
    let off_mismatch = dat.append_standard(b"original-bytes");
    let off_empty = dat.append_empty_stub();
    let entries = [
        (
            hash_index1_key_for_path("exd/clean1.dat"),
            data_field_for_offset(off_clean1),
        ),
        (
            hash_index1_key_for_path("exd/clean2.dat"),
            data_field_for_offset(off_clean2),
        ),
        (
            hash_index1_key_for_path("exd/mismatch.dat"),
            data_field_for_offset(off_mismatch),
        ),
        (
            hash_index1_key_for_path("exd/empty.dat"),
            data_field_for_offset(off_empty),
        ),
    ];
    let mut sorted: Vec<_> = entries.to_vec();
    sorted.sort_by_key(|&(h, _)| h);
    fs.insert(
        "/install/sqpack/ffxiv/0a0000.win32.index",
        make_index_file(&sorted),
    );
    fs.insert("/install/sqpack/ffxiv/0a0000.win32.dat0", dat.into_bytes());

    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");
    let mut builder = SqPackPathManifestBuilder::new(&sq);
    builder.add("exd/clean1.dat").expect("add clean1");
    builder.add("exd/clean2.dat").expect("add clean2");
    builder.add("exd/mismatch.dat").expect("add mismatch");
    let mut manifest = builder.finish();
    manifest.entries.insert(
        "exd/empty.dat".to_string(),
        PathManifestEntry::new([0u8; 32], 0),
    );
    manifest
        .entries
        .get_mut("exd/mismatch.dat")
        .expect("manifest contains mismatch entry")
        .hash = [0xFFu8; 32];

    let verified = std::sync::atomic::AtomicUsize::new(0);
    let mismatches = std::sync::atomic::AtomicUsize::new(0);
    let skipped = std::sync::atomic::AtomicUsize::new(0);
    {
        let probe = ObserverProbe {
            verified: &verified,
            mismatches: &mismatches,
            skipped: &skipped,
        };
        let mut verifier = SqPackPathVerifier::new(&sq).with_observer(probe);
        let report = verifier.verify(&manifest);
        assert_eq!(report.verified_count, 2, "two clean Standard verifies");
        assert_eq!(report.skipped_count, 1, "Empty entry skipped");
        assert_eq!(
            report.mismatches.len(),
            2,
            "one HashMismatch + one Unverifiable",
        );
        assert!(!report.cancelled);
    }
    assert_eq!(verified.load(std::sync::atomic::Ordering::Relaxed), 2);
    assert_eq!(mismatches.load(std::sync::atomic::Ordering::Relaxed), 2);
    assert_eq!(skipped.load(std::sync::atomic::Ordering::Relaxed), 1);
}

struct OnlySizeObserver {
    bytes: u64,
}

impl PathVerifyObserver for OnlySizeObserver {
    fn on_file_verified(&mut self, _game_path: &str, bytes_hashed: u32) {
        self.bytes += u64::from(bytes_hashed);
    }
}

#[test]
fn verify_observer_default_impls_are_inert() {
    let (fs, manifest) = build_multi_entry_install(3);
    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");

    let observer = OnlySizeObserver { bytes: 0 };
    let mut verifier = SqPackPathVerifier::new(&sq).with_observer(observer);
    let report = verifier.verify(&manifest);
    assert!(report.is_clean());
    assert_eq!(report.verified_count, 3);
    assert!(!report.cancelled);
}

#[test]
fn cancel_token_clone_is_visible_to_verifier() {
    let (fs, manifest) = build_multi_entry_install(3);
    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");

    let token = sqpack_rs::CancelToken::new();
    let bg = token.clone();
    let mut verifier = SqPackPathVerifier::new(&sq).with_cancel_token(token);

    bg.cancel();
    let report = verifier.verify(&manifest);
    assert!(
        report.cancelled,
        "cancel via a clone must be visible to the verifier"
    );
    assert_eq!(report.verified_count, 0);
}

#[test]
fn cancelled_report_is_never_clean_even_without_mismatches() {
    let payload = b"regression-pin-payload".to_vec();
    let (fs, expected_hashes) = build_install(&[("exd/root.exl", &payload)]);
    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");

    let mut manifest = SqPackPathManifest::new();
    manifest.entries.insert(
        "exd/root.exl".into(),
        PathManifestEntry::new(
            expected_hashes["exd/root.exl"],
            u32::try_from(payload.len()).unwrap(),
        ),
    );

    let token = sqpack_rs::CancelToken::new();
    token.cancel();

    let mut verifier = SqPackPathVerifier::new(&sq).with_cancel_token(token);
    let report = verifier.verify(&manifest);

    assert!(report.cancelled, "pre-flipped token must mark cancelled");
    assert!(
        report.mismatches.is_empty(),
        "no mismatches: no work was done before cancellation"
    );
    assert_eq!(report.verified_count, 0);
    assert_eq!(report.skipped_count, 0);
    assert_eq!(report.total(), 0);

    assert!(
        !report.is_clean(),
        "a cancelled report must never be clean even when mismatches is empty"
    );
}

struct AlwaysCancelObserver {
    polls: Arc<std::sync::Mutex<usize>>,
}

impl PathVerifyObserver for AlwaysCancelObserver {
    fn should_cancel(&mut self) -> bool {
        *self.polls.lock().unwrap() += 1;
        true
    }
}

#[test]
fn observer_should_cancel_true_before_any_entry_yields_empty_cancelled_report() {
    let payload = b"observer-cancel-pin-payload".to_vec();
    let (fs, expected_hashes) = build_install(&[("exd/root.exl", &payload)]);
    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");

    let mut manifest = SqPackPathManifest::new();
    manifest.entries.insert(
        "exd/root.exl".into(),
        PathManifestEntry::new(
            expected_hashes["exd/root.exl"],
            u32::try_from(payload.len()).unwrap(),
        ),
    );

    let polls = Arc::new(std::sync::Mutex::new(0usize));
    let observer = AlwaysCancelObserver {
        polls: Arc::clone(&polls),
    };

    let mut verifier = SqPackPathVerifier::new(&sq).with_observer(observer);
    let report = verifier.verify(&manifest);

    assert!(
        report.cancelled,
        "observer should_cancel=true must mark cancelled"
    );
    assert!(
        report.mismatches.is_empty(),
        "no work done before cancellation — mismatches must be empty"
    );
    assert_eq!(report.verified_count, 0);
    assert_eq!(report.skipped_count, 0);
    assert_eq!(report.total(), 0);
    assert!(
        !report.is_clean(),
        "a cancelled report must never be clean even when mismatches is empty"
    );

    let poll_count = *polls.lock().unwrap();
    assert_eq!(
        poll_count, 1,
        "should_cancel must be polled exactly once (at the first entry boundary)"
    );
}

fn hash_index2_for_path(path: &str) -> u32 {
    sqpack_rs::hash::hash_index2(path)
}

fn make_index2_file(entries: &[(u32, 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() * INDEX2_ENTRY_SIZE;
    buf.extend_from_slice(&make_index_header(index_data_offset, index_data_size));
    for (hash, data) in entries {
        buf.extend_from_slice(&hash.to_le_bytes());
        buf.extend_from_slice(&data.to_le_bytes());
    }
    buf
}

fn exd_triple() -> ArchiveTriple {
    ArchiveTriple::from_game_path("exd/anything").expect("exd routes")
}

#[test]
fn hash_builder_walks_install_and_records_entries() {
    let payload_a = b"hash-build-a".to_vec();
    let payload_b = b"hash-build-b-longer".to_vec();
    let payload_c = b"hash-build-c-different".to_vec();
    let (fs, expected) = build_install(&[
        ("exd/a.exl", &payload_a),
        ("exd/b.exh", &payload_b),
        ("exd/c.exh", &payload_c),
    ]);
    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");

    let mut builder = SqPackHashManifestBuilder::new(&sq);
    let report = builder.build().expect("build");

    assert!(!report.cancelled);
    assert_eq!(report.verified_count, 3);
    assert_eq!(report.skipped_count, 0);
    assert!(report.issues.is_empty());
    assert_eq!(report.manifest.len(), 3);

    for key in report.manifest.entries.keys() {
        match key {
            IndexHashKey::V1 { triple, .. } => assert_eq!(*triple, exd_triple()),
            other => panic!("expected V1 key, got {other:?}"),
        }
    }

    let v1_key = sqpack_rs::hash::hash_index1_key("exd/a.exl");
    let key = IndexHashKey::V1 {
        triple: exd_triple(),
        hash: v1_key,
    };
    let entry = report.manifest.get(&key).expect("entry present");
    assert_eq!(entry.hash, expected["exd/a.exl"]);
    assert_eq!(entry.size, u32::try_from(payload_a.len()).unwrap());
    assert!(entry.path.is_none(), "walker never sets path annotation");
}

#[test]
fn hash_builder_hashes_model_kind_into_manifest() {
    let fs = InMemoryFs::new();
    let mut dat = DatBuilder::new();
    let model_payload = b"hash-keyed model bytes".to_vec();
    let model_off = dat.build_model_payload(&model_payload);
    let v1_key = hash_index1_key_for_path("bgcommon/foo.mdl");
    let entries = [(v1_key, data_field_for_offset(model_off))];
    fs.insert(
        "/install/sqpack/ffxiv/010000.win32.index",
        make_index_file(&entries),
    );
    fs.insert("/install/sqpack/ffxiv/010000.win32.dat0", dat.into_bytes());

    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");
    let mut builder = SqPackHashManifestBuilder::new(&sq);
    let report = builder.build().expect("build");

    assert_eq!(report.verified_count, 1);
    assert_eq!(report.skipped_count, 0);
    assert_eq!(report.manifest.len(), 1);
    assert!(report.issues.is_empty());

    let mut h = Sha256::new();
    h.update(&model_payload);
    let expected_hash: [u8; 32] = h.finalize().into();
    let triple = ArchiveTriple::from_game_path("bgcommon/anything").expect("bgcommon routes");
    let key = IndexHashKey::V1 {
        triple,
        hash: v1_key,
    };
    let entry = report.manifest.get(&key).expect("entry present");
    assert_eq!(entry.hash, expected_hash);
    assert_eq!(entry.size, u32::try_from(model_payload.len()).unwrap());
}

#[test]
fn hash_builder_skips_empty_kind_as_build_issue() {
    let fs = InMemoryFs::new();
    let mut dat = DatBuilder::new();
    let empty_off = dat.append_empty_stub();
    let v1_key = hash_index1_key_for_path("exd/empty.exl");
    let entries = [(v1_key, data_field_for_offset(empty_off))];
    fs.insert(
        "/install/sqpack/ffxiv/0a0000.win32.index",
        make_index_file(&entries),
    );
    fs.insert("/install/sqpack/ffxiv/0a0000.win32.dat0", dat.into_bytes());

    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");
    let mut builder = SqPackHashManifestBuilder::new(&sq);
    let report = builder.build().expect("build");

    assert_eq!(report.verified_count, 0);
    assert_eq!(report.skipped_count, 1);
    assert!(report.manifest.is_empty());
    match &report.issues[0].kind {
        HashManifestBuildIssueKind::Unverifiable { kind } => assert_eq!(*kind, FileKind::Empty),
        other => panic!("expected Unverifiable Empty, got {other:?}"),
    }
}

#[test]
fn hash_builder_v2_archive_yields_v2_keys() {
    let fs = InMemoryFs::new();
    let mut dat = DatBuilder::new();
    let payload_a = b"v2-payload-a".to_vec();
    let payload_b = b"v2-payload-b-different".to_vec();
    let off_a = dat.append_standard(&payload_a);
    let off_b = dat.append_standard(&payload_b);
    let entries = [
        (
            hash_index2_for_path("exd/a.exl"),
            data_field_for_offset(off_a),
        ),
        (
            hash_index2_for_path("exd/b.exh"),
            data_field_for_offset(off_b),
        ),
    ];
    let mut sorted: Vec<_> = entries.to_vec();
    sorted.sort_by_key(|&(h, _)| h);
    fs.insert(
        "/install/sqpack/ffxiv/0a0000.win32.index2",
        make_index2_file(&sorted),
    );
    fs.insert("/install/sqpack/ffxiv/0a0000.win32.dat0", dat.into_bytes());

    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");
    let mut builder = SqPackHashManifestBuilder::new(&sq);
    let report = builder.build().expect("build");

    assert_eq!(report.verified_count, 2);
    for key in report.manifest.entries.keys() {
        match key {
            IndexHashKey::V2 { triple, .. } => assert_eq!(*triple, exd_triple()),
            other => panic!("expected V2 key, got {other:?}"),
        }
    }
}

#[test]
fn hash_verifier_clean_round_trip_is_clean() {
    let payload = b"clean-round-trip".to_vec();
    let (fs, _) = build_install(&[("exd/root.exl", &payload)]);
    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");

    let mut builder = SqPackHashManifestBuilder::new(&sq);
    let build_report = builder.build().expect("build");
    assert_eq!(build_report.verified_count, 1);

    let mut verifier = SqPackHashVerifier::new(&sq);
    let report = verifier.verify(&build_report.manifest);
    assert!(report.is_clean(), "clean round-trip must be clean");
    assert_eq!(report.verified_count, 1);
    assert_eq!(report.skipped_count, 0);
    assert!(report.mismatches.is_empty());
    assert!(report.extras.is_empty());
}

#[test]
fn hash_verifier_detects_hash_mismatch() {
    let payload = b"tamper-target".to_vec();
    let (fs, _) = build_install(&[("exd/root.exl", &payload)]);
    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");

    let mut builder = SqPackHashManifestBuilder::new(&sq);
    let mut manifest = builder.build().expect("build").manifest;

    let key = *manifest.entries.keys().next().expect("one entry");
    manifest.entries.get_mut(&key).unwrap().hash[0] ^= 0xFF;

    let mut verifier = SqPackHashVerifier::new(&sq);
    let report = verifier.verify(&manifest);
    assert_eq!(report.mismatches.len(), 1);
    match &report.mismatches[0].kind {
        PathMismatchKind::HashMismatch { expected, actual } => assert_ne!(expected, actual),
        other => panic!("expected HashMismatch, got {other:?}"),
    }
    assert!(!report.is_clean());
}

#[test]
fn hash_verifier_detects_missing_entry() {
    let payload = b"present-entry".to_vec();
    let (fs, _) = build_install(&[("exd/present.exl", &payload)]);
    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");

    let mut builder = SqPackHashManifestBuilder::new(&sq);
    let mut manifest = builder.build().expect("build").manifest;

    let phantom_key = IndexHashKey::V1 {
        triple: exd_triple(),
        hash: 0x0DEA_DBEE_FBAD_F00D_u64,
    };
    manifest
        .entries
        .insert(phantom_key, HashManifestEntry::new([0u8; 32], 0));

    let mut verifier = SqPackHashVerifier::new(&sq);
    let report = verifier.verify(&manifest);

    assert_eq!(report.verified_count, 1);
    assert_eq!(report.mismatches.len(), 1);
    let m = &report.mismatches[0];
    assert_eq!(m.key, phantom_key);
    assert!(matches!(m.kind, PathMismatchKind::Missing));
    assert!(report.extras.is_empty());
}

#[test]
fn hash_verifier_detects_extras_in_install() {
    let payload_a = b"baseline-a".to_vec();
    let (fs_baseline, _) = build_install(&[("exd/a.exl", &payload_a)]);
    let sq_baseline =
        SqPack::open_with_vfs("/install", Arc::new(fs_baseline)).expect("open baseline");
    let mut builder = SqPackHashManifestBuilder::new(&sq_baseline);
    let manifest = builder.build().expect("build").manifest;
    assert_eq!(manifest.len(), 1);

    let payload_b = b"post-baseline-add".to_vec();
    let (fs_extended, _) = build_install(&[("exd/a.exl", &payload_a), ("exd/b.exh", &payload_b)]);
    let sq_extended =
        SqPack::open_with_vfs("/install", Arc::new(fs_extended)).expect("open extended");

    let mut verifier = SqPackHashVerifier::new(&sq_extended);
    let report = verifier.verify(&manifest);

    assert_eq!(
        report.verified_count, 1,
        "baseline entry still hashes clean"
    );
    assert!(
        report.mismatches.is_empty(),
        "no mismatches — the baseline entry is intact"
    );
    assert_eq!(report.extras.len(), 1, "the new entry must be an extra");
    assert!(!report.is_clean(), "extras must make the report not-clean");

    let expected_key = IndexHashKey::V1 {
        triple: exd_triple(),
        hash: sqpack_rs::hash::hash_index1_key("exd/b.exh"),
    };
    assert_eq!(report.extras[0], expected_key);
}

#[test]
fn hash_verifier_detects_size_mismatch_before_hashing() {
    let payload = b"size-mismatch-target".to_vec();
    let (fs, _) = build_install(&[("exd/root.exl", &payload)]);
    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");

    let mut builder = SqPackHashManifestBuilder::new(&sq);
    let mut manifest = builder.build().expect("build").manifest;
    let key = *manifest.entries.keys().next().unwrap();
    let original_size = manifest.entries[&key].size;
    manifest.entries.get_mut(&key).unwrap().size = original_size + 99;
    manifest.entries.get_mut(&key).unwrap().hash = [0xAB; 32];

    let mut verifier = SqPackHashVerifier::new(&sq);
    let report = verifier.verify(&manifest);
    assert_eq!(report.mismatches.len(), 1);
    match &report.mismatches[0].kind {
        PathMismatchKind::SizeMismatch { expected, actual } => {
            assert_eq!(*expected, original_size + 99);
            assert_eq!(*actual, original_size);
        }
        other => panic!("expected SizeMismatch (size precedes hash), got {other:?}"),
    }
}

#[test]
fn hash_verify_report_by_category_groups_correctly() {
    let payload_exd = b"exd-cat".to_vec();
    let payload_bg = b"bg-cat".to_vec();

    let fs = InMemoryFs::new();
    let mut dat_exd = DatBuilder::new();
    let off_exd = dat_exd.append_standard(&payload_exd);
    let v1_exd = (
        hash_index1_key_for_path("exd/a.exl"),
        data_field_for_offset(off_exd),
    );
    fs.insert(
        "/install/sqpack/ffxiv/0a0000.win32.index",
        make_index_file(&[v1_exd]),
    );
    fs.insert(
        "/install/sqpack/ffxiv/0a0000.win32.dat0",
        dat_exd.into_bytes(),
    );

    let mut dat_bg = DatBuilder::new();
    let off_bg = dat_bg.append_standard(&payload_bg);
    let v1_bg = (
        hash_index1_key_for_path("bg/foo.bin"),
        data_field_for_offset(off_bg),
    );
    fs.insert(
        "/install/sqpack/ffxiv/020000.win32.index",
        make_index_file(&[v1_bg]),
    );
    fs.insert(
        "/install/sqpack/ffxiv/020000.win32.dat0",
        dat_bg.into_bytes(),
    );

    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");

    let mut builder = SqPackHashManifestBuilder::new(&sq);
    let mut manifest = builder.build().expect("build").manifest;
    assert_eq!(manifest.len(), 2, "one entry per archive");
    for entry in manifest.entries.values_mut() {
        entry.hash = [0xFF; 32];
    }

    let mut verifier = SqPackHashVerifier::new(&sq);
    let report = verifier.verify(&manifest);
    assert_eq!(report.mismatches.len(), 2);
    let grouped = report.by_category();
    assert_eq!(grouped.len(), 2);
    let exd = Category::try_from_name("exd").unwrap();
    let bg = Category::try_from_name("bg").unwrap();
    assert_eq!(grouped[&exd].len(), 1);
    assert_eq!(grouped[&bg].len(), 1);
    let keys: Vec<u8> = grouped.keys().map(|c| c.id()).collect();
    assert_eq!(keys, vec![0x02, 0x0a]);
}

#[test]
fn hash_builder_pre_flipped_cancel_token_yields_empty_cancelled_report() {
    let payload = b"cancel-pre".to_vec();
    let (fs, _) = build_install(&[("exd/root.exl", &payload)]);
    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");

    let token = sqpack_rs::CancelToken::new();
    token.cancel();

    let mut builder = SqPackHashManifestBuilder::new(&sq).with_cancel_token(token);
    let report = builder.build().expect("build");
    assert!(report.cancelled);
    assert_eq!(report.verified_count, 0);
    assert_eq!(report.skipped_count, 0);
    assert!(report.manifest.is_empty());
    assert!(report.issues.is_empty());
}

#[test]
fn hash_verifier_pre_flipped_cancel_token_yields_empty_cancelled_report() {
    let payload = b"verify-cancel-pre".to_vec();
    let (fs, _) = build_install(&[("exd/root.exl", &payload)]);
    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");
    let mut builder = SqPackHashManifestBuilder::new(&sq);
    let manifest = builder.build().expect("build").manifest;

    let token = sqpack_rs::CancelToken::new();
    token.cancel();

    let mut verifier = SqPackHashVerifier::new(&sq).with_cancel_token(token);
    let report = verifier.verify(&manifest);
    assert!(report.cancelled);
    assert_eq!(report.verified_count, 0);
    assert_eq!(report.skipped_count, 0);
    assert!(
        report.mismatches.is_empty(),
        "missing-reconciliation skipped on cancel"
    );
    assert!(report.extras.is_empty());
    assert!(!report.is_clean());
}

struct HashedCounter {
    hashed: std::sync::atomic::AtomicUsize,
}

impl HashScanObserver for &HashedCounter {
    fn on_entry_hashed(&mut self, _key: &IndexHashKey, _bytes: u32) {
        self.hashed
            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
    }
}

#[test]
fn hash_builder_observer_fires_on_hashed() {
    let payload_a = b"observer-a".to_vec();
    let payload_b = b"observer-b-longer".to_vec();
    let (fs, _) = build_install(&[("exd/a.exl", &payload_a), ("exd/b.exh", &payload_b)]);
    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");

    let counter = HashedCounter {
        hashed: std::sync::atomic::AtomicUsize::new(0),
    };
    {
        let mut builder = SqPackHashManifestBuilder::new(&sq).with_observer(&counter);
        let report = builder.build().expect("build");
        assert_eq!(report.verified_count, 2);
    }
    assert_eq!(
        counter.hashed.load(std::sync::atomic::Ordering::Relaxed),
        2,
        "on_entry_hashed fires once per Standard entry"
    );
}

struct IssueCounter {
    issues: std::sync::atomic::AtomicUsize,
}

impl HashScanObserver for &IssueCounter {
    fn on_entry_build_issue(&mut self, _issue: &sqpack_rs::verify::HashManifestBuildIssue) {
        self.issues
            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
    }
}

#[test]
fn hash_builder_observer_fires_on_build_issue() {
    let fs = InMemoryFs::new();
    let mut dat = DatBuilder::new();
    let empty_off = dat.append_empty_stub();
    let v1_key = hash_index1_key_for_path("exd/empty.exl");
    let entries = [(v1_key, data_field_for_offset(empty_off))];
    fs.insert(
        "/install/sqpack/ffxiv/0a0000.win32.index",
        make_index_file(&entries),
    );
    fs.insert("/install/sqpack/ffxiv/0a0000.win32.dat0", dat.into_bytes());

    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");

    let counter = IssueCounter {
        issues: std::sync::atomic::AtomicUsize::new(0),
    };
    {
        let mut builder = SqPackHashManifestBuilder::new(&sq).with_observer(&counter);
        let report = builder.build().expect("build");
        assert_eq!(report.skipped_count, 1);
    }
    assert_eq!(
        counter.issues.load(std::sync::atomic::Ordering::Relaxed),
        1,
        "on_entry_build_issue fires once per skipped entry"
    );
}

struct ExtraCounter {
    extras: std::sync::atomic::AtomicUsize,
}

impl HashScanObserver for &ExtraCounter {
    fn on_entry_extra(&mut self, _key: &IndexHashKey) {
        self.extras
            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
    }
}

#[test]
fn hash_verifier_observer_fires_on_extra() {
    let payload_a = b"extras-baseline".to_vec();
    let (fs_baseline, _) = build_install(&[("exd/a.exl", &payload_a)]);
    let sq_baseline =
        SqPack::open_with_vfs("/install", Arc::new(fs_baseline)).expect("open baseline");
    let manifest = SqPackHashManifestBuilder::new(&sq_baseline)
        .build()
        .expect("build")
        .manifest;

    let payload_b = b"extras-new".to_vec();
    let (fs_extended, _) = build_install(&[("exd/a.exl", &payload_a), ("exd/b.exh", &payload_b)]);
    let sq_extended =
        SqPack::open_with_vfs("/install", Arc::new(fs_extended)).expect("open extended");

    let counter = ExtraCounter {
        extras: std::sync::atomic::AtomicUsize::new(0),
    };
    {
        let mut verifier = SqPackHashVerifier::new(&sq_extended).with_observer(&counter);
        let report = verifier.verify(&manifest);
        assert_eq!(report.extras.len(), 1);
    }
    assert_eq!(
        counter.extras.load(std::sync::atomic::Ordering::Relaxed),
        1,
        "on_entry_extra fires once per extra entry"
    );
}

#[test]
fn hash_manifest_entry_path_annotation_round_trips() {
    let payload = b"path-annotation-target".to_vec();
    let (fs, _) = build_install(&[("exd/annotated.exl", &payload)]);
    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");
    let mut manifest = SqPackHashManifestBuilder::new(&sq)
        .build()
        .expect("build")
        .manifest;

    let key = *manifest.entries.keys().next().unwrap();
    let entry = manifest.entries.get_mut(&key).unwrap();
    entry.hash[0] ^= 0xFF;
    entry.path = Some("exd/annotated.exl".to_string());

    let mut verifier = SqPackHashVerifier::new(&sq);
    let report = verifier.verify(&manifest);
    assert_eq!(report.mismatches.len(), 1);
    let m = &report.mismatches[0];
    assert_eq!(m.path.as_deref(), Some("exd/annotated.exl"));
}

#[cfg(feature = "serde")]
#[test]
fn hash_manifest_serde_round_trips_via_json() {
    let mut manifest = SqPackHashManifest::new();
    let key1 = IndexHashKey::V1 {
        triple: exd_triple(),
        hash: 0x0DEA_DBEE_FCAF_EBAB_u64,
    };
    let key2 = IndexHashKey::V2 {
        triple: exd_triple(),
        hash: 0xCAFE_BABE,
    };
    manifest
        .entries
        .insert(key1, HashManifestEntry::new([0x12; 32], 1024));
    manifest.entries.insert(
        key2,
        HashManifestEntry::new([0x34; 32], 2048).with_path("exd/foo.exh"),
    );

    let json = serde_json::to_string(&manifest).expect("serialise");
    let parsed: SqPackHashManifest = serde_json::from_str(&json).expect("deserialise");
    assert_eq!(parsed, manifest);
}

#[cfg(feature = "serde")]
#[test]
fn hash_manifest_serde_serialise_order_is_deterministic() {
    let key1 = IndexHashKey::V1 {
        triple: exd_triple(),
        hash: 0x0DEA_DBEE_F000_0001_u64,
    };
    let key2 = IndexHashKey::V1 {
        triple: exd_triple(),
        hash: 0x0DEA_DBEE_F000_0002_u64,
    };

    let mut a = SqPackHashManifest::new();
    a.entries.insert(key1, HashManifestEntry::new([1u8; 32], 1));
    a.entries.insert(key2, HashManifestEntry::new([2u8; 32], 2));

    let mut b = SqPackHashManifest::new();
    b.entries.insert(key2, HashManifestEntry::new([2u8; 32], 2));
    b.entries.insert(key1, HashManifestEntry::new([1u8; 32], 1));

    assert_eq!(
        serde_json::to_string(&a).unwrap(),
        serde_json::to_string(&b).unwrap(),
        "BTreeMap + sequence serde must round-trip byte-for-byte"
    );
}

const SYN_ENTRY_SIZE: u32 = 256;

fn make_index_file_with_synonyms(
    standard_entries: &[(u64, u32)],
    synonyms: &[(u64, u32, u32, &str)],
) -> Vec<u8> {
    let index_data_offset = SQPACK_HEADER_SIZE + 1024;
    let index_data_size = u32::try_from(standard_entries.len()).unwrap() * INDEX_ENTRY_SIZE;
    let synonym_data_offset = index_data_offset + index_data_size;
    let synonym_records = u32::try_from(synonyms.len()).unwrap() + 1;
    let synonym_data_size = synonym_records * SYN_ENTRY_SIZE;

    let mut buf = Vec::new();
    buf.extend_from_slice(&make_sqpack_header(2));
    let mut hdr = Vec::with_capacity(1024);
    hdr.extend_from_slice(&1024u32.to_le_bytes());
    hdr.extend_from_slice(&1u32.to_le_bytes());
    hdr.extend_from_slice(&index_data_offset.to_le_bytes());
    hdr.extend_from_slice(&index_data_size.to_le_bytes());
    hdr.resize(hdr.len() + 64, 0);
    hdr.extend_from_slice(&1u32.to_le_bytes());
    hdr.extend_from_slice(&synonym_data_offset.to_le_bytes());
    hdr.extend_from_slice(&synonym_data_size.to_le_bytes());
    hdr.resize(hdr.len() + 64, 0);
    hdr.extend_from_slice(&0u32.to_le_bytes());
    hdr.extend_from_slice(&0u32.to_le_bytes());
    hdr.resize(hdr.len() + 64, 0);
    hdr.extend_from_slice(&0u32.to_le_bytes());
    hdr.extend_from_slice(&0u32.to_le_bytes());
    hdr.resize(hdr.len() + 64, 0);
    hdr.extend_from_slice(&0u32.to_le_bytes());
    hdr.resize(hdr.len() + 656, 0);
    hdr.resize(hdr.len() + 64, 0);
    assert_eq!(hdr.len(), 1024);
    buf.extend_from_slice(&hdr);

    for (hash, data) in standard_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());
    }

    for (hash, data, conflict_index, path) in synonyms {
        let mut entry = [0u8; 256];
        entry[0..8].copy_from_slice(&hash.to_le_bytes());
        entry[8..12].copy_from_slice(&data.to_le_bytes());
        entry[12..16].copy_from_slice(&conflict_index.to_le_bytes());
        let path_bytes = path.as_bytes();
        let n = path_bytes.len().min(240);
        entry[16..16 + n].copy_from_slice(&path_bytes[..n]);
        buf.extend_from_slice(&entry);
    }
    let mut sentinel = [0u8; 256];
    sentinel[0..8].copy_from_slice(&u64::MAX.to_le_bytes());
    sentinel[12..16].copy_from_slice(&u32::MAX.to_le_bytes());
    buf.extend_from_slice(&sentinel);

    buf
}

fn data_field_for_offset_synonym(info_off: u64) -> u32 {
    u32::try_from(info_off / 8).unwrap()
}

impl DatBuilder {
    fn append_unknown_stub(&mut self) -> u64 {
        let aligned = (self.buf.len() + 127) & !127;
        self.buf.resize(aligned, 0);
        let info_off = u64::try_from(self.buf.len()).unwrap();
        self.buf.extend_from_slice(&24u32.to_le_bytes());
        self.buf.extend_from_slice(&99u32.to_le_bytes());
        self.buf.extend_from_slice(&0u32.to_le_bytes());
        self.buf.extend_from_slice(&0u32.to_le_bytes());
        self.buf.extend_from_slice(&0u32.to_le_bytes());
        self.buf.extend_from_slice(&0u32.to_le_bytes());
        info_off
    }
}

#[test]
fn hash_builder_synonym_entry_hashes_under_v1_synonym_key() {
    use sha2::{Digest, Sha256};

    let std_payload = b"standard-payload".to_vec();
    let syn_payload_a = b"synonym-payload-a".to_vec();
    let syn_payload_b = b"synonym-payload-bee".to_vec();
    let fs = InMemoryFs::new();
    let mut dat = DatBuilder::new();
    let std_off = dat.append_standard(&std_payload);
    let syn_off_a = dat.append_standard(&syn_payload_a);
    let syn_off_b = dat.append_standard(&syn_payload_b);

    let std_hash = hash_index1_key_for_path("exd/standard.exl");
    let std_data = data_field_for_offset(std_off);
    let parent_hash: u64 = 0xCAFE_BABE_DEAD_BEEF;

    fs.insert(
        "/install/sqpack/ffxiv/0a0000.win32.index",
        make_index_file_with_synonyms(
            &[(std_hash, std_data), (parent_hash, 0x0000_0001)],
            &[
                (
                    parent_hash,
                    data_field_for_offset_synonym(syn_off_a),
                    1,
                    "exd/synonym-a.exl",
                ),
                (
                    parent_hash,
                    data_field_for_offset_synonym(syn_off_b),
                    2,
                    "exd/synonym-b.exl",
                ),
            ],
        ),
    );
    fs.insert("/install/sqpack/ffxiv/0a0000.win32.dat0", dat.into_bytes());

    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");
    let mut builder = SqPackHashManifestBuilder::new(&sq);
    let report = builder.build().expect("build");

    assert!(!report.cancelled);
    assert!(report.issues.is_empty(), "no build issues expected");
    assert_eq!(report.skipped_count, 0);
    assert_eq!(report.verified_count, 3);
    assert_eq!(report.manifest.entries.len(), 3);

    let v1 = IndexHashKey::V1 {
        triple: exd_triple(),
        hash: std_hash,
    };
    let std_entry = report.manifest.get(&v1).expect("standard entry present");
    assert_eq!(std_entry.size, u32::try_from(std_payload.len()).unwrap());
    assert!(
        std_entry.path.is_none(),
        "non-synonym entries have no path annotation"
    );

    let syn_a = IndexHashKey::V1Synonym {
        triple: exd_triple(),
        hash: parent_hash,
        conflict_index: 1,
    };
    let syn_b = IndexHashKey::V1Synonym {
        triple: exd_triple(),
        hash: parent_hash,
        conflict_index: 2,
    };
    let ea = report
        .manifest
        .get(&syn_a)
        .expect("synonym entry A present");
    let eb = report
        .manifest
        .get(&syn_b)
        .expect("synonym entry B present");
    assert_eq!(ea.size, u32::try_from(syn_payload_a.len()).unwrap());
    assert_eq!(eb.size, u32::try_from(syn_payload_b.len()).unwrap());
    assert_eq!(ea.path.as_deref(), Some("exd/synonym-a.exl"));
    assert_eq!(eb.path.as_deref(), Some("exd/synonym-b.exl"));

    let mut h = Sha256::new();
    h.update(&syn_payload_a);
    let expected_a: [u8; 32] = h.finalize().into();
    let mut h = Sha256::new();
    h.update(&syn_payload_b);
    let expected_b: [u8; 32] = h.finalize().into();
    assert_eq!(ea.hash, expected_a);
    assert_eq!(eb.hash, expected_b);

    let parent_v1 = IndexHashKey::V1 {
        triple: exd_triple(),
        hash: parent_hash,
    };
    assert!(
        report.manifest.get(&parent_v1).is_none(),
        "parent synonym-redirect entry must not appear under V1 key"
    );
}

#[test]
fn hash_verifier_synonym_entries_verify_cleanly_via_fan_out() {
    let std_payload = b"clean-standard".to_vec();
    let syn_payload_a = b"clean-syn-a".to_vec();
    let syn_payload_b = b"clean-syn-b".to_vec();
    let fs = InMemoryFs::new();
    let mut dat = DatBuilder::new();
    let std_off = dat.append_standard(&std_payload);
    let syn_off_a = dat.append_standard(&syn_payload_a);
    let syn_off_b = dat.append_standard(&syn_payload_b);

    let std_hash = hash_index1_key_for_path("exd/clean.exl");
    let std_data = data_field_for_offset(std_off);
    let parent_hash: u64 = 0xAAAA_BBBB_CCCC_DDDD;

    fs.insert(
        "/install/sqpack/ffxiv/0a0000.win32.index",
        make_index_file_with_synonyms(
            &[(std_hash, std_data), (parent_hash, 0x0000_0001)],
            &[
                (
                    parent_hash,
                    data_field_for_offset_synonym(syn_off_a),
                    1,
                    "exd/syn-a.exl",
                ),
                (
                    parent_hash,
                    data_field_for_offset_synonym(syn_off_b),
                    2,
                    "exd/syn-b.exl",
                ),
            ],
        ),
    );
    fs.insert("/install/sqpack/ffxiv/0a0000.win32.dat0", dat.into_bytes());

    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");
    let mut builder = SqPackHashManifestBuilder::new(&sq);
    let manifest = builder.build().expect("build").manifest;
    assert_eq!(manifest.entries.len(), 3);

    let mut verifier = SqPackHashVerifier::new(&sq);
    let report = verifier.verify(&manifest);

    assert!(
        report.is_clean(),
        "clean install with synonyms must verify clean: {report:?}"
    );
    assert_eq!(report.verified_count, 3);
    assert!(report.mismatches.is_empty());
    assert!(report.extras.is_empty());
}

#[test]
fn hash_verifier_synonym_not_in_manifest_lands_in_extras() {
    let std_payload = b"orig-standard".to_vec();
    let fs = InMemoryFs::new();
    let mut dat = DatBuilder::new();
    let std_off = dat.append_standard(&std_payload);
    let std_hash = hash_index1_key_for_path("exd/orig.exl");
    let std_data = data_field_for_offset(std_off);

    fs.insert(
        "/install/sqpack/ffxiv/0a0000.win32.index",
        make_index_file(&[(std_hash, std_data)]),
    );
    fs.insert("/install/sqpack/ffxiv/0a0000.win32.dat0", dat.into_bytes());

    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");
    let manifest = SqPackHashManifestBuilder::new(&sq)
        .build()
        .expect("build")
        .manifest;

    let parent_hash: u64 = 0xBABE_FACE_0000_0001;
    let syn_payload = b"new-syn-payload".to_vec();
    let fs2 = InMemoryFs::new();
    let mut dat2 = DatBuilder::new();
    let std_off2 = dat2.append_standard(&std_payload);
    let syn_off2 = dat2.append_standard(&syn_payload);
    let std_data2 = data_field_for_offset(std_off2);

    fs2.insert(
        "/install/sqpack/ffxiv/0a0000.win32.index",
        make_index_file_with_synonyms(
            &[(std_hash, std_data2), (parent_hash, 0x0000_0001)],
            &[(
                parent_hash,
                data_field_for_offset_synonym(syn_off2),
                1,
                "exd/new-syn.exl",
            )],
        ),
    );
    fs2.insert("/install/sqpack/ffxiv/0a0000.win32.dat0", dat2.into_bytes());

    let sq2 = SqPack::open_with_vfs("/install", Arc::new(fs2)).expect("open");
    let mut verifier = SqPackHashVerifier::new(&sq2);
    let report = verifier.verify(&manifest);

    assert_eq!(report.mismatches.len(), 0, "no mismatches expected");
    assert_eq!(
        report.verified_count, 1,
        "only the std entry was in manifest"
    );
    assert_eq!(
        report.extras.len(),
        1,
        "the new synonym record is the only extra"
    );
    let expected_extra = IndexHashKey::V1Synonym {
        triple: exd_triple(),
        hash: parent_hash,
        conflict_index: 1,
    };
    assert_eq!(report.extras[0], expected_extra);
    assert!(!report.is_clean(), "extras must flip clean to false");
}

#[test]
fn hash_verifier_eager_and_streaming_agree_with_synonyms() {
    let std_payload = b"agree-standard".to_vec();
    let syn_payload = b"agree-syn".to_vec();
    let fs = InMemoryFs::new();
    let mut dat = DatBuilder::new();
    let std_off = dat.append_standard(&std_payload);
    let syn_off = dat.append_standard(&syn_payload);

    let std_hash = hash_index1_key_for_path("exd/agree.exl");
    let std_data = data_field_for_offset(std_off);
    let parent_hash: u64 = 0x0BAD_F00D_0000_0001;

    fs.insert(
        "/install/sqpack/ffxiv/0a0000.win32.index",
        make_index_file_with_synonyms(
            &[(std_hash, std_data), (parent_hash, 0x0000_0001)],
            &[(
                parent_hash,
                data_field_for_offset_synonym(syn_off),
                1,
                "exd/agree-syn.exl",
            )],
        ),
    );
    fs.insert("/install/sqpack/ffxiv/0a0000.win32.dat0", dat.into_bytes());

    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");
    let mut builder = SqPackHashManifestBuilder::new(&sq);
    let mut manifest_full = builder.build().expect("build").manifest;
    let syn_key = IndexHashKey::V1Synonym {
        triple: exd_triple(),
        hash: parent_hash,
        conflict_index: 1,
    };
    manifest_full.entries.remove(&syn_key);
    let manifest = manifest_full;

    let eager = SqPackHashVerifier::new(&sq).verify(&manifest);

    let mut v2 = SqPackHashVerifier::new(&sq);
    let mut stream = v2.verify_streaming(&manifest);
    let mut s_mismatches: Vec<_> = Vec::new();
    let mut s_extras: Vec<IndexHashKey> = Vec::new();
    for ev in stream.by_ref() {
        match ev {
            HashVerifyEvent::PathMismatch(m) => s_mismatches.push(m),
            HashVerifyEvent::Extra(k) => s_extras.push(k),
            _ => {}
        }
    }

    assert_eq!(eager.mismatches, s_mismatches);
    assert_eq!(eager.extras, s_extras);
    assert_eq!(eager.verified_count, stream.verified_count());
}

struct CancelAfterOne {
    count: std::cell::Cell<usize>,
}
impl HashScanObserver for CancelAfterOne {
    fn on_entry_hashed(&mut self, _key: &IndexHashKey, _size: u32) {
        self.count.set(self.count.get() + 1);
    }
    fn should_cancel(&mut self) -> bool {
        self.count.get() >= 1
    }
}

#[test]
fn hash_verifier_cancellation_observed_between_synonym_records() {
    let payloads = [
        b"syn-pay-1".to_vec(),
        b"syn-pay-2".to_vec(),
        b"syn-pay-3".to_vec(),
        b"syn-pay-4".to_vec(),
        b"syn-pay-5".to_vec(),
    ];
    let fs = InMemoryFs::new();
    let mut dat = DatBuilder::new();
    let mut syn_offs = Vec::new();
    for p in &payloads {
        syn_offs.push(dat.append_standard(p));
    }
    let parent_hash: u64 = 0xCA0E_F00D_0000_0001u64;
    let synonyms: Vec<(u64, u32, u32, &str)> = (0..5)
        .map(|i| {
            (
                parent_hash,
                data_field_for_offset_synonym(syn_offs[i]),
                u32::try_from(i + 1).unwrap(),
                match i {
                    0 => "exd/syn-1.exl",
                    1 => "exd/syn-2.exl",
                    2 => "exd/syn-3.exl",
                    3 => "exd/syn-4.exl",
                    _ => "exd/syn-5.exl",
                },
            )
        })
        .collect();

    fs.insert(
        "/install/sqpack/ffxiv/0a0000.win32.index",
        make_index_file_with_synonyms(&[(parent_hash, 0x0000_0001)], &synonyms),
    );
    fs.insert("/install/sqpack/ffxiv/0a0000.win32.dat0", dat.into_bytes());

    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");
    let manifest = SqPackHashManifestBuilder::new(&sq)
        .build()
        .expect("build")
        .manifest;
    assert_eq!(
        manifest.entries.len(),
        5,
        "five synonym records in manifest"
    );

    let obs = CancelAfterOne {
        count: std::cell::Cell::new(0),
    };

    let mut verifier = SqPackHashVerifier::new(&sq).with_observer(obs);
    let mut stream = verifier.verify_streaming(&manifest);
    let mut events = 0usize;
    while stream.next().is_some() {
        events += 1;
    }
    assert!(stream.cancelled(), "stream must report cancelled");
    assert_eq!(
        stream.verified_count(),
        1,
        "only one record verified before cancel was observed: {} events emitted, verified_count={}",
        events,
        stream.verified_count(),
    );
}

#[test]
fn hash_verifier_empty_kind_entry_surfaces_as_unverifiable() {
    let fs = InMemoryFs::new();
    let mut dat = DatBuilder::new();
    let empty_off = dat.append_empty_stub();
    let v1_key_hash = hash_index1_key_for_path("exd/empty.exl");
    let entries = [(v1_key_hash, data_field_for_offset(empty_off))];
    fs.insert(
        "/install/sqpack/ffxiv/0a0000.win32.index",
        make_index_file(&entries),
    );
    fs.insert("/install/sqpack/ffxiv/0a0000.win32.dat0", dat.into_bytes());

    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");

    let empty_key = IndexHashKey::V1 {
        triple: exd_triple(),
        hash: v1_key_hash,
    };
    let mut manifest = SqPackHashManifest::new();
    manifest
        .entries
        .insert(empty_key, HashManifestEntry::new([0u8; 32], 0));

    let mut verifier = SqPackHashVerifier::new(&sq);
    let report = verifier.verify(&manifest);

    assert_eq!(report.skipped_count, 1);
    assert_eq!(report.verified_count, 0);
    assert_eq!(
        report.mismatches.len(),
        1,
        "Empty kind must surface as Unverifiable mismatch"
    );
    match &report.mismatches[0].kind {
        PathMismatchKind::Unverifiable { kind } => assert_eq!(*kind, FileKind::Empty),
        other => panic!("expected Unverifiable {{ kind: Empty }}, got {other:?}"),
    }
    assert!(!report.is_clean());
    assert!(report.extras.is_empty());
}

#[test]
fn hash_builder_unknown_kind_entry_surfaces_as_unverifiable_build_issue() {
    let fs = InMemoryFs::new();
    let mut dat = DatBuilder::new();
    let unknown_off = dat.append_unknown_stub();
    let v1_hash = hash_index1_key_for_path("exd/unknown.bin");
    let entries = [(v1_hash, data_field_for_offset(unknown_off))];
    fs.insert(
        "/install/sqpack/ffxiv/0a0000.win32.index",
        make_index_file(&entries),
    );
    fs.insert("/install/sqpack/ffxiv/0a0000.win32.dat0", dat.into_bytes());

    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");
    let mut builder = SqPackHashManifestBuilder::new(&sq);
    let report = builder.build().expect("build");

    assert_eq!(report.verified_count, 0);
    assert_eq!(report.skipped_count, 1);
    assert!(
        report.manifest.is_empty(),
        "Unknown-kind entry must not appear in the manifest"
    );
    assert_eq!(report.issues.len(), 1);
    match &report.issues[0].kind {
        HashManifestBuildIssueKind::Unverifiable { kind } => {
            assert_eq!(
                *kind,
                FileKind::Unknown(99),
                "raw wire value must be preserved in Unknown variant"
            );
        }
        other => panic!("expected Unverifiable {{ kind: Unknown(99) }}, got {other:?}"),
    }
}

#[test]
fn hash_verifier_unknown_kind_entry_surfaces_as_unverifiable() {
    let fs = InMemoryFs::new();
    let mut dat = DatBuilder::new();
    let unknown_off = dat.append_unknown_stub();
    let v1_hash = hash_index1_key_for_path("exd/unknown2.bin");
    let entries = [(v1_hash, data_field_for_offset(unknown_off))];
    fs.insert(
        "/install/sqpack/ffxiv/0a0000.win32.index",
        make_index_file(&entries),
    );
    fs.insert("/install/sqpack/ffxiv/0a0000.win32.dat0", dat.into_bytes());

    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");

    let unknown_key = IndexHashKey::V1 {
        triple: exd_triple(),
        hash: v1_hash,
    };
    let mut manifest = SqPackHashManifest::new();
    manifest
        .entries
        .insert(unknown_key, HashManifestEntry::new([0u8; 32], 0));

    let mut verifier = SqPackHashVerifier::new(&sq);
    let report = verifier.verify(&manifest);

    assert_eq!(report.skipped_count, 1);
    assert_eq!(report.verified_count, 0);
    assert_eq!(
        report.mismatches.len(),
        1,
        "Unknown kind must surface as Unverifiable mismatch"
    );
    match &report.mismatches[0].kind {
        PathMismatchKind::Unverifiable { kind } => {
            assert_eq!(
                *kind,
                FileKind::Unknown(99),
                "raw wire value must be preserved in Unknown variant"
            );
        }
        other => panic!("expected Unverifiable {{ kind: Unknown(99) }}, got {other:?}"),
    }
    assert!(!report.is_clean());
    assert!(report.extras.is_empty());
}

#[test]
fn hash_verifier_path_annotation_propagates_to_missing_mismatch() {
    let payload = b"present-entry-for-missing-test".to_vec();
    let (fs, _) = build_install(&[("exd/present.exl", &payload)]);
    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");

    let mut builder = SqPackHashManifestBuilder::new(&sq);
    let mut manifest = builder.build().expect("build").manifest;

    let phantom_key = IndexHashKey::V1 {
        triple: exd_triple(),
        hash: 0x0BAD_F00D_DEAD_BEEF_u64,
    };
    manifest.entries.insert(
        phantom_key,
        HashManifestEntry::new([0u8; 32], 0).with_path("exd/phantom.exl"),
    );

    let mut verifier = SqPackHashVerifier::new(&sq);
    let report = verifier.verify(&manifest);

    let missing: Vec<_> = report
        .mismatches
        .iter()
        .filter(|m| matches!(m.kind, PathMismatchKind::Missing))
        .collect();
    assert_eq!(missing.len(), 1);
    let m = missing[0];
    assert_eq!(m.key, phantom_key);
    assert_eq!(
        m.path.as_deref(),
        Some("exd/phantom.exl"),
        "path annotation must propagate from manifest entry to Missing HashMismatch"
    );
}

#[test]
fn hash_verify_streaming_clean_install_yields_no_events() {
    let payload_a = b"clean-stream-a".to_vec();
    let payload_b = b"clean-stream-b-longer".to_vec();
    let (fs, _) = build_install(&[("exd/a.exl", &payload_a), ("exd/b.exh", &payload_b)]);
    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");

    let manifest = SqPackHashManifestBuilder::new(&sq)
        .build()
        .expect("build")
        .manifest;
    assert_eq!(manifest.len(), 2);

    let mut verifier = SqPackHashVerifier::new(&sq);
    let mut stream = verifier.verify_streaming(&manifest);
    let events: Vec<_> = stream.by_ref().collect();

    assert!(
        events.is_empty(),
        "clean stream emits no events, got {events:?}",
    );
    assert_eq!(stream.verified_count(), 2);
    assert_eq!(stream.skipped_count(), 0);
    assert!(
        !stream.cancelled(),
        "natural exhaustion is not cancellation"
    );
}

#[test]
fn hash_verify_streaming_corrupted_entry_yields_one_hash_mismatch() {
    let payload = b"corrupted-stream-target".to_vec();
    let (fs, _) = build_install(&[("exd/corrupt.exl", &payload)]);
    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");

    let mut manifest = SqPackHashManifestBuilder::new(&sq)
        .build()
        .expect("build")
        .manifest;
    let key = *manifest.entries.keys().next().unwrap();
    manifest.entries.get_mut(&key).unwrap().hash[0] ^= 0xFF;

    let mut verifier = SqPackHashVerifier::new(&sq);
    let mut stream = verifier.verify_streaming(&manifest);
    let events: Vec<_> = stream.by_ref().collect();

    assert_eq!(events.len(), 1, "expected one event, got {events:?}");
    let m = events[0]
        .clone()
        .into_mismatch()
        .expect("event must be PathMismatch");
    assert_eq!(m.key, key);
    assert!(matches!(m.kind, PathMismatchKind::HashMismatch { .. }));
    assert_eq!(stream.verified_count(), 0);
    assert!(!stream.cancelled());
}

#[test]
fn hash_verify_streaming_extras_in_install_yield_extra_event() {
    let payload_a = b"stream-baseline".to_vec();
    let (fs_baseline, _) = build_install(&[("exd/a.exl", &payload_a)]);
    let sq_baseline =
        SqPack::open_with_vfs("/install", Arc::new(fs_baseline)).expect("open baseline");
    let manifest = SqPackHashManifestBuilder::new(&sq_baseline)
        .build()
        .expect("build")
        .manifest;

    let payload_b = b"stream-new-entry".to_vec();
    let (fs_extended, _) = build_install(&[("exd/a.exl", &payload_a), ("exd/b.exh", &payload_b)]);
    let sq_extended =
        SqPack::open_with_vfs("/install", Arc::new(fs_extended)).expect("open extended");

    let mut verifier = SqPackHashVerifier::new(&sq_extended);
    let mut stream = verifier.verify_streaming(&manifest);
    let events: Vec<_> = stream.by_ref().collect();

    assert_eq!(events.len(), 1);
    let extra = events[0].clone().into_extra().expect("event must be Extra");
    let expected_key = IndexHashKey::V1 {
        triple: exd_triple(),
        hash: sqpack_rs::hash::hash_index1_key("exd/b.exh"),
    };
    assert_eq!(extra, expected_key);
    assert_eq!(stream.verified_count(), 1, "baseline entry still clean");
    assert!(!stream.cancelled());
}

#[test]
fn hash_verify_streaming_missing_entry_emits_after_archive_walk() {
    let payload = b"present-for-streaming".to_vec();
    let (fs, _) = build_install(&[("exd/present.exl", &payload)]);
    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");

    let mut manifest = SqPackHashManifestBuilder::new(&sq)
        .build()
        .expect("build")
        .manifest;
    let phantom_key = IndexHashKey::V1 {
        triple: exd_triple(),
        hash: 0x0DEA_DBEE_FBAD_F00D_u64,
    };
    manifest
        .entries
        .insert(phantom_key, HashManifestEntry::new([0u8; 32], 0));

    let mut verifier = SqPackHashVerifier::new(&sq);
    let mut stream = verifier.verify_streaming(&manifest);
    let events: Vec<_> = stream.by_ref().collect();

    assert_eq!(events.len(), 1);
    let m = events[0]
        .clone()
        .into_mismatch()
        .expect("event must be PathMismatch");
    assert_eq!(m.key, phantom_key);
    assert!(matches!(m.kind, PathMismatchKind::Missing));
    assert_eq!(stream.verified_count(), 1);
    assert!(!stream.cancelled());
}

struct CancelAfterOneMismatch {
    seen: std::sync::atomic::AtomicUsize,
}

impl HashScanObserver for &CancelAfterOneMismatch {
    fn on_entry_mismatch(&mut self, _m: &sqpack_rs::verify::HashMismatch) {
        self.seen.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
    }
    fn should_cancel(&mut self) -> bool {
        self.seen.load(std::sync::atomic::Ordering::Relaxed) >= 1
    }
}

#[test]
fn hash_verify_streaming_cancelled_mid_walk_skips_missing_reconciliation() {
    let payload = b"cancel-stream".to_vec();
    let (fs, _) = build_install(&[("exd/a.exl", &payload)]);
    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");

    let mut manifest = SqPackHashManifestBuilder::new(&sq)
        .build()
        .expect("build")
        .manifest;
    let phantom_key = IndexHashKey::V1 {
        triple: exd_triple(),
        hash: 0x1234_5678_9ABC_DEF0_u64,
    };
    manifest
        .entries
        .insert(phantom_key, HashManifestEntry::new([0u8; 32], 0));

    let token = sqpack_rs::CancelToken::new();
    token.cancel();

    let mut verifier = SqPackHashVerifier::new(&sq).with_cancel_token(token);
    let mut stream = verifier.verify_streaming(&manifest);
    let events: Vec<_> = stream.by_ref().collect();

    assert!(
        events.is_empty(),
        "cancellation before any work means no events, got {events:?}",
    );
    assert!(stream.cancelled(), "stream reports cancelled");
    assert_eq!(stream.verified_count(), 0);
    assert_eq!(stream.skipped_count(), 0);
}

#[test]
fn hash_verify_streaming_cancelled_mid_reconciliation_stops_emitting() {
    let payload = b"mid-reconcile-cancel".to_vec();
    let (fs, _) = build_install(&[("exd/a.exl", &payload)]);
    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");

    let mut manifest = SqPackHashManifestBuilder::new(&sq)
        .build()
        .expect("build")
        .manifest;
    for n in 0..8u64 {
        let phantom = IndexHashKey::V1 {
            triple: exd_triple(),
            hash: 0xDEAD_BEEF_0000_0000_u64 | n,
        };
        manifest
            .entries
            .insert(phantom, HashManifestEntry::new([0u8; 32], 0));
    }

    let token = sqpack_rs::CancelToken::new();
    let mut verifier = SqPackHashVerifier::new(&sq).with_cancel_token(token.clone());
    let mut stream = verifier.verify_streaming(&manifest);

    let mut phase2_events = 0usize;
    let mut saw_first_missing = false;
    for ev in stream.by_ref() {
        match ev {
            HashVerifyEvent::PathMismatch(m) if matches!(m.kind, PathMismatchKind::Missing) => {
                phase2_events += 1;
                if !saw_first_missing {
                    saw_first_missing = true;
                    token.cancel();
                    break;
                }
            }
            _ => unreachable!("unexpected event {ev:?} before phase 2"),
        }
    }
    assert!(
        saw_first_missing,
        "phase 2 must produce at least one Missing event before cancel",
    );
    assert_eq!(phase2_events, 1);

    assert!(
        stream.next().is_none(),
        "stream must return None after mid-phase-2 cancellation",
    );
    assert!(stream.cancelled(), "stream.cancelled() must be true");
    assert_eq!(
        stream.verified_count(),
        1,
        "verified_count reflects only the phase-1 hit",
    );
    assert_eq!(stream.skipped_count(), 0);
}

#[test]
fn hash_verify_streaming_and_eager_agree_on_full_event_set() {
    let payload_clean = b"clean-roundtrip".to_vec();
    let payload_corrupt = b"corrupt-target".to_vec();
    let payload_extra = b"new-entry-not-in-manifest".to_vec();
    let (fs, _) = build_install(&[
        ("exd/clean.exl", &payload_clean),
        ("exd/corrupt.exl", &payload_corrupt),
        ("exd/extra.exh", &payload_extra),
    ]);
    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");

    let mut manifest = SqPackHashManifestBuilder::new(&sq)
        .build()
        .expect("build")
        .manifest;
    assert_eq!(manifest.len(), 3);

    let corrupt_key = IndexHashKey::V1 {
        triple: exd_triple(),
        hash: sqpack_rs::hash::hash_index1_key("exd/corrupt.exl"),
    };
    manifest
        .entries
        .get_mut(&corrupt_key)
        .expect("corrupt key in manifest")
        .hash[0] ^= 0xFF;

    let extra_key = IndexHashKey::V1 {
        triple: exd_triple(),
        hash: sqpack_rs::hash::hash_index1_key("exd/extra.exh"),
    };
    manifest
        .entries
        .remove(&extra_key)
        .expect("extra key in manifest");

    let phantom_key = IndexHashKey::V1 {
        triple: exd_triple(),
        hash: 0x0BAD_F00D_DEAD_BEEF_u64,
    };
    manifest
        .entries
        .insert(phantom_key, HashManifestEntry::new([0u8; 32], 0));

    let mut eager_driver = SqPackHashVerifier::new(&sq);
    let report = eager_driver.verify(&manifest);

    let mut lazy_driver = SqPackHashVerifier::new(&sq);
    let mut stream = lazy_driver.verify_streaming(&manifest);
    let events: Vec<_> = stream.by_ref().collect();
    let lazy_verified = stream.verified_count();
    let lazy_skipped = stream.skipped_count();
    let lazy_cancelled = stream.cancelled();
    drop(stream);

    let mut stream_mismatches = Vec::new();
    let mut stream_extras = Vec::new();
    for ev in events {
        match ev {
            HashVerifyEvent::PathMismatch(m) => stream_mismatches.push(m),
            HashVerifyEvent::Extra(k) => stream_extras.push(k),
            _ => unreachable!("non_exhaustive: no other variants today"),
        }
    }

    assert_eq!(
        stream_mismatches, report.mismatches,
        "streaming mismatches must match eager report",
    );
    assert_eq!(
        stream_extras, report.extras,
        "streaming extras must match eager report",
    );
    assert_eq!(lazy_verified, report.verified_count);
    assert_eq!(lazy_skipped, report.skipped_count);
    assert_eq!(lazy_cancelled, report.cancelled);
}

#[derive(Default)]
struct CallbackLog {
    events: std::sync::Mutex<Vec<String>>,
}

impl HashScanObserver for &CallbackLog {
    fn on_entry_hashed(&mut self, key: &IndexHashKey, _bytes: u32) {
        self.events.lock().unwrap().push(format!("hashed:{key:?}"));
    }
    fn on_entry_mismatch(&mut self, m: &sqpack_rs::verify::HashMismatch) {
        self.events
            .lock()
            .unwrap()
            .push(format!("mismatch:{:?}:{:?}", m.key, m.kind));
    }
    fn on_entry_extra(&mut self, key: &IndexHashKey) {
        self.events.lock().unwrap().push(format!("extra:{key:?}"));
    }
}

#[test]
fn hash_verify_streaming_observer_callbacks_fire_identically() {
    let payload_clean = b"observer-clean".to_vec();
    let payload_corrupt = b"observer-corrupt".to_vec();
    let (fs, _) = build_install(&[
        ("exd/a.exl", &payload_clean),
        ("exd/b.exl", &payload_corrupt),
    ]);
    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");

    let mut manifest = SqPackHashManifestBuilder::new(&sq)
        .build()
        .expect("build")
        .manifest;
    let corrupt_key = IndexHashKey::V1 {
        triple: exd_triple(),
        hash: sqpack_rs::hash::hash_index1_key("exd/b.exl"),
    };
    manifest.entries.get_mut(&corrupt_key).unwrap().hash[0] ^= 0xFF;
    let phantom_key = IndexHashKey::V1 {
        triple: exd_triple(),
        hash: 0x9999_9999_9999_9999_u64,
    };
    manifest
        .entries
        .insert(phantom_key, HashManifestEntry::new([0u8; 32], 0));

    let log_eager = CallbackLog::default();
    {
        let mut verifier = SqPackHashVerifier::new(&sq).with_observer(&log_eager);
        let _ = verifier.verify(&manifest);
    }

    let log_stream = CallbackLog::default();
    {
        let mut verifier = SqPackHashVerifier::new(&sq).with_observer(&log_stream);
        let mut stream = verifier.verify_streaming(&manifest);
        for _ev in stream.by_ref() {}
    }

    let eager_events = log_eager.events.lock().unwrap().clone();
    let stream_events = log_stream.events.lock().unwrap().clone();
    assert_eq!(
        eager_events, stream_events,
        "eager and streaming runs must fire observer callbacks identically",
    );
    assert!(!eager_events.is_empty(), "fixture must produce callbacks");
}

#[test]
fn hash_verify_streaming_debug_format_includes_counts_and_cancelled() {
    let payload = b"debug-format".to_vec();
    let (fs, _) = build_install(&[("exd/a.exl", &payload)]);
    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");
    let manifest = SqPackHashManifestBuilder::new(&sq)
        .build()
        .expect("build")
        .manifest;

    let mut verifier = SqPackHashVerifier::new(&sq);
    let stream = verifier.verify_streaming(&manifest);
    let s = format!("{stream:?}");
    assert!(s.contains("HashVerifyStream"), "type name in Debug: {s}");
    assert!(s.contains("verified_count"), "verified_count in Debug: {s}");
    assert!(s.contains("skipped_count"), "skipped_count in Debug: {s}");
    assert!(s.contains("cancelled"), "cancelled in Debug: {s}");
}

#[test]
fn hash_verify_report_mismatches_preserves_walk_order_not_sorted_order() {
    let regular_payload = b"regular-content".to_vec();
    let synonym_payload = b"synonym-content".to_vec();
    let fs = InMemoryFs::new();
    let mut dat = DatBuilder::new();
    let regular_off = dat.append_standard(&regular_payload);
    let synonym_off = dat.append_standard(&synonym_payload);

    let regular_hash = hash_index1_key_for_path("exd/regular.exl");
    let parent_hash: u64 = 0xFEED_FACE_DEAD_BEEF;

    fs.insert(
        "/install/sqpack/ffxiv/0a0000.win32.index",
        make_index_file_with_synonyms(
            &[
                (regular_hash, data_field_for_offset(regular_off)),
                (parent_hash, 0x0000_0001),
            ],
            &[(
                parent_hash,
                data_field_for_offset_synonym(synonym_off),
                1,
                "exd/synonym.exl",
            )],
        ),
    );
    fs.insert("/install/sqpack/ffxiv/0a0000.win32.dat0", dat.into_bytes());

    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");
    let mut manifest = SqPackHashManifestBuilder::new(&sq)
        .build()
        .expect("build")
        .manifest;

    let regular_key = IndexHashKey::V1 {
        triple: exd_triple(),
        hash: regular_hash,
    };
    let synonym_key = IndexHashKey::V1Synonym {
        triple: exd_triple(),
        hash: parent_hash,
        conflict_index: 1,
    };
    manifest.entries.get_mut(&regular_key).unwrap().hash[0] ^= 0xFF;
    manifest.entries.get_mut(&synonym_key).unwrap().hash[0] ^= 0xFF;

    let missing_v2_key = IndexHashKey::V2 {
        triple: exd_triple(),
        hash: 0x1234_5678u32,
    };
    manifest
        .entries
        .insert(missing_v2_key, HashManifestEntry::new([0u8; 32], 0));

    let report = SqPackHashVerifier::new(&sq).verify(&manifest);

    assert_eq!(report.mismatches.len(), 3, "{report:?}");

    let keys: Vec<IndexHashKey> = report.mismatches.iter().map(|m| m.key).collect();
    assert_eq!(
        keys,
        vec![regular_key, synonym_key, missing_v2_key],
        "mismatches must appear in walk-observed order: \
         V1 → V1Synonym → V2(Missing). Derived Ord would have sorted these as \
         V1 < V2 < V1Synonym."
    );

    let mut sorted = keys.clone();
    sorted.sort();
    assert_ne!(
        keys, sorted,
        "fixture must distinguish walk-order from derived-Ord order"
    );
}

#[test]
fn hash_verifier_v1_to_synonym_install_transition_surfaces_as_missing_plus_extras() {
    let baseline_payload = b"baseline-payload".to_vec();
    let fs_base = InMemoryFs::new();
    let mut dat_base = DatBuilder::new();
    let baseline_off = dat_base.append_standard(&baseline_payload);
    let parent_hash: u64 = 0x1357_9BDF_2468_ACE0;

    fs_base.insert(
        "/install/sqpack/ffxiv/0a0000.win32.index",
        make_index_file(&[(parent_hash, data_field_for_offset(baseline_off))]),
    );
    fs_base.insert(
        "/install/sqpack/ffxiv/0a0000.win32.dat0",
        dat_base.into_bytes(),
    );

    let sq_base = SqPack::open_with_vfs("/install", Arc::new(fs_base)).expect("open");
    let baseline_manifest = SqPackHashManifestBuilder::new(&sq_base)
        .build()
        .expect("build")
        .manifest;
    let baseline_v1_key = IndexHashKey::V1 {
        triple: exd_triple(),
        hash: parent_hash,
    };
    assert!(
        baseline_manifest.entries.contains_key(&baseline_v1_key),
        "baseline must record the regular V1 entry"
    );

    let new_a = b"new-syn-a".to_vec();
    let new_b = b"new-syn-bee".to_vec();
    let fs_new = InMemoryFs::new();
    let mut dat_new = DatBuilder::new();
    let off_a = dat_new.append_standard(&new_a);
    let off_b = dat_new.append_standard(&new_b);

    fs_new.insert(
        "/install/sqpack/ffxiv/0a0000.win32.index",
        make_index_file_with_synonyms(
            &[(parent_hash, 0x0000_0001)],
            &[
                (
                    parent_hash,
                    data_field_for_offset_synonym(off_a),
                    1,
                    "exd/moved-a.exl",
                ),
                (
                    parent_hash,
                    data_field_for_offset_synonym(off_b),
                    2,
                    "exd/moved-b.exl",
                ),
            ],
        ),
    );
    fs_new.insert(
        "/install/sqpack/ffxiv/0a0000.win32.dat0",
        dat_new.into_bytes(),
    );

    let sq_new = SqPack::open_with_vfs("/install", Arc::new(fs_new)).expect("open");

    let report = SqPackHashVerifier::new(&sq_new).verify(&baseline_manifest);

    let missing: Vec<&HashMismatch> = report
        .mismatches
        .iter()
        .filter(|m| m.key == baseline_v1_key && matches!(m.kind, PathMismatchKind::Missing))
        .collect();
    assert_eq!(
        missing.len(),
        1,
        "old V1 key must surface exactly once as Missing in: {report:?}",
    );

    let syn_a = IndexHashKey::V1Synonym {
        triple: exd_triple(),
        hash: parent_hash,
        conflict_index: 1,
    };
    let syn_b = IndexHashKey::V1Synonym {
        triple: exd_triple(),
        hash: parent_hash,
        conflict_index: 2,
    };
    assert!(
        report.extras.contains(&syn_a),
        "synonym record A must appear in extras"
    );
    assert!(
        report.extras.contains(&syn_b),
        "synonym record B must appear in extras"
    );
    assert_eq!(report.extras.len(), 2, "exactly two new synonym extras");

    assert!(!report.is_clean());
}

#[test]
fn hash_verify_stream_drop_mid_iteration_does_not_panic() {
    let payload_a = b"drop-mid-a".to_vec();
    let payload_b = b"drop-mid-b".to_vec();
    let (fs, _) = build_install(&[("exd/a.exl", &payload_a), ("exd/b.exl", &payload_b)]);
    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");

    let mut manifest = SqPackHashManifestBuilder::new(&sq)
        .build()
        .expect("build")
        .manifest;
    let extra_key = IndexHashKey::V1 {
        triple: exd_triple(),
        hash: sqpack_rs::hash::hash_index1_key("exd/b.exl"),
    };
    manifest.entries.remove(&extra_key).expect("key present");

    let mut verifier = SqPackHashVerifier::new(&sq);
    let mut stream = verifier.verify_streaming(&manifest);

    let first = stream.next().expect("stream must yield at least one event");
    assert!(
        matches!(first, HashVerifyEvent::Extra(k) if k == extra_key),
        "first event must be Extra for the removed key, got {first:?}",
    );

    drop(stream);
}

#[test]
fn hash_verifier_sequential_streaming_calls_are_independent() {
    let payload = b"sequential-calls".to_vec();
    let (fs, _) = build_install(&[("exd/a.exl", &payload)]);
    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");

    let manifest = SqPackHashManifestBuilder::new(&sq)
        .build()
        .expect("build")
        .manifest;

    let mut verifier = SqPackHashVerifier::new(&sq);

    let events_first: Vec<_> = verifier.verify_streaming(&manifest).collect();

    let events_second: Vec<_> = verifier.verify_streaming(&manifest).collect();

    assert_eq!(
        events_first, events_second,
        "sequential verify_streaming calls must produce identical event sequences",
    );
    assert!(
        events_first.is_empty(),
        "clean manifest must yield no events, got {events_first:?}",
    );
}

#[test]
fn hash_verifier_empty_manifest_treats_all_install_entries_as_extras() {
    let payload_a = b"extra-all-a".to_vec();
    let payload_b = b"extra-all-b".to_vec();
    let (fs, _) = build_install(&[("exd/a.exl", &payload_a), ("exd/b.exl", &payload_b)]);
    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");

    let empty_manifest = SqPackHashManifest::new();
    assert!(empty_manifest.is_empty());

    let report = SqPackHashVerifier::new(&sq).verify(&empty_manifest);

    assert_eq!(
        report.mismatches.len(),
        0,
        "empty manifest must produce no mismatches: {report:?}",
    );
    assert_eq!(
        report.extras.len(),
        2,
        "both live entries must surface as extras: {report:?}",
    );
    assert!(!report.is_clean(), "extras make the report not-clean");
    assert!(!report.cancelled);
}

#[test]
fn hash_verifier_nonempty_manifest_against_empty_install_all_missing() {
    let empty_fs = InMemoryFs::new();
    empty_fs.insert("/install/sqpack/ffxiv/.keep", Vec::<u8>::new());
    let sq = SqPack::open_with_vfs("/install", Arc::new(empty_fs)).expect("open");

    let key_a = IndexHashKey::V1 {
        triple: exd_triple(),
        hash: 0xAAAA_BBBB_CCCC_DDDD_u64,
    };
    let key_b = IndexHashKey::V1 {
        triple: exd_triple(),
        hash: 0x1111_2222_3333_4444_u64,
    };
    let mut manifest = SqPackHashManifest::new();
    manifest
        .entries
        .insert(key_a, HashManifestEntry::new([0u8; 32], 10));
    manifest
        .entries
        .insert(key_b, HashManifestEntry::new([0u8; 32], 20));

    let report = SqPackHashVerifier::new(&sq).verify(&manifest);

    let missing_kinds: Vec<_> = report
        .mismatches
        .iter()
        .filter(|m| matches!(m.kind, PathMismatchKind::Missing))
        .collect();
    assert_eq!(
        missing_kinds.len(),
        2,
        "both manifest entries must surface as Missing: {report:?}",
    );
    assert_eq!(
        report.extras.len(),
        0,
        "empty install has no extras: {report:?}",
    );
    assert!(!report.is_clean());
}

#[test]
fn hash_verifier_large_synonym_fan_out_all_records_hashed() {
    const FAN_OUT: usize = 100;
    let parent_hash: u64 = 0xDECA_F000_FEED_BEEF;

    let fs = InMemoryFs::new();
    let mut dat = DatBuilder::new();

    let fan_out_u32 = u32::try_from(FAN_OUT).unwrap();

    let mut syn_tuples: Vec<(u32, u32, String)> = Vec::new();
    for i in 0u32..fan_out_u32 {
        let payload = format!("large-fan-out-payload-{i:03}");
        let off = dat.append_standard(payload.as_bytes());
        let data = data_field_for_offset_synonym(off);
        let path = format!("exd/large-syn-{i:03}.exl");
        syn_tuples.push((data, i + 1, path));
    }

    let syn_refs: Vec<(u64, u32, u32, &str)> = syn_tuples
        .iter()
        .map(|(data, ci, path)| (parent_hash, *data, *ci, path.as_str()))
        .collect();

    let standard_entries: Vec<(u64, u32)> = vec![(parent_hash, 0x0000_0001u32)];

    fs.insert(
        "/install/sqpack/ffxiv/0a0000.win32.index",
        make_index_file_with_synonyms(&standard_entries, &syn_refs),
    );
    fs.insert("/install/sqpack/ffxiv/0a0000.win32.dat0", dat.into_bytes());

    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");
    let build_report = SqPackHashManifestBuilder::new(&sq).build().expect("build");
    let manifest = build_report.manifest;

    assert_eq!(
        manifest.len(),
        FAN_OUT,
        "manifest must contain exactly {FAN_OUT} V1Synonym entries, got {}",
        manifest.len(),
    );
    for i in 0u32..fan_out_u32 {
        let key = IndexHashKey::V1Synonym {
            triple: exd_triple(),
            hash: parent_hash,
            conflict_index: i + 1,
        };
        assert!(
            manifest.entries.contains_key(&key),
            "V1Synonym key conflict_index={} must be in manifest",
            i + 1,
        );
    }

    let verify_report = SqPackHashVerifier::new(&sq).verify(&manifest);
    assert!(
        verify_report.is_clean(),
        "large fan-out round-trip must be clean: {verify_report:?}",
    );
}

#[cfg(feature = "serde")]
#[test]
fn hash_manifest_serde_v1_synonym_key_round_trips() {
    let key = IndexHashKey::V1Synonym {
        triple: exd_triple(),
        hash: 0xCAFE_BABE_DEAD_BEEF_u64,
        conflict_index: 42,
    };
    let mut manifest = SqPackHashManifest::new();
    manifest.entries.insert(
        key,
        HashManifestEntry::new([0x77; 32], 512).with_path("exd/syn.exl"),
    );

    let json = serde_json::to_string(&manifest).expect("serialise");

    assert!(
        json.contains("V1Synonym"),
        "serialised JSON must contain the 'V1Synonym' variant tag: {json}",
    );
    assert!(
        json.contains("conflict_index"),
        "serialised JSON must contain 'conflict_index': {json}",
    );

    let parsed: SqPackHashManifest = serde_json::from_str(&json).expect("deserialise");
    assert_eq!(
        parsed, manifest,
        "Serde round-trip must preserve V1Synonym key"
    );
    assert!(
        parsed.entries.contains_key(&key),
        "deserialized manifest must contain the V1Synonym key",
    );
}

#[test]
fn hash_verify_observer_exact_counts_for_clean_scenario() {
    use std::sync::atomic::{AtomicU32, Ordering};

    struct ExactCountObserver {
        hashed: AtomicU32,
        mismatched: AtomicU32,
        extra: AtomicU32,
    }
    impl HashScanObserver for &ExactCountObserver {
        fn on_entry_hashed(&mut self, _key: &IndexHashKey, _bytes: u32) {
            self.hashed.fetch_add(1, Ordering::Relaxed);
        }
        fn on_entry_mismatch(&mut self, _m: &sqpack_rs::verify::HashMismatch) {
            self.mismatched.fetch_add(1, Ordering::Relaxed);
        }
        fn on_entry_extra(&mut self, _key: &IndexHashKey) {
            self.extra.fetch_add(1, Ordering::Relaxed);
        }
    }

    let payload = b"exact-counts".to_vec();
    let (fs, _) = build_install(&[("exd/a.exl", &payload)]);
    let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");
    let manifest = SqPackHashManifestBuilder::new(&sq)
        .build()
        .expect("build")
        .manifest;

    let obs = ExactCountObserver {
        hashed: AtomicU32::new(0),
        mismatched: AtomicU32::new(0),
        extra: AtomicU32::new(0),
    };
    let mut verifier = SqPackHashVerifier::new(&sq).with_observer(&obs);
    let report = verifier.verify(&manifest);

    assert!(report.is_clean(), "clean fixture must produce clean report");
    assert_eq!(
        obs.hashed.load(Ordering::Relaxed),
        1,
        "on_entry_hashed must fire exactly once",
    );
    assert_eq!(
        obs.mismatched.load(Ordering::Relaxed),
        0,
        "on_entry_mismatch must not fire for clean entry",
    );
    assert_eq!(
        obs.extra.load(Ordering::Relaxed),
        0,
        "on_entry_extra must not fire when manifest covers all entries",
    );
}

#[cfg(feature = "parallel")]
mod parallel_tests {
    use super::{
        DatBuilder, build_install, data_field_for_offset, data_field_for_offset_synonym,
        hash_index1_key_for_path, make_index_file, make_index_file_with_synonyms,
    };
    use std::sync::Arc;

    use sqpack_rs::verify::{HashManifestEntry, IndexHashKey, PathManifestEntry};
    use sqpack_rs::vfs::InMemoryFs;
    use sqpack_rs::{
        CancelToken, Category, SqPack, SqPackHashManifestBuilder, SqPackHashVerifier,
        SqPackPathManifest, SqPackPathManifestBuilder, SqPackPathVerifier,
    };

    const PARALLEL_FIXTURE_ENTRIES: usize = 48;

    fn fixture_paths() -> Vec<(String, Vec<u8>)> {
        (0..PARALLEL_FIXTURE_ENTRIES)
            .map(|i| {
                (
                    format!("exd/parallel-fixture-{i:04}.exh"),
                    format!("payload-bytes-for-entry-{i:04}").into_bytes(),
                )
            })
            .collect()
    }

    fn open_fixture_install() -> SqPack {
        let owned = fixture_paths();
        let refs: Vec<(&str, &[u8])> = owned
            .iter()
            .map(|(p, b)| (p.as_str(), b.as_slice()))
            .collect();
        let (fs, _expected) = build_install(&refs);
        SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open")
    }

    #[test]
    fn path_verify_parallel_matches_sequential_on_clean_install() {
        let sq = open_fixture_install();
        let manifest = {
            let mut builder = SqPackPathManifestBuilder::new(&sq);
            for (path, _) in fixture_paths() {
                builder.add(&path).expect("add");
            }
            builder.finish()
        };

        let sequential = SqPackPathVerifier::new(&sq).verify(&manifest);
        let parallel = SqPackPathVerifier::new(&sq).verify_parallel(&manifest);
        assert_eq!(
            sequential, parallel,
            "parallel report must be byte-for-byte identical to sequential",
        );
        assert!(sequential.is_clean());
        assert_eq!(sequential.verified_count, PARALLEL_FIXTURE_ENTRIES);
    }

    #[test]
    fn path_verify_parallel_matches_sequential_on_mismatch_mix() {
        let sq = open_fixture_install();
        let mut manifest = {
            let mut builder = SqPackPathManifestBuilder::new(&sq);
            for (path, _) in fixture_paths() {
                builder.add(&path).expect("add");
            }
            builder.finish()
        };
        let paths_to_corrupt: Vec<String> = manifest
            .entries
            .keys()
            .enumerate()
            .filter(|(i, _)| i % 4 == 0)
            .map(|(_, k)| k.clone())
            .collect();
        for path in &paths_to_corrupt {
            let entry = manifest.entries.get_mut(path).expect("present");
            entry.hash[0] ^= 0xFF;
        }

        let sequential = SqPackPathVerifier::new(&sq).verify(&manifest);
        let parallel = SqPackPathVerifier::new(&sq).verify_parallel(&manifest);
        assert_eq!(sequential, parallel);
        assert_eq!(
            sequential.mismatches.len(),
            paths_to_corrupt.len(),
            "every corrupted entry must surface as exactly one mismatch",
        );
    }

    #[test]
    fn path_verify_parallel_pre_flipped_cancel_returns_cancelled_report() {
        let sq = open_fixture_install();
        let manifest = {
            let mut builder = SqPackPathManifestBuilder::new(&sq);
            for (path, _) in fixture_paths() {
                builder.add(&path).expect("add");
            }
            builder.finish()
        };
        let token = CancelToken::new();
        token.cancel();
        let mut v = SqPackPathVerifier::new(&sq).with_cancel_token(token);
        let report = v.verify_parallel(&manifest);
        assert!(
            report.cancelled,
            "pre-cancelled token must surface cancelled=true on the parallel path",
        );
        assert_eq!(
            report.verified_count, 0,
            "no entry should have been hashed before cancellation",
        );
    }

    #[test]
    fn hash_verify_parallel_matches_sequential_on_clean_install() {
        let sq = open_fixture_install();
        let manifest = SqPackHashManifestBuilder::new(&sq)
            .build()
            .expect("build")
            .manifest;

        let sequential = SqPackHashVerifier::new(&sq).verify(&manifest);
        let parallel = SqPackHashVerifier::new(&sq).verify_parallel(&manifest);
        assert_eq!(
            sequential, parallel,
            "parallel hash-verify report must equal the sequential report",
        );
        assert!(sequential.is_clean());
    }

    #[test]
    fn hash_verify_parallel_pre_flipped_cancel_returns_cancelled_report() {
        let sq = open_fixture_install();
        let manifest = SqPackHashManifestBuilder::new(&sq)
            .build()
            .expect("build")
            .manifest;
        let token = CancelToken::new();
        token.cancel();
        let mut v = SqPackHashVerifier::new(&sq).with_cancel_token(token);
        let report = v.verify_parallel(&manifest);
        assert!(report.cancelled);
        assert_eq!(report.verified_count, 0);
    }

    #[test]
    fn hash_build_parallel_matches_sequential_manifest() {
        let sq = open_fixture_install();
        let sequential = SqPackHashManifestBuilder::new(&sq).build().expect("build");
        let parallel = SqPackHashManifestBuilder::new(&sq)
            .build_parallel()
            .expect("build_parallel");
        assert_eq!(
            sequential.manifest, parallel.manifest,
            "parallel manifest must equal sequential manifest",
        );
        assert_eq!(sequential.verified_count, parallel.verified_count);
        assert_eq!(sequential.skipped_count, parallel.skipped_count);
        assert_eq!(sequential.cancelled, parallel.cancelled);
        assert_eq!(sequential.issues, parallel.issues);
        assert_eq!(
            sequential.verified_count, PARALLEL_FIXTURE_ENTRIES,
            "every entry must be hashed",
        );
    }

    #[test]
    fn hash_build_parallel_pre_flipped_cancel_returns_cancelled_report() {
        let sq = open_fixture_install();
        let token = CancelToken::new();
        token.cancel();
        let report = SqPackHashManifestBuilder::new(&sq)
            .with_cancel_token(token)
            .build_parallel()
            .expect("build_parallel");
        assert!(report.cancelled);
    }

    #[test]
    fn enumerate_archives_parallel_matches_sequential_order() {
        let fs = InMemoryFs::new();
        {
            let mut dat = DatBuilder::new();
            let payload = b"exd-payload".to_vec();
            let off = dat.append_standard(&payload);
            let v1_key = hash_index1_key_for_path("exd/foo.exh");
            let entries = [(v1_key, data_field_for_offset(off))];
            fs.insert(
                "/install/sqpack/ffxiv/0a0000.win32.index",
                make_index_file(&entries),
            );
            fs.insert("/install/sqpack/ffxiv/0a0000.win32.dat0", dat.into_bytes());
        }
        {
            let mut dat = DatBuilder::new();
            let payload = b"bg-payload".to_vec();
            let off = dat.append_standard(&payload);
            let v1_key = hash_index1_key_for_path("bg/foo.bin");
            let entries = [(v1_key, data_field_for_offset(off))];
            fs.insert(
                "/install/sqpack/ffxiv/020000.win32.index",
                make_index_file(&entries),
            );
            fs.insert("/install/sqpack/ffxiv/020000.win32.dat0", dat.into_bytes());
        }
        let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");

        let sequential: Vec<_> = sq
            .enumerate_archives()
            .map(|r| {
                let a = r.expect("ok");
                (a.triple(), a.len())
            })
            .collect();
        let parallel: Vec<_> = sq
            .enumerate_archives_parallel()
            .into_iter()
            .map(|r| {
                let a = r.expect("ok");
                (a.triple(), a.len())
            })
            .collect();
        assert_eq!(
            sequential, parallel,
            "parallel enumerator must match sequential walk in order + content",
        );
        assert_eq!(sequential.len(), 2, "two archives in the fixture");
    }

    #[test]
    fn enumerate_archives_parallel_return_type_is_send_and_sync() {
        fn assert_send_sync<T: Send + Sync>() {}
        assert_send_sync::<Vec<sqpack_rs::Result<sqpack_rs::sqpack::EnumeratedArchive>>>();
    }

    fn build_two_archive_install_with_bad_index() -> SqPack {
        let fs = InMemoryFs::new();
        {
            let mut dat = DatBuilder::new();
            let off = dat.append_standard(b"exd-good-payload");
            let key = hash_index1_key_for_path("exd/good.exh");
            fs.insert(
                "/install/sqpack/ffxiv/0a0000.win32.index",
                make_index_file(&[(key, data_field_for_offset(off))]),
            );
            fs.insert("/install/sqpack/ffxiv/0a0000.win32.dat0", dat.into_bytes());
        }
        fs.insert("/install/sqpack/ffxiv/020000.win32.index", Vec::<u8>::new());
        {
            let mut dat = DatBuilder::new();
            dat.append_standard(b"bg-unreachable-payload");
            fs.insert("/install/sqpack/ffxiv/020000.win32.dat0", dat.into_bytes());
        }
        SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open")
    }

    #[test]
    fn hash_verify_parallel_archive_read_error_matches_sequential_order() {
        let sq = build_two_archive_install_with_bad_index();
        let mut builder = SqPackHashManifestBuilder::new(&sq);
        let build_report = builder.build().expect("build");
        assert!(
            !build_report.issues.is_empty(),
            "build must record an issue for the malformed bg archive"
        );

        let bg_triple =
            sqpack_rs::route::ArchiveTriple::from_game_path("bg/dummy").expect("bg routes");
        let phantom_bg_key = IndexHashKey::V1 {
            triple: bg_triple,
            hash: 0xBAD0_1234_DEAD_CAFE_u64,
        };
        let mut manifest = build_report.manifest.clone();
        manifest
            .entries
            .insert(phantom_bg_key, HashManifestEntry::new([0u8; 32], 0));

        let sequential = SqPackHashVerifier::new(&sq).verify(&manifest);
        let parallel = SqPackHashVerifier::new(&sq).verify_parallel(&manifest);

        assert_eq!(
            sequential.mismatches, parallel.mismatches,
            "sequential and parallel mismatch lists must be identical in order and content"
        );
        assert_eq!(sequential.extras, parallel.extras);
        assert_eq!(sequential.verified_count, parallel.verified_count);
        assert_eq!(sequential.cancelled, parallel.cancelled);
    }

    #[test]
    fn hash_verify_parallel_extras_match_sequential() {
        let sq = open_fixture_install();
        let mut manifest = SqPackHashManifestBuilder::new(&sq)
            .build()
            .expect("build")
            .manifest;

        let dropped_key = *manifest.entries.keys().next().expect("non-empty manifest");
        manifest.entries.remove(&dropped_key).expect("key present");

        let sequential = SqPackHashVerifier::new(&sq).verify(&manifest);
        let parallel = SqPackHashVerifier::new(&sq).verify_parallel(&manifest);

        assert_eq!(
            sequential, parallel,
            "parallel report must match sequential when install has extras"
        );
        assert!(
            sequential.extras.contains(&dropped_key),
            "dropped key must be in extras: {:?}",
            sequential.extras,
        );
    }

    #[test]
    fn hash_verify_parallel_synonym_fan_out_matches_sequential() {
        let std_payload = b"par-syn-std".to_vec();
        let syn_payload_a = b"par-syn-a-payload".to_vec();
        let syn_payload_b = b"par-syn-b-payload-longer".to_vec();
        let fs = InMemoryFs::new();
        let mut dat = DatBuilder::new();
        let std_off = dat.append_standard(&std_payload);
        let syn_off_a = dat.append_standard(&syn_payload_a);
        let syn_off_b = dat.append_standard(&syn_payload_b);

        let std_hash = hash_index1_key_for_path("exd/par-std.exl");
        let std_data = data_field_for_offset(std_off);
        let parent_hash: u64 = 0xBEEF_CAFE_0101_0202;

        fs.insert(
            "/install/sqpack/ffxiv/0a0000.win32.index",
            make_index_file_with_synonyms(
                &[(std_hash, std_data), (parent_hash, 0x0000_0001)],
                &[
                    (
                        parent_hash,
                        data_field_for_offset_synonym(syn_off_a),
                        1,
                        "exd/par-syn-a.exl",
                    ),
                    (
                        parent_hash,
                        data_field_for_offset_synonym(syn_off_b),
                        2,
                        "exd/par-syn-b.exl",
                    ),
                ],
            ),
        );
        fs.insert("/install/sqpack/ffxiv/0a0000.win32.dat0", dat.into_bytes());

        let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");
        let manifest = SqPackHashManifestBuilder::new(&sq)
            .build()
            .expect("build")
            .manifest;
        assert_eq!(manifest.entries.len(), 3, "std + two synonym records");

        let sequential = SqPackHashVerifier::new(&sq).verify(&manifest);
        let parallel = SqPackHashVerifier::new(&sq).verify_parallel(&manifest);

        assert_eq!(
            sequential, parallel,
            "parallel synonym verify must match sequential"
        );
        assert!(
            sequential.is_clean(),
            "clean synonym install must verify clean: {sequential:?}"
        );
        assert_eq!(sequential.verified_count, 3);
    }

    #[test]
    fn hash_build_parallel_mid_run_cancel_via_background_thread() {
        let sq = open_fixture_install();
        let token = CancelToken::new();
        let token_bg = token.clone();

        let handle = std::thread::spawn(move || {
            std::thread::sleep(std::time::Duration::from_micros(100));
            token_bg.cancel();
        });

        let report = SqPackHashManifestBuilder::new(&sq)
            .with_cancel_token(token)
            .build_parallel()
            .expect("build_parallel");

        handle.join().expect("background thread must not panic");

        if report.cancelled {
            let partial = report.verified_count + report.skipped_count;
            assert!(
                partial <= PARALLEL_FIXTURE_ENTRIES,
                "partial build cannot exceed fixture size: {partial} > {PARALLEL_FIXTURE_ENTRIES}",
            );
        }
    }

    #[test]
    fn enumerate_archives_parallel_archive_read_error_matches_sequential() {
        let sq = build_two_archive_install_with_bad_index();

        let sequential: Vec<(sqpack_rs::route::ArchiveTriple, bool)> = sq
            .enumerate_archives()
            .map(|r| match r {
                Ok(a) => (a.triple(), true),
                Err(_) => (
                    sqpack_rs::route::ArchiveTriple::from_game_path("exd/dummy")
                        .expect("exd routes"),
                    false,
                ),
            })
            .collect();

        let parallel: Vec<(sqpack_rs::route::ArchiveTriple, bool)> = sq
            .enumerate_archives_parallel()
            .into_iter()
            .map(|r| match r {
                Ok(a) => (a.triple(), true),
                Err(_) => (
                    sqpack_rs::route::ArchiveTriple::from_game_path("exd/dummy")
                        .expect("exd routes"),
                    false,
                ),
            })
            .collect();

        assert_eq!(
            sequential.len(),
            parallel.len(),
            "parallel and sequential must return the same number of results"
        );

        let seq_ok: Vec<_> = sequential.iter().filter(|(_, ok)| *ok).collect();
        let par_ok: Vec<_> = parallel.iter().filter(|(_, ok)| *ok).collect();
        assert_eq!(
            seq_ok.len(),
            par_ok.len(),
            "same number of successful archive results"
        );

        let seq_err_count = sequential.iter().filter(|(_, ok)| !ok).count();
        let par_err_count = parallel.iter().filter(|(_, ok)| !ok).count();
        assert_eq!(
            seq_err_count, par_err_count,
            "same number of error results in parallel and sequential"
        );

        for (s, p) in seq_ok.iter().zip(par_ok.iter()) {
            assert_eq!(s.0, p.0, "ok archive triples must be in the same order");
        }
    }

    #[test]
    fn enumerate_archives_parallel_chunk_gap_stops_at_first_missing() {
        let fs = InMemoryFs::new();
        for chunk in 0u8..=1 {
            let mut dat = DatBuilder::new();
            let off = dat.append_standard(b"chunk-payload");
            let key = hash_index1_key_for_path("exd/chunk-entry.exh");
            let filename = format!("/install/sqpack/ffxiv/0a000{chunk}.win32.index");
            fs.insert(
                filename,
                make_index_file(&[(key, data_field_for_offset(off))]),
            );
            let dat_name = format!("/install/sqpack/ffxiv/0a000{chunk}.win32.dat0");
            fs.insert(dat_name, dat.into_bytes());
        }

        let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");

        let sequential: Vec<_> = sq
            .enumerate_archives()
            .map(|r| r.expect("ok").triple())
            .collect();
        let parallel: Vec<_> = sq
            .enumerate_archives_parallel()
            .into_iter()
            .map(|r| r.expect("ok").triple())
            .collect();

        assert_eq!(
            sequential, parallel,
            "sequential and parallel must agree on chunk-gap traversal"
        );
        assert_eq!(
            sequential.len(),
            2,
            "chunk gap at index 2 must stop enumeration after chunks 0 and 1"
        );
        for triple in &sequential {
            assert!(
                triple.chunk <= 1,
                "no archive beyond chunk 1 should be returned, got chunk {}",
                triple.chunk,
            );
        }
    }

    #[test]
    fn verify_parallel_deterministic_across_ten_runs() {
        let sq = open_fixture_install();
        let manifest = {
            let mut builder = SqPackPathManifestBuilder::new(&sq);
            for (path, _) in fixture_paths() {
                builder.add(&path).expect("add");
            }
            builder.finish()
        };

        let first = SqPackPathVerifier::new(&sq).verify_parallel(&manifest);
        for run in 1..10 {
            let rep = SqPackPathVerifier::new(&sq).verify_parallel(&manifest);
            assert_eq!(
                first, rep,
                "verify_parallel run {run} must be byte-for-byte identical to run 0"
            );
        }
    }

    #[test]
    fn hash_build_parallel_deterministic_across_ten_runs() {
        let sq = open_fixture_install();
        let first = SqPackHashManifestBuilder::new(&sq)
            .build_parallel()
            .expect("build_parallel");
        for run in 1..10 {
            let rep = SqPackHashManifestBuilder::new(&sq)
                .build_parallel()
                .expect("build_parallel");
            assert_eq!(
                first.manifest, rep.manifest,
                "build_parallel run {run} manifest must be byte-for-byte identical to run 0"
            );
            assert_eq!(first.verified_count, rep.verified_count);
            assert_eq!(first.issues, rep.issues);
        }
    }

    #[test]
    fn enumerate_archives_parallel_deterministic_across_ten_runs() {
        let sq = open_fixture_install();
        let first: Vec<_> = sq
            .enumerate_archives_parallel()
            .into_iter()
            .map(|r| r.expect("ok").triple())
            .collect();
        for run in 1..10 {
            let rep: Vec<_> = sq
                .enumerate_archives_parallel()
                .into_iter()
                .map(|r| r.expect("ok").triple())
                .collect();
            assert_eq!(
                first, rep,
                "enumerate_archives_parallel run {run} must be in the same order as run 0"
            );
        }
    }

    #[test]
    fn path_verify_parallel_by_category_matches_sequential() {
        let payload_exd = b"par-exd-payload".to_vec();
        let payload_bg = b"par-bg-payload".to_vec();

        let fs = InMemoryFs::new();
        {
            let mut dat = DatBuilder::new();
            let off = dat.append_standard(&payload_exd);
            let key = hash_index1_key_for_path("exd/par-cat.exl");
            fs.insert(
                "/install/sqpack/ffxiv/0a0000.win32.index",
                make_index_file(&[(key, data_field_for_offset(off))]),
            );
            fs.insert("/install/sqpack/ffxiv/0a0000.win32.dat0", dat.into_bytes());
        }
        {
            let mut dat = DatBuilder::new();
            let off = dat.append_standard(&payload_bg);
            let key = hash_index1_key_for_path("bg/par-cat.bin");
            fs.insert(
                "/install/sqpack/ffxiv/020000.win32.index",
                make_index_file(&[(key, data_field_for_offset(off))]),
            );
            fs.insert("/install/sqpack/ffxiv/020000.win32.dat0", dat.into_bytes());
        }

        let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");

        let mut manifest = SqPackPathManifest::new();
        manifest.entries.insert(
            "exd/par-cat.exl".into(),
            PathManifestEntry::new([0xAA; 32], u32::try_from(payload_exd.len()).unwrap()),
        );
        manifest.entries.insert(
            "bg/par-cat.bin".into(),
            PathManifestEntry::new([0xBB; 32], u32::try_from(payload_bg.len()).unwrap()),
        );

        let sequential = SqPackPathVerifier::new(&sq).verify(&manifest);
        let parallel = SqPackPathVerifier::new(&sq).verify_parallel(&manifest);

        assert_eq!(
            sequential, parallel,
            "parallel verify report must match sequential for two-category mismatch fixture"
        );

        let seq_grouped = sequential.by_category();
        let par_grouped = parallel.by_category();
        assert_eq!(seq_grouped.len(), par_grouped.len());

        let exd = Category::try_from_name("exd").unwrap();
        let bg = Category::try_from_name("bg").unwrap();
        assert_eq!(seq_grouped[&exd].len(), par_grouped[&exd].len());
        assert_eq!(seq_grouped[&bg].len(), par_grouped[&bg].len());

        let seq_ids: Vec<u8> = seq_grouped.keys().map(|c| c.id()).collect();
        let par_ids: Vec<u8> = par_grouped.keys().map(|c| c.id()).collect();
        assert_eq!(seq_ids, par_ids, "by_category key order must match");
        assert_eq!(seq_ids, vec![0x02, 0x0a]);
    }

    #[test]
    fn hash_build_parallel_issues_ordering_matches_sequential() {
        let fs = InMemoryFs::new();

        {
            let mut dat = DatBuilder::new();
            let off = dat.append_standard(b"build-issue-std");
            let key = hash_index1_key_for_path("exd/build-issue.exh");
            fs.insert(
                "/install/sqpack/ffxiv/0a0000.win32.index",
                make_index_file(&[(key, data_field_for_offset(off))]),
            );
            fs.insert("/install/sqpack/ffxiv/0a0000.win32.dat0", dat.into_bytes());
        }

        fs.insert("/install/sqpack/ffxiv/020000.win32.index", Vec::<u8>::new());
        {
            let mut dat = DatBuilder::new();
            dat.append_standard(b"bg-unreachable");
            fs.insert("/install/sqpack/ffxiv/020000.win32.dat0", dat.into_bytes());
        }

        {
            let mut dat = DatBuilder::new();
            let off = dat.append_empty_stub();
            let key = hash_index1_key_for_path("exd/build-issue-empty.exl");
            fs.insert(
                "/install/sqpack/ffxiv/0a0001.win32.index",
                make_index_file(&[(key, data_field_for_offset(off))]),
            );
            fs.insert("/install/sqpack/ffxiv/0a0001.win32.dat0", dat.into_bytes());
        }

        let sq = SqPack::open_with_vfs("/install", Arc::new(fs)).expect("open");

        let sequential = SqPackHashManifestBuilder::new(&sq).build().expect("build");
        let parallel = SqPackHashManifestBuilder::new(&sq)
            .build_parallel()
            .expect("build_parallel");

        assert_eq!(
            sequential.manifest, parallel.manifest,
            "successfully built manifest entries must match"
        );

        assert_eq!(
            sequential.issues.len(),
            parallel.issues.len(),
            "sequential and parallel must record the same number of issues"
        );

        let seq_kinds: Vec<String> = sequential
            .issues
            .iter()
            .map(|i| format!("{:?}", i.kind))
            .collect();
        let par_kinds: Vec<String> = parallel
            .issues
            .iter()
            .map(|i| format!("{:?}", i.kind))
            .collect();
        assert_eq!(
            seq_kinds, par_kinds,
            "issue kinds must be in the same order between sequential and parallel"
        );
    }
}
