use std::path::PathBuf;
use std::sync::Arc;

use sqpack_rs::route::ArchiveTriple;
use sqpack_rs::vfs::{InMemoryFs, VfsWrite};
use sqpack_rs::write::{
    APPEND_MAX_FILE_SIZE, REPACK_MAX_FILES, RepackedArchive, SqPackAppender, SqPackRepacker,
};
use sqpack_rs::{Error, Platform, SqPack, Vfs};

const SQPACK_HEADER_SIZE: u32 = 1024;

fn exd_triple() -> ArchiveTriple {
    ArchiveTriple::from_game_path("exd/probe.exh").unwrap()
}

fn install_three_files(fs: &InMemoryFs, archive: &RepackedArchive) {
    fs.insert(
        "/install/sqpack/ffxiv/0a0000.win32.dat0",
        archive.dat.clone(),
    );
    if let Some(idx) = archive.index.as_ref() {
        fs.insert("/install/sqpack/ffxiv/0a0000.win32.index", idx.clone());
    }
    if let Some(idx2) = archive.index2.as_ref() {
        fs.insert("/install/sqpack/ffxiv/0a0000.win32.index2", idx2.clone());
    }
}

fn read_all(install: &InMemoryFs) -> SqPack {
    SqPack::open_with_vfs("/install", Arc::new(install.clone())).unwrap()
}

#[test]
fn round_trip_single_small_file() {
    let mut r = SqPackRepacker::new(exd_triple(), Platform::Win32)
        .with_index_v1()
        .with_index_v2();
    r.add_file("exd/a.exd", b"hello".to_vec()).unwrap();
    let arc = r.build().unwrap();

    let fs = InMemoryFs::new();
    install_three_files(&fs, &arc);
    let sq = read_all(&fs);
    assert_eq!(sq.read_file("exd/a.exd").unwrap(), b"hello");
}

#[test]
fn round_trip_two_files() {
    let mut r = SqPackRepacker::new(exd_triple(), Platform::Win32)
        .with_index_v1()
        .with_index_v2();
    r.add_file("exd/a.exd", b"alpha".to_vec()).unwrap();
    r.add_file("exd/b.exd", b"beta".to_vec()).unwrap();
    let arc = r.build().unwrap();

    let fs = InMemoryFs::new();
    install_three_files(&fs, &arc);
    let sq = read_all(&fs);
    assert_eq!(sq.read_file("exd/a.exd").unwrap(), b"alpha");
    assert_eq!(sq.read_file("exd/b.exd").unwrap(), b"beta");
}

#[test]
fn round_trip_ten_files() {
    let mut r = SqPackRepacker::new(exd_triple(), Platform::Win32)
        .with_index_v1()
        .with_index_v2();
    for i in 0..10 {
        let name = format!("exd/f{i:02}.exd");
        let payload = format!("payload-{i}").into_bytes();
        r.add_file(name, payload).unwrap();
    }
    let arc = r.build().unwrap();
    let fs = InMemoryFs::new();
    install_three_files(&fs, &arc);
    let sq = read_all(&fs);
    for i in 0..10 {
        let name = format!("exd/f{i:02}.exd");
        let expected = format!("payload-{i}").into_bytes();
        let got = sq.read_file(&name).unwrap();
        assert_eq!(got, expected, "mismatch on {name}");
    }
}

#[test]
fn round_trip_one_hundred_files() {
    let mut r = SqPackRepacker::new(exd_triple(), Platform::Win32)
        .with_index_v1()
        .with_index_v2();
    for i in 0u32..100 {
        let name = format!("exd/big{i:03}.exd");
        let payload: Vec<u8> = (0u32..32)
            .map(|j| u8::try_from((i + j) % 251).expect("modulo 251 fits u8"))
            .collect();
        r.add_file(name, payload).unwrap();
    }
    let arc = r.build().unwrap();
    let fs = InMemoryFs::new();
    install_three_files(&fs, &arc);
    let sq = read_all(&fs);
    for i in 0u32..100 {
        let name = format!("exd/big{i:03}.exd");
        let expected: Vec<u8> = (0u32..32)
            .map(|j| u8::try_from((i + j) % 251).expect("modulo 251 fits u8"))
            .collect();
        let got = sq.read_file(&name).unwrap();
        assert_eq!(got, expected, "mismatch on {name}");
    }
}

#[test]
fn round_trip_empty_payload() {
    let mut r = SqPackRepacker::new(exd_triple(), Platform::Win32).with_index_v1();
    r.add_file("exd/empty.exd", Vec::<u8>::new()).unwrap();
    let arc = r.build().unwrap();
    let fs = InMemoryFs::new();
    install_three_files(&fs, &arc);
    let sq = read_all(&fs);
    assert_eq!(sq.read_file("exd/empty.exd").unwrap(), Vec::<u8>::new());
}

#[test]
fn round_trip_multi_block_payload() {
    let mut r = SqPackRepacker::new(exd_triple(), Platform::Win32).with_index_v1();
    let payload: Vec<u8> = (0u32..(64 * 1024))
        .map(|i| u8::try_from(i % 251).expect("modulo 251 fits u8"))
        .collect();
    r.add_file("exd/big.exd", payload.clone()).unwrap();
    let arc = r.build().unwrap();
    let fs = InMemoryFs::new();
    install_three_files(&fs, &arc);
    let sq = read_all(&fs);
    assert_eq!(sq.read_file("exd/big.exd").unwrap(), payload);
}

#[test]
fn round_trip_with_v1_only() {
    let mut r = SqPackRepacker::new(exd_triple(), Platform::Win32).with_index_v1();
    r.add_file("exd/v1.exd", b"only-v1".to_vec()).unwrap();
    let arc = r.build().unwrap();
    assert!(arc.index.is_some());
    assert!(arc.index2.is_none());

    let fs = InMemoryFs::new();
    install_three_files(&fs, &arc);
    let sq = read_all(&fs);
    assert_eq!(sq.read_file("exd/v1.exd").unwrap(), b"only-v1");
}

#[test]
fn round_trip_with_v2_only() {
    let mut r = SqPackRepacker::new(exd_triple(), Platform::Win32).with_index_v2();
    r.add_file("exd/v2.exd", b"only-v2".to_vec()).unwrap();
    let arc = r.build().unwrap();
    assert!(arc.index.is_none());
    assert!(arc.index2.is_some());

    let fs = InMemoryFs::new();
    install_three_files(&fs, &arc);
    let sq = read_all(&fs);
    assert_eq!(sq.read_file("exd/v2.exd").unwrap(), b"only-v2");
}

#[test]
fn round_trip_with_both_indexes() {
    let mut r = SqPackRepacker::new(exd_triple(), Platform::Win32)
        .with_index_v1()
        .with_index_v2();
    r.add_file("exd/both.exd", b"both-indexes".to_vec())
        .unwrap();
    let arc = r.build().unwrap();
    assert!(arc.index.is_some());
    assert!(arc.index2.is_some());

    let fs = InMemoryFs::new();
    install_three_files(&fs, &arc);
    let sq = read_all(&fs);
    assert_eq!(sq.read_file("exd/both.exd").unwrap(), b"both-indexes");
}

#[test]
fn round_trip_mixed_payload_sizes() {
    let mut r = SqPackRepacker::new(exd_triple(), Platform::Win32).with_index_v1();
    let sizes = [0usize, 1, 64, 1024, 8 * 1024, 32 * 1024, 100 * 1024];
    for (i, &sz) in sizes.iter().enumerate() {
        let name = format!("exd/mix{i:02}.exd");
        let payload: Vec<u8> = (0..sz)
            .map(|j| u8::try_from((i + j) % 251).expect("modulo 251 fits u8"))
            .collect();
        r.add_file(name, payload).unwrap();
    }
    let arc = r.build().unwrap();
    let fs = InMemoryFs::new();
    install_three_files(&fs, &arc);
    let sq = read_all(&fs);
    for (i, &sz) in sizes.iter().enumerate() {
        let name = format!("exd/mix{i:02}.exd");
        let expected: Vec<u8> = (0..sz)
            .map(|j| u8::try_from((i + j) % 251).expect("modulo 251 fits u8"))
            .collect();
        let got = sq.read_file(&name).unwrap();
        assert_eq!(got.len(), sz, "size mismatch on {name}");
        assert_eq!(got, expected, "byte mismatch on {name}");
    }
}

#[test]
fn determinism_round_trip_byte_equal() {
    let mut r = SqPackRepacker::new(exd_triple(), Platform::Win32)
        .with_index_v1()
        .with_index_v2();
    for i in 0..5 {
        let name = format!("exd/det{i}.exd");
        let payload = format!("det-{i}-payload").into_bytes();
        r.add_file(name, payload).unwrap();
    }
    let a = r.build().unwrap();
    let b = r.build().unwrap();
    assert_eq!(a.dat, b.dat, "dat bytes diverge across builds");
    assert_eq!(a.index, b.index, "index bytes diverge");
    assert_eq!(a.index2, b.index2, "index2 bytes diverge");
}

#[test]
fn determinism_input_order_does_not_matter() {
    let triple = exd_triple();

    let mut r1 = SqPackRepacker::new(triple, Platform::Win32)
        .with_index_v1()
        .with_index_v2();
    r1.add_file("exd/a.exd", b"alpha".to_vec()).unwrap();
    r1.add_file("exd/b.exd", b"beta".to_vec()).unwrap();
    r1.add_file("exd/c.exd", b"gamma".to_vec()).unwrap();
    let archive_in_order = r1.build().unwrap();

    let mut r2 = SqPackRepacker::new(triple, Platform::Win32)
        .with_index_v1()
        .with_index_v2();
    r2.add_file("exd/c.exd", b"gamma".to_vec()).unwrap();
    r2.add_file("exd/a.exd", b"alpha".to_vec()).unwrap();
    r2.add_file("exd/b.exd", b"beta".to_vec()).unwrap();
    let archive_reordered = r2.build().unwrap();

    assert_eq!(
        archive_in_order.dat, archive_reordered.dat,
        "dat bytes must not depend on input order"
    );
    assert_eq!(
        archive_in_order.index, archive_reordered.index,
        "index bytes must not depend on input order"
    );
    assert_eq!(
        archive_in_order.index2, archive_reordered.index2,
        "index2 bytes must not depend on input order"
    );
}

#[test]
fn determinism_across_repacker_recreate() {
    let inputs: Vec<(&'static str, Vec<u8>)> = vec![
        ("exd/p.exd", b"phi".to_vec()),
        ("exd/q.exd", b"chi".to_vec()),
        ("exd/r.exd", b"psi".to_vec()),
    ];
    let triple = exd_triple();

    let mut r1 = SqPackRepacker::new(triple, Platform::Win32)
        .with_index_v1()
        .with_index_v2();
    for (path, bytes) in &inputs {
        r1.add_file(*path, bytes.clone()).unwrap();
    }
    let a = r1.build().unwrap();
    drop(r1);

    let mut r2 = SqPackRepacker::new(triple, Platform::Win32)
        .with_index_v1()
        .with_index_v2();
    for (path, bytes) in &inputs {
        r2.add_file(*path, bytes.clone()).unwrap();
    }
    let b = r2.build().unwrap();

    assert_eq!(a.dat, b.dat);
    assert_eq!(a.index, b.index);
    assert_eq!(a.index2, b.index2);
}

#[test]
fn determinism_holds_for_v1_only() {
    let triple = exd_triple();
    let mut r1 = SqPackRepacker::new(triple, Platform::Win32).with_index_v1();
    r1.add_file("exd/x.exd", b"X".to_vec()).unwrap();
    r1.add_file("exd/y.exd", b"Y".to_vec()).unwrap();
    let a = r1.build().unwrap();

    let mut r2 = SqPackRepacker::new(triple, Platform::Win32).with_index_v1();
    r2.add_file("exd/y.exd", b"Y".to_vec()).unwrap();
    r2.add_file("exd/x.exd", b"X".to_vec()).unwrap();
    let b = r2.build().unwrap();

    assert_eq!(a.dat, b.dat);
    assert_eq!(a.index, b.index);
    assert!(a.index2.is_none() && b.index2.is_none());
}

#[test]
fn determinism_holds_for_v2_only() {
    let triple = exd_triple();
    let mut r1 = SqPackRepacker::new(triple, Platform::Win32).with_index_v2();
    r1.add_file("exd/p.exd", b"P".to_vec()).unwrap();
    r1.add_file("exd/q.exd", b"Q".to_vec()).unwrap();
    let a = r1.build().unwrap();

    let mut r2 = SqPackRepacker::new(triple, Platform::Win32).with_index_v2();
    r2.add_file("exd/q.exd", b"Q".to_vec()).unwrap();
    r2.add_file("exd/p.exd", b"P".to_vec()).unwrap();
    let b = r2.build().unwrap();

    assert_eq!(a.dat, b.dat);
    assert_eq!(a.index2, b.index2);
    assert!(a.index.is_none() && b.index.is_none());
}

#[test]
fn determinism_dat_has_no_uninitialised_padding() {
    let mut r = SqPackRepacker::new(exd_triple(), Platform::Win32).with_index_v1();
    r.add_file("exd/a.exd", b"x".to_vec()).unwrap();
    let a = r.build().unwrap();
    let b = r.build().unwrap();
    assert_eq!(a.dat[..1024], b.dat[..1024]);
    for (i, byte) in a.dat[24..1024].iter().enumerate() {
        assert_eq!(*byte, 0, "header padding byte {} is non-zero", 24 + i);
    }
}

#[test]
fn entries_are_sorted_by_hash_in_v1_index() {
    let mut r = SqPackRepacker::new(exd_triple(), Platform::Win32).with_index_v1();
    r.add_file("exd/zzzz.exd", b"Z".to_vec()).unwrap();
    r.add_file("exd/aaaa.exd", b"A".to_vec()).unwrap();
    r.add_file("exd/mmmm.exd", b"M".to_vec()).unwrap();
    let arc = r.build().unwrap();
    let fs = InMemoryFs::new();
    install_three_files(&fs, &arc);
    let idx = sqpack_rs::Index::open_in(
        std::path::Path::new("/install/sqpack/ffxiv/0a0000.win32.index"),
        &fs,
    )
    .unwrap();
    let hashes: Vec<u64> = idx.entries().iter().map(|e| e.hash).collect();
    for w in hashes.windows(2) {
        assert!(
            w[0] <= w[1],
            "v1 hashes not sorted: {:#x} > {:#x}",
            w[0],
            w[1]
        );
    }
}

#[test]
fn entries_are_sorted_by_hash_in_v2_index() {
    let mut r = SqPackRepacker::new(exd_triple(), Platform::Win32).with_index_v2();
    r.add_file("exd/zzzz.exd", b"Z".to_vec()).unwrap();
    r.add_file("exd/aaaa.exd", b"A".to_vec()).unwrap();
    r.add_file("exd/mmmm.exd", b"M".to_vec()).unwrap();
    let arc = r.build().unwrap();
    let fs = InMemoryFs::new();
    install_three_files(&fs, &arc);
    let idx2 = sqpack_rs::Index2::open_in(
        std::path::Path::new("/install/sqpack/ffxiv/0a0000.win32.index2"),
        &fs,
    )
    .unwrap();
    let hashes: Vec<u32> = idx2.entries().iter().map(|e| e.hash).collect();
    for w in hashes.windows(2) {
        assert!(w[0] <= w[1], "v2 hashes not sorted");
    }
}

#[test]
fn build_zero_inputs_is_empty_archive_error() {
    let r = SqPackRepacker::new(exd_triple(), Platform::Win32).with_index_v1();
    let err = r.build().unwrap_err();
    assert!(matches!(err, Error::RepackEmptyArchive));
}

#[test]
fn build_no_index_selected_is_error() {
    let mut r = SqPackRepacker::new(exd_triple(), Platform::Win32);
    r.add_file("exd/x.exd", b"x".to_vec()).unwrap();
    let err = r.build().unwrap_err();
    assert!(matches!(err, Error::RepackNoIndexSelected));
}

#[test]
fn duplicate_path_in_v1_input_is_error() {
    let mut r = SqPackRepacker::new(exd_triple(), Platform::Win32).with_index_v1();
    r.add_file("exd/dup.exd", b"first".to_vec()).unwrap();
    let err = r.add_file("exd/dup.exd", b"second".to_vec()).unwrap_err();
    match err {
        Error::DuplicatePath { game_path, kind } => {
            assert_eq!(game_path, "exd/dup.exd");
            assert_eq!(kind, "v1");
        }
        other => panic!("expected DuplicatePath, got {other:?}"),
    }
}

#[test]
fn duplicate_path_in_v2_input_is_error() {
    let mut r = SqPackRepacker::new(exd_triple(), Platform::Win32).with_index_v2();
    r.add_file("exd/dup.exd", b"first".to_vec()).unwrap();
    let err = r.add_file("exd/dup.exd", b"second".to_vec()).unwrap_err();
    match err {
        Error::DuplicatePath { kind, .. } => assert_eq!(kind, "v2"),
        other => panic!("expected DuplicatePath, got {other:?}"),
    }
}

#[test]
fn duplicate_path_check_runs_against_both_when_both_enabled() {
    let mut r = SqPackRepacker::new(exd_triple(), Platform::Win32)
        .with_index_v1()
        .with_index_v2();
    r.add_file("exd/dup.exd", b"first".to_vec()).unwrap();
    let err = r.add_file("exd/dup.exd", b"second".to_vec()).unwrap_err();
    match err {
        Error::DuplicatePath { kind, .. } => {
            assert_eq!(kind, "v1", "v1 collision check must run before v2");
        }
        other => panic!("expected DuplicatePath, got {other:?}"),
    }
}

#[test]
fn add_file_dedup_disabled_when_neither_index_emitted() {
    let mut r = SqPackRepacker::new(exd_triple(), Platform::Win32);
    r.add_file("exd/x.exd", b"a".to_vec()).unwrap();
    r.add_file("exd/x.exd", b"b".to_vec()).unwrap();
    assert_eq!(r.input_count(), 2);
}

#[test]
fn add_file_rejects_oversized_input_via_error_field() {
    let err = Error::AppendFileTooLarge {
        size: (1u64 << 30) + 1,
        max: 1u64 << 30,
    };
    assert!(format!("{err}").contains("exceeds maximum"));
}

#[test]
#[ignore = "allocates 1 GiB + 1 byte; run with `cargo test -- --ignored`"]
fn add_file_rejects_oversized_payload_ignored() {
    let oversize = usize::try_from(APPEND_MAX_FILE_SIZE + 1)
        .expect("APPEND_MAX_FILE_SIZE + 1 fits usize on 64-bit");
    let payload = vec![0u8; oversize];
    let mut r = SqPackRepacker::new(exd_triple(), Platform::Win32).with_index_v1();
    let err = r
        .add_file("exd/toobig.exd", payload)
        .expect_err("add_file must reject oversized payload");
    assert!(
        matches!(err, Error::AppendFileTooLarge { size, max }
            if size == APPEND_MAX_FILE_SIZE + 1 && max == APPEND_MAX_FILE_SIZE),
        "unexpected error variant or fields: {err:?}",
    );
    assert_eq!(
        r.input_count(),
        0,
        "rejected oversized input must not be retained"
    );
}

#[test]
fn dat_max_size_const_pinned_at_2_gb() {
    use sqpack_rs::write::DAT_MAX_SIZE;
    assert_eq!(DAT_MAX_SIZE, 2_000_000_000);
}

#[test]
fn add_file_rejects_empty_game_path() {
    let mut r = SqPackRepacker::new(exd_triple(), Platform::Win32).with_index_v1();
    let err = r.add_file("", b"x".to_vec()).unwrap_err();
    match err {
        Error::UnknownCategory { name } => {
            assert_eq!(name, "");
        }
        other => panic!("expected UnknownCategory for empty path, got {other:?}"),
    }
    assert_eq!(r.input_count(), 0, "rejected input must not be retained");
}

#[test]
fn add_file_rejects_wrong_archive_route() {
    let mut r = SqPackRepacker::new(exd_triple(), Platform::Win32).with_index_v1();
    let err = r
        .add_file("bg/ffxiv/sea_s1/foo.lgb", b"x".to_vec())
        .unwrap_err();
    match err {
        Error::PathRoutesToWrongArchive {
            game_path,
            expected,
            found,
        } => {
            assert_eq!(game_path, "bg/ffxiv/sea_s1/foo.lgb");
            assert_eq!(expected, exd_triple());
            assert_ne!(found, exd_triple());
        }
        other => panic!("expected PathRoutesToWrongArchive, got {other:?}"),
    }
    assert_eq!(r.input_count(), 0, "rejected input must not be retained");
}

#[test]
fn build_rejects_invalid_platform() {
    let mut r = SqPackRepacker::new(exd_triple(), Platform::Unknown(99)).with_index_v1();
    r.add_file("exd/x.exd", b"x".to_vec()).unwrap();
    let fs = InMemoryFs::new();
    let vfs_arc: Arc<InMemoryFs> = Arc::new(fs);
    let vfs: &dyn VfsWrite = &*vfs_arc;
    let err = r.write_to("/install", vfs).unwrap_err();
    assert!(matches!(err, Error::UnsupportedPlatform { .. }));
}

#[test]
fn error_display_for_new_variants() {
    let e = Error::DuplicatePath {
        game_path: "exd/x.exd".to_string(),
        kind: "v1",
    };
    assert!(format!("{e}").contains("exd/x.exd"));
    assert!(format!("{e}").contains("v1"));

    let e = Error::RepackEmptyArchive;
    assert!(format!("{e}").contains("no input files"));

    let e = Error::RepackTooManyFiles {
        count: 100_000,
        max: REPACK_MAX_FILES,
    };
    assert!(format!("{e}").contains("100000"));

    let e = Error::RepackNoIndexSelected;
    assert!(format!("{e}").contains("at least one"));

    let e = Error::PathRoutesToWrongArchive {
        game_path: "bg/ffxiv/sea_s1/foo.lgb".to_string(),
        expected: exd_triple(),
        found: ArchiveTriple::from_game_path("bg/ffxiv/sea_s1/foo.lgb").unwrap(),
    };
    let rendered = format!("{e}");
    assert!(rendered.contains("bg/ffxiv/sea_s1/foo.lgb"));
    assert!(rendered.contains("repacker was built for"));
}

#[test]
fn repack_max_files_const_pinned() {
    assert_eq!(REPACK_MAX_FILES, 65_536);
}

#[test]
fn add_file_rejects_at_max_files_cap() {
    let triple = exd_triple();
    let mut repacker = SqPackRepacker::new(triple, Platform::Win32).with_index_v1();

    for i in 0..REPACK_MAX_FILES {
        repacker
            .add_file(format!("exd/path_{i:05}.exd"), vec![0u8; 1])
            .expect("under cap");
    }
    assert_eq!(repacker.input_count(), REPACK_MAX_FILES);

    let err = repacker
        .add_file("exd/over_cap.exd", b"x".to_vec())
        .expect_err("over cap must reject");
    assert!(
        matches!(err, Error::RepackTooManyFiles { count, max }
            if count == REPACK_MAX_FILES + 1 && max == REPACK_MAX_FILES),
        "unexpected error: {err:?}",
    );
}

#[test]
fn api_with_index_v1_only_yields_v1_only_archive() {
    let mut r = SqPackRepacker::new(exd_triple(), Platform::Win32).with_index_v1();
    r.add_file("exd/x.exd", b"x".to_vec()).unwrap();
    let arc = r.build().unwrap();
    assert!(arc.index.is_some());
    assert!(arc.index2.is_none());
    assert!(!arc.dat.is_empty());
}

#[test]
fn api_with_index_v2_only_yields_v2_only_archive() {
    let mut r = SqPackRepacker::new(exd_triple(), Platform::Win32).with_index_v2();
    r.add_file("exd/x.exd", b"x".to_vec()).unwrap();
    let arc = r.build().unwrap();
    assert!(arc.index.is_none());
    assert!(arc.index2.is_some());
}

#[test]
fn api_both_indexes_yields_both() {
    let mut r = SqPackRepacker::new(exd_triple(), Platform::Win32)
        .with_index_v1()
        .with_index_v2();
    r.add_file("exd/x.exd", b"x".to_vec()).unwrap();
    let arc = r.build().unwrap();
    assert!(arc.index.is_some());
    assert!(arc.index2.is_some());
}

#[test]
fn api_input_count_reflects_added_files() {
    let mut r = SqPackRepacker::new(exd_triple(), Platform::Win32).with_index_v1();
    assert_eq!(r.input_count(), 0);
    r.add_file("exd/a.exd", b"a".to_vec()).unwrap();
    assert_eq!(r.input_count(), 1);
    r.add_file("exd/b.exd", b"b".to_vec()).unwrap();
    assert_eq!(r.input_count(), 2);
}

#[test]
fn api_emits_index_v1_v2_predicates() {
    let r = SqPackRepacker::new(exd_triple(), Platform::Win32);
    assert!(!r.emits_index_v1());
    assert!(!r.emits_index_v2());

    let r = SqPackRepacker::new(exd_triple(), Platform::Win32).with_index_v1();
    assert!(r.emits_index_v1());
    assert!(!r.emits_index_v2());

    let r = SqPackRepacker::new(exd_triple(), Platform::Win32).with_index_v2();
    assert!(!r.emits_index_v1());
    assert!(r.emits_index_v2());
}

#[test]
fn write_to_lands_all_three_files() {
    let mut r = SqPackRepacker::new(exd_triple(), Platform::Win32)
        .with_index_v1()
        .with_index_v2();
    r.add_file("exd/a.exd", b"alpha".to_vec()).unwrap();
    r.add_file("exd/b.exd", b"beta".to_vec()).unwrap();

    let fs_arc: Arc<InMemoryFs> = Arc::new(InMemoryFs::new());
    let vfs: &dyn VfsWrite = &*fs_arc;
    r.write_to("/install", vfs).unwrap();

    let snapshot = fs_arc.snapshot_files();
    assert!(snapshot.contains_key(&PathBuf::from("/install/sqpack/ffxiv/0a0000.win32.dat0")));
    assert!(snapshot.contains_key(&PathBuf::from("/install/sqpack/ffxiv/0a0000.win32.index")));
    assert!(snapshot.contains_key(&PathBuf::from("/install/sqpack/ffxiv/0a0000.win32.index2")));

    let read_fs: Arc<dyn Vfs> = fs_arc as Arc<dyn Vfs>;
    let sq = SqPack::open_with_vfs("/install", read_fs).unwrap();
    assert_eq!(sq.read_file("exd/a.exd").unwrap(), b"alpha");
    assert_eq!(sq.read_file("exd/b.exd").unwrap(), b"beta");
}

#[test]
fn write_to_omits_index_when_not_selected() {
    let mut r = SqPackRepacker::new(exd_triple(), Platform::Win32).with_index_v2();
    r.add_file("exd/a.exd", b"alpha".to_vec()).unwrap();

    let fs_arc: Arc<InMemoryFs> = Arc::new(InMemoryFs::new());
    let vfs: &dyn VfsWrite = &*fs_arc;
    r.write_to("/install", vfs).unwrap();

    let snapshot = fs_arc.snapshot_files();
    assert!(snapshot.contains_key(&PathBuf::from("/install/sqpack/ffxiv/0a0000.win32.dat0")));
    assert!(!snapshot.contains_key(&PathBuf::from("/install/sqpack/ffxiv/0a0000.win32.index")));
    assert!(snapshot.contains_key(&PathBuf::from("/install/sqpack/ffxiv/0a0000.win32.index2")));
}

struct FailingVfsWrite {
    inner: Arc<InMemoryFs>,
    rename_count: std::sync::Mutex<usize>,
    fail_rename_after: usize,
}

impl FailingVfsWrite {
    fn new(inner: Arc<InMemoryFs>, fail_rename_after: usize) -> Self {
        Self {
            inner,
            rename_count: std::sync::Mutex::new(0),
            fail_rename_after,
        }
    }
}

impl sqpack_rs::vfs::VfsWrite for FailingVfsWrite {
    fn open_write(
        &self,
        path: &std::path::Path,
    ) -> std::io::Result<Box<dyn sqpack_rs::vfs::VfsWriteHandle>> {
        self.inner.open_write(path)
    }

    fn open_append(
        &self,
        path: &std::path::Path,
    ) -> std::io::Result<Box<dyn sqpack_rs::vfs::VfsWriteHandle>> {
        self.inner.open_append(path)
    }

    fn rename(&self, from: &std::path::Path, to: &std::path::Path) -> std::io::Result<()> {
        let mut count = self.rename_count.lock().unwrap();
        *count += 1;
        if *count > self.fail_rename_after {
            return Err(std::io::Error::other(format!(
                "FailingVfsWrite injected rename failure on call {count}"
            )));
        }
        drop(count);
        self.inner.rename(from, to)
    }

    fn remove_file(&self, path: &std::path::Path) -> std::io::Result<()> {
        self.inner.remove_file(path)
    }
}

#[test]
fn write_to_with_failing_rename_returns_error() {
    let mut r = SqPackRepacker::new(exd_triple(), Platform::Win32).with_index_v1();
    r.add_file("exd/a.exd", b"alpha".to_vec()).unwrap();

    let inner: Arc<InMemoryFs> = Arc::new(InMemoryFs::new());
    let failing = FailingVfsWrite::new(inner.clone(), 0);
    let err = r.write_to("/install", &failing).unwrap_err();
    assert!(matches!(err, Error::WriteAtomicityFailure { .. }));

    let snapshot = inner.snapshot_files();
    assert!(!snapshot.contains_key(&PathBuf::from("/install/sqpack/ffxiv/0a0000.win32.dat0")));
}

#[test]
fn write_to_with_dat_landed_index_fails_leaves_dat_visible() {
    let mut r = SqPackRepacker::new(exd_triple(), Platform::Win32).with_index_v1();
    r.add_file("exd/a.exd", b"alpha".to_vec()).unwrap();

    let inner: Arc<InMemoryFs> = Arc::new(InMemoryFs::new());
    let failing = FailingVfsWrite::new(inner.clone(), 1);
    let err = r.write_to("/install", &failing).unwrap_err();
    assert!(matches!(err, Error::WriteAtomicityFailure { .. }));

    let snapshot = inner.snapshot_files();
    assert!(snapshot.contains_key(&PathBuf::from("/install/sqpack/ffxiv/0a0000.win32.dat0")));
    assert!(!snapshot.contains_key(&PathBuf::from("/install/sqpack/ffxiv/0a0000.win32.index")));
}

#[test]
fn write_to_install_stdfs_smoke() {
    let mut root = std::env::temp_dir();
    root.push(format!("sqpack-rs-repack-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&root);
    std::fs::create_dir_all(root.join("sqpack/ffxiv")).unwrap();

    let mut r = SqPackRepacker::new(exd_triple(), Platform::Win32)
        .with_index_v1()
        .with_index_v2();
    r.add_file("exd/smoke.exd", b"smoke-bytes".to_vec())
        .unwrap();
    r.write_to_install(&root).unwrap();

    let sq = SqPack::open(&root).unwrap();
    let bytes = sq.read_file("exd/smoke.exd").unwrap();
    assert_eq!(bytes, b"smoke-bytes");

    let _ = std::fs::remove_dir_all(&root);
}

const SQPACK_MAGIC: [u8; 8] = *b"SqPack\0\0";

fn make_sqpack_header_with_pad(file_type: u32, pad_byte: u8) -> 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());
    while buf.len() < SQPACK_HEADER_SIZE as usize {
        buf.push(pad_byte);
    }
    buf
}

fn make_index_header(
    index_data_offset: u32,
    index_data_size: u32,
    number_of_data_file: u32,
) -> Vec<u8> {
    let mut buf = Vec::with_capacity(1024);
    buf.extend_from_slice(&1024u32.to_le_bytes());
    buf.extend_from_slice(&1u32.to_le_bytes());
    buf.extend_from_slice(&index_data_offset.to_le_bytes());
    buf.extend_from_slice(&index_data_size.to_le_bytes());
    buf.resize(buf.len() + 64, 0);
    buf.extend_from_slice(&number_of_data_file.to_le_bytes());
    let synonym_offset = index_data_offset + index_data_size;
    buf.extend_from_slice(&synonym_offset.to_le_bytes());
    buf.extend_from_slice(&0u32.to_le_bytes());
    buf.resize(buf.len() + 64, 0);
    buf.extend_from_slice(&synonym_offset.to_le_bytes());
    buf.extend_from_slice(&0u32.to_le_bytes());
    buf.resize(buf.len() + 64, 0);
    buf.extend_from_slice(&synonym_offset.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_padded(entries: &[(u64, u32)], pad_byte: u8) -> Vec<u8> {
    use sqpack_rs::index::INDEX_ENTRY_SIZE;
    let mut buf = Vec::new();
    buf.extend_from_slice(&make_sqpack_header_with_pad(2, pad_byte));
    let index_data_offset = SQPACK_HEADER_SIZE + 1024;
    let index_data_size = u32::try_from(entries.len()).unwrap() * INDEX_ENTRY_SIZE;
    buf.extend_from_slice(&make_index_header(index_data_offset, index_data_size, 1));
    for (hash, data) in entries {
        buf.extend_from_slice(&hash.to_le_bytes());
        buf.extend_from_slice(&data.to_le_bytes());
        buf.extend_from_slice(&0u32.to_le_bytes());
    }
    buf
}

fn make_index2_file_padded(entries: &[(u32, u32)], pad_byte: u8) -> Vec<u8> {
    use sqpack_rs::index2::INDEX2_ENTRY_SIZE;
    let mut buf = Vec::new();
    buf.extend_from_slice(&make_sqpack_header_with_pad(2, pad_byte));
    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, 1));
    for (hash, data) in entries {
        buf.extend_from_slice(&hash.to_le_bytes());
        buf.extend_from_slice(&data.to_le_bytes());
    }
    buf
}

fn make_appender_install_padded(pad_byte: u8) -> InMemoryFs {
    let fs = InMemoryFs::new();
    fs.insert(
        "/install/sqpack/ffxiv/0a0000.win32.index",
        make_index_file_padded(&[], pad_byte),
    );
    fs.insert(
        "/install/sqpack/ffxiv/0a0000.win32.index2",
        make_index2_file_padded(&[], pad_byte),
    );
    fs.insert(
        "/install/sqpack/ffxiv/0a0000.win32.dat0",
        make_sqpack_header_with_pad(1, pad_byte),
    );
    fs
}

fn make_empty_appender_install() -> InMemoryFs {
    make_appender_install_padded(0)
}

fn appender_dat_for_single_file(fs: &InMemoryFs, path: &str, bytes: &[u8]) -> Vec<u8> {
    let fs_arc: Arc<InMemoryFs> = Arc::new(fs.clone());
    let mut writer = SqPackAppender::open_with_vfs(
        "/install",
        Platform::Win32,
        fs_arc.clone() as Arc<dyn Vfs>,
        fs_arc as Arc<dyn VfsWrite>,
    )
    .unwrap();
    writer.append(path, bytes).unwrap();
    writer.commit().unwrap();
    fs.snapshot_files()
        .get(&PathBuf::from("/install/sqpack/ffxiv/0a0000.win32.dat0"))
        .unwrap()
        .clone()
}

fn repacker_archive_for_single_file(path: &str, bytes: &[u8]) -> RepackedArchive {
    let mut r = SqPackRepacker::new(exd_triple(), Platform::Win32)
        .with_index_v1()
        .with_index_v2();
    r.add_file(path.to_string(), bytes.to_vec()).unwrap();
    r.build().unwrap()
}

#[test]
fn appender_repacker_one_entry_byte_divergence() {
    let append_fs = make_appender_install_padded(0xAB);
    let appender_dat = appender_dat_for_single_file(&append_fs, "exd/x.exd", b"divergence");
    let repacker_archive = repacker_archive_for_single_file("exd/x.exd", b"divergence");

    assert_ne!(
        appender_dat, repacker_archive.dat,
        "appender preserves source dat header padding (poisoned with 0xAB); \
         repacker rebuilds canonical zeros — the dats must diverge",
    );
    assert_eq!(
        &appender_dat[24..1024],
        &vec![0xABu8; 1000][..],
        "appender preserved 0xAB padding"
    );
    assert_eq!(
        &repacker_archive.dat[24..1024],
        &vec![0u8; 1000][..],
        "repacker emitted canonical zero padding"
    );

    let appender_sq = SqPack::open_with_vfs("/install", Arc::new(append_fs)).unwrap();
    let appender_bytes = appender_sq.read_file("exd/x.exd").unwrap();
    assert_eq!(appender_bytes, b"divergence");

    let fs2 = InMemoryFs::new();
    install_three_files(&fs2, &repacker_archive);
    let repacker_sq = SqPack::open_with_vfs("/install", Arc::new(fs2)).unwrap();
    let repacker_bytes = repacker_sq.read_file("exd/x.exd").unwrap();
    assert_eq!(repacker_bytes, b"divergence");
}

#[test]
fn appender_repacker_dat_byte_identical_on_canonical_empty_source() {
    let append_fs = make_empty_appender_install();
    let appender_dat = appender_dat_for_single_file(&append_fs, "exd/x.exd", b"canonical");
    let archive = repacker_archive_for_single_file("exd/x.exd", b"canonical");

    assert_eq!(
        appender_dat, archive.dat,
        "on a canonical empty source, appender and repacker produce identical dats"
    );
}

#[test]
fn dat_starts_with_sqpack_header() {
    let mut r = SqPackRepacker::new(exd_triple(), Platform::Win32).with_index_v1();
    r.add_file("exd/x.exd", b"x".to_vec()).unwrap();
    let arc = r.build().unwrap();
    assert_eq!(&arc.dat[..8], b"SqPack\0\0");
    let ft = u32::from_le_bytes(arc.dat[20..24].try_into().unwrap());
    assert_eq!(ft, 1);
}

#[test]
fn index_starts_with_sqpack_header_and_index_file_type() {
    let mut r = SqPackRepacker::new(exd_triple(), Platform::Win32).with_index_v1();
    r.add_file("exd/x.exd", b"x".to_vec()).unwrap();
    let arc = r.build().unwrap();
    let idx = arc.index.expect("v1 index selected");
    assert_eq!(&idx[..8], b"SqPack\0\0");
    let ft = u32::from_le_bytes(idx[20..24].try_into().unwrap());
    assert_eq!(ft, 2, "index file_type should be 2");
}

#[test]
fn index2_starts_with_sqpack_header_and_index_file_type() {
    let mut r = SqPackRepacker::new(exd_triple(), Platform::Win32).with_index_v2();
    r.add_file("exd/x.exd", b"x".to_vec()).unwrap();
    let arc = r.build().unwrap();
    let idx2 = arc.index2.expect("v2 index selected");
    assert_eq!(&idx2[..8], b"SqPack\0\0");
    let ft = u32::from_le_bytes(idx2[20..24].try_into().unwrap());
    assert_eq!(ft, 2);
}

#[test]
fn first_payload_byte_offset_is_128_aligned() {
    let mut r = SqPackRepacker::new(exd_triple(), Platform::Win32).with_index_v1();
    r.add_file("exd/x.exd", b"x".to_vec()).unwrap();
    let arc = r.build().unwrap();
    let fs = InMemoryFs::new();
    install_three_files(&fs, &arc);
    let idx = sqpack_rs::Index::open_in(
        std::path::Path::new("/install/sqpack/ffxiv/0a0000.win32.index"),
        &fs,
    )
    .unwrap();
    let entry = idx.entries().first().unwrap();
    let loc = entry.dat_location().unwrap();
    assert_eq!(loc.byte_offset % 128, 0);
    assert_eq!(loc.data_file_id, 0);
}

#[test]
fn rebuilt_index_carries_nonzero_sha1_hashes() {
    let mut r = SqPackRepacker::new(exd_triple(), Platform::Win32).with_index_v1();
    r.add_file("exd/x.exd", b"x".to_vec()).unwrap();
    let arc = r.build().unwrap();
    let fs = InMemoryFs::new();
    install_three_files(&fs, &arc);
    let idx = sqpack_rs::Index::open_in(
        std::path::Path::new("/install/sqpack/ffxiv/0a0000.win32.index"),
        &fs,
    )
    .unwrap();
    assert_ne!(&idx.index_header().index_data_hash[..20], &[0u8; 20]);
    assert_ne!(&idx.index_header().self_hash[..20], &[0u8; 20]);
}

#[test]
fn empty_synonym_section_is_well_formed() {
    let mut r = SqPackRepacker::new(exd_triple(), Platform::Win32).with_index_v1();
    r.add_file("exd/x.exd", b"x".to_vec()).unwrap();
    let arc = r.build().unwrap();
    let fs = InMemoryFs::new();
    install_three_files(&fs, &arc);
    let idx = sqpack_rs::Index::open_in(
        std::path::Path::new("/install/sqpack/ffxiv/0a0000.win32.index"),
        &fs,
    )
    .unwrap();
    assert_eq!(idx.index_header().synonym_data_size, 0);
    assert_eq!(idx.index_header().empty_block_data_size, 0);
    assert_eq!(idx.index_header().dir_index_data_size, 0);
}

#[test]
fn input_count_zero_after_new() {
    let r = SqPackRepacker::new(exd_triple(), Platform::Win32);
    assert_eq!(r.input_count(), 0);
}

#[test]
fn round_trip_block_size_boundary_minus_one() {
    use sqpack_rs::write::APPEND_BLOCK_SIZE;
    let mut r = SqPackRepacker::new(exd_triple(), Platform::Win32).with_index_v1();
    let payload = vec![0x55u8; (APPEND_BLOCK_SIZE as usize) - 1];
    r.add_file("exd/edge_minus.exd", payload.clone()).unwrap();
    let arc = r.build().unwrap();
    let fs = InMemoryFs::new();
    install_three_files(&fs, &arc);
    let sq = read_all(&fs);
    assert_eq!(
        sq.read_file("exd/edge_minus.exd").unwrap(),
        payload,
        "BLOCK_SIZE - 1 payload must round-trip"
    );
    let arc2 = r.build().unwrap();
    assert_eq!(
        arc.dat, arc2.dat,
        "BLOCK_SIZE - 1 dat must be deterministic"
    );
}

#[test]
fn round_trip_block_size_boundary_exact() {
    use sqpack_rs::write::APPEND_BLOCK_SIZE;
    let mut r = SqPackRepacker::new(exd_triple(), Platform::Win32).with_index_v1();
    let payload = vec![0x66u8; APPEND_BLOCK_SIZE as usize];
    r.add_file("exd/edge_exact.exd", payload.clone()).unwrap();
    let arc = r.build().unwrap();
    let fs = InMemoryFs::new();
    install_three_files(&fs, &arc);
    let sq = read_all(&fs);
    assert_eq!(
        sq.read_file("exd/edge_exact.exd").unwrap(),
        payload,
        "BLOCK_SIZE-exact payload must round-trip"
    );
    let arc2 = r.build().unwrap();
    assert_eq!(
        arc.dat, arc2.dat,
        "BLOCK_SIZE-exact dat must be deterministic"
    );
}

#[test]
fn round_trip_block_size_boundary_plus_one() {
    use sqpack_rs::write::APPEND_BLOCK_SIZE;
    let mut r = SqPackRepacker::new(exd_triple(), Platform::Win32).with_index_v1();
    let payload = vec![0x77u8; (APPEND_BLOCK_SIZE as usize) + 1];
    r.add_file("exd/edge_plus.exd", payload.clone()).unwrap();
    let arc = r.build().unwrap();
    let fs = InMemoryFs::new();
    install_three_files(&fs, &arc);
    let sq = read_all(&fs);
    assert_eq!(
        sq.read_file("exd/edge_plus.exd").unwrap(),
        payload,
        "BLOCK_SIZE + 1 payload must round-trip"
    );
    let arc2 = r.build().unwrap();
    assert_eq!(
        arc.dat, arc2.dat,
        "BLOCK_SIZE + 1 dat must be deterministic"
    );
}

#[test]
fn round_trip_archive_with_many_block_sized_payloads() {
    let mut r = SqPackRepacker::new(exd_triple(), Platform::Win32)
        .with_index_v1()
        .with_index_v2();
    for i in 0u32..20 {
        let name = format!("exd/many{i:02}.exd");
        let payload: Vec<u8> = (0u32..(32 * 1024))
            .map(|j| u8::try_from((i + j) % 251).expect("modulo 251 fits u8"))
            .collect();
        r.add_file(name, payload).unwrap();
    }
    let arc = r.build().unwrap();
    let fs = InMemoryFs::new();
    install_three_files(&fs, &arc);
    let sq = read_all(&fs);
    for i in 0u32..20 {
        let name = format!("exd/many{i:02}.exd");
        let expected: Vec<u8> = (0u32..(32 * 1024))
            .map(|j| u8::try_from((i + j) % 251).expect("modulo 251 fits u8"))
            .collect();
        let got = sq.read_file(&name).unwrap();
        assert_eq!(got, expected, "mismatch on {name}");
    }
}
