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

use sqpack_rs::dat::BLOCK_HEADER_SIZE;
use sqpack_rs::index::INDEX_ENTRY_SIZE;
use sqpack_rs::index2::INDEX2_ENTRY_SIZE;
use sqpack_rs::vfs::InMemoryFs;
use sqpack_rs::write::{APPEND_BLOCK_SIZE, AppendOutcome, SqPackAppender};
use sqpack_rs::{Error, Platform, SqPack, Vfs};

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

fn 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, 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_empty_dat0() -> Vec<u8> {
    make_sqpack_header(1)
}

fn make_install(fs: &InMemoryFs) {
    fs.insert(
        "/install/sqpack/ffxiv/0a0000.win32.index",
        make_index_file(&[]),
    );
    fs.insert(
        "/install/sqpack/ffxiv/0a0000.win32.index2",
        make_index2_file(&[]),
    );
    fs.insert("/install/sqpack/ffxiv/0a0000.win32.dat0", make_empty_dat0());
}

fn make_install_v1_only(fs: &InMemoryFs) {
    fs.insert(
        "/install/sqpack/ffxiv/0a0000.win32.index",
        make_index_file(&[]),
    );
    fs.insert("/install/sqpack/ffxiv/0a0000.win32.dat0", make_empty_dat0());
}

fn make_install_v2_only(fs: &InMemoryFs) {
    fs.insert(
        "/install/sqpack/ffxiv/0a0000.win32.index2",
        make_index2_file(&[]),
    );
    fs.insert("/install/sqpack/ffxiv/0a0000.win32.dat0", make_empty_dat0());
}

fn open_appender(fs: &InMemoryFs) -> SqPackAppender {
    let fs_arc: Arc<InMemoryFs> = Arc::new(fs.clone());
    SqPackAppender::open_with_vfs(
        "/install",
        Platform::Win32,
        fs_arc.clone() as Arc<dyn sqpack_rs::Vfs>,
        fs_arc as Arc<dyn sqpack_rs::vfs::VfsWrite>,
    )
    .unwrap()
}

#[test]
fn append_then_read_round_trips_small_payload() {
    let fs = InMemoryFs::new();
    make_install(&fs);
    let mut writer = open_appender(&fs);
    let payload = b"hello-write".to_vec();
    writer.append("exd/CustomSheet.exd", &payload).unwrap();
    writer.commit().unwrap();

    let sq = SqPack::open_with_vfs("/install", Arc::new(fs.clone())).unwrap();
    let bytes = sq.read_file("exd/CustomSheet.exd").unwrap();
    assert_eq!(bytes, payload);
}

#[test]
fn append_round_trips_1_kib_payload() {
    let fs = InMemoryFs::new();
    make_install(&fs);
    let mut writer = open_appender(&fs);
    let payload: Vec<u8> = (0u32..1024)
        .map(|i| u8::try_from(i % 251).unwrap())
        .collect();
    writer.append("exd/sheet1.exd", &payload).unwrap();
    writer.commit().unwrap();

    let sq = SqPack::open_with_vfs("/install", Arc::new(fs.clone())).unwrap();
    let bytes = sq.read_file("exd/sheet1.exd").unwrap();
    assert_eq!(bytes, payload);
}

#[test]
fn append_round_trips_100_kib_payload() {
    let fs = InMemoryFs::new();
    make_install(&fs);
    let mut writer = open_appender(&fs);
    let payload: Vec<u8> = (0u32..100 * 1024)
        .map(|i| u8::try_from(i % 251).unwrap())
        .collect();
    writer.append("exd/sheet2.exd", &payload).unwrap();
    writer.commit().unwrap();

    let sq = SqPack::open_with_vfs("/install", Arc::new(fs.clone())).unwrap();
    let bytes = sq.read_file("exd/sheet2.exd").unwrap();
    assert_eq!(bytes, payload);
}

#[test]
fn append_round_trips_1_mib_payload_multi_block() {
    let fs = InMemoryFs::new();
    make_install(&fs);
    let mut writer = open_appender(&fs);
    let payload: Vec<u8> = (0u32..1024 * 1024)
        .map(|i| u8::try_from(i % 251).unwrap())
        .collect();
    let outcome = writer.append("exd/big.exd", &payload).unwrap();
    assert!(outcome.block_count >= 64);
    writer.commit().unwrap();

    let sq = SqPack::open_with_vfs("/install", Arc::new(fs.clone())).unwrap();
    let bytes = sq.read_file("exd/big.exd").unwrap();
    assert_eq!(bytes, payload);
}

#[test]
fn append_round_trips_at_two_block_size_boundary_exact() {
    let fs = InMemoryFs::new();
    make_install(&fs);
    let mut writer = open_appender(&fs);

    let two_blocks = vec![0xAAu8; (APPEND_BLOCK_SIZE as usize) * 2];
    let outcome = writer.append("exd/two_blocks.exd", &two_blocks).unwrap();
    assert_eq!(
        outcome.block_count, 2,
        "2 * BLOCK_SIZE must split into 2 blocks"
    );
    writer.commit().unwrap();

    let sq = SqPack::open_with_vfs("/install", Arc::new(fs.clone())).unwrap();
    let bytes = sq.read_file("exd/two_blocks.exd").unwrap();
    assert_eq!(bytes, two_blocks);
}

#[test]
fn append_round_trips_at_two_block_size_plus_one() {
    let fs = InMemoryFs::new();
    make_install(&fs);
    let mut writer = open_appender(&fs);

    let three_blocks = vec![0xBBu8; (APPEND_BLOCK_SIZE as usize) * 2 + 1];
    let outcome = writer
        .append("exd/three_blocks.exd", &three_blocks)
        .unwrap();
    assert_eq!(
        outcome.block_count, 3,
        "2 * BLOCK_SIZE + 1 must produce 3 blocks"
    );
    writer.commit().unwrap();

    let sq = SqPack::open_with_vfs("/install", Arc::new(fs.clone())).unwrap();
    let bytes = sq.read_file("exd/three_blocks.exd").unwrap();
    assert_eq!(bytes, three_blocks);
}

#[test]
fn append_round_trips_at_block_size_boundary_minus_one() {
    let fs = InMemoryFs::new();
    make_install(&fs);
    let mut writer = open_appender(&fs);
    let payload = vec![0x55u8; (APPEND_BLOCK_SIZE as usize) - 1];
    writer.append("exd/edge_minus.exd", &payload).unwrap();
    writer.commit().unwrap();

    let sq = SqPack::open_with_vfs("/install", Arc::new(fs.clone())).unwrap();
    let bytes = sq.read_file("exd/edge_minus.exd").unwrap();
    assert_eq!(bytes, payload);
}

#[test]
fn append_round_trips_at_block_size_boundary_exact() {
    let fs = InMemoryFs::new();
    make_install(&fs);
    let mut writer = open_appender(&fs);
    let payload = vec![0x66u8; APPEND_BLOCK_SIZE as usize];
    let outcome = writer.append("exd/edge_exact.exd", &payload).unwrap();
    assert_eq!(outcome.block_count, 1);
    writer.commit().unwrap();

    let sq = SqPack::open_with_vfs("/install", Arc::new(fs.clone())).unwrap();
    let bytes = sq.read_file("exd/edge_exact.exd").unwrap();
    assert_eq!(bytes, payload);
}

#[test]
fn append_round_trips_at_block_size_boundary_plus_one() {
    let fs = InMemoryFs::new();
    make_install(&fs);
    let mut writer = open_appender(&fs);
    let payload = vec![0x77u8; (APPEND_BLOCK_SIZE as usize) + 1];
    let outcome = writer.append("exd/edge_plus.exd", &payload).unwrap();
    assert_eq!(outcome.block_count, 2);
    writer.commit().unwrap();

    let sq = SqPack::open_with_vfs("/install", Arc::new(fs.clone())).unwrap();
    let bytes = sq.read_file("exd/edge_plus.exd").unwrap();
    assert_eq!(bytes, payload);
}

#[test]
fn append_handles_empty_payload() {
    let fs = InMemoryFs::new();
    make_install(&fs);
    let mut writer = open_appender(&fs);
    let payload: Vec<u8> = Vec::new();
    writer.append("exd/empty.exd", &payload).unwrap();
    writer.commit().unwrap();

    let sq = SqPack::open_with_vfs("/install", Arc::new(fs.clone())).unwrap();
    let bytes = sq.read_file("exd/empty.exd").unwrap();
    assert_eq!(bytes, payload);
}

#[test]
fn batched_commit_lands_multiple_files_atomically() {
    let fs = InMemoryFs::new();
    make_install(&fs);
    let mut writer = open_appender(&fs);
    writer.append("exd/a.exd", b"alpha").unwrap();
    writer.append("exd/b.exd", b"beta").unwrap();
    writer.append("exd/c.exd", b"gamma").unwrap();
    assert!(writer.staged_count() >= 3);
    writer.commit().unwrap();
    assert_eq!(writer.staged_count(), 0);

    let sq = SqPack::open_with_vfs("/install", Arc::new(fs.clone())).unwrap();
    assert_eq!(sq.read_file("exd/a.exd").unwrap(), b"alpha");
    assert_eq!(sq.read_file("exd/b.exd").unwrap(), b"beta");
    assert_eq!(sq.read_file("exd/c.exd").unwrap(), b"gamma");
}

#[test]
fn commit_with_no_staged_appends_is_a_noop() {
    let fs = InMemoryFs::new();
    make_install(&fs);
    let mut writer = open_appender(&fs);
    writer.commit().unwrap();

    let sq = SqPack::open_with_vfs("/install", Arc::new(fs.clone())).unwrap();
    let err = sq.read_file("exd/missing.exd").unwrap_err();
    assert!(matches!(err, Error::PathNotFound { .. }));
}

#[test]
fn no_commit_means_no_visible_files() {
    let fs = InMemoryFs::new();
    make_install(&fs);
    {
        let mut writer = open_appender(&fs);
        writer.append("exd/never.exd", b"phantom").unwrap();
    }
    let sq = SqPack::open_with_vfs("/install", Arc::new(fs.clone())).unwrap();
    let err = sq.read_file("exd/never.exd").unwrap_err();
    assert!(matches!(err, Error::PathNotFound { .. }));
}

#[test]
fn append_one_lands_a_single_file() {
    use std::env;
    let mut root = env::temp_dir();
    root.push(format!("sqpack-rs-append-one-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&root);
    std::fs::create_dir_all(root.join("sqpack/ffxiv")).unwrap();
    std::fs::write(
        root.join("sqpack/ffxiv/0a0000.win32.index"),
        make_index_file(&[]),
    )
    .unwrap();
    std::fs::write(
        root.join("sqpack/ffxiv/0a0000.win32.index2"),
        make_index2_file(&[]),
    )
    .unwrap();
    std::fs::write(
        root.join("sqpack/ffxiv/0a0000.win32.dat0"),
        make_empty_dat0(),
    )
    .unwrap();

    let outcome: AppendOutcome =
        SqPackAppender::append_one(&root, Platform::Win32, "exd/oneshot.exd", b"one-shot bytes")
            .unwrap();
    assert_eq!(outcome.data_file_id, 0);
    assert!(outcome.byte_offset >= u64::from(SQPACK_HEADER_SIZE));

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

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

#[test]
fn append_rejects_collision_with_existing_entry() {
    let fs = InMemoryFs::new();
    make_install(&fs);
    let mut writer = open_appender(&fs);
    writer.append("exd/collide.exd", b"first").unwrap();
    writer.commit().unwrap();

    let mut writer2 = open_appender(&fs);
    let err = writer2.append("exd/collide.exd", b"second").unwrap_err();
    match err {
        Error::FileAlreadyExists { game_path } => assert_eq!(game_path, "exd/collide.exd"),
        other => panic!("unexpected error: {other:?}"),
    }
}

#[test]
fn append_rejects_collision_within_same_batch() {
    let fs = InMemoryFs::new();
    make_install(&fs);
    let mut writer = open_appender(&fs);
    writer.append("exd/dup.exd", b"first").unwrap();
    let err = writer.append("exd/dup.exd", b"second").unwrap_err();
    assert!(matches!(err, Error::FileAlreadyExists { .. }));
}

#[test]
fn append_rejects_unknown_category() {
    let fs = InMemoryFs::new();
    make_install(&fs);
    let mut writer = open_appender(&fs);
    let err = writer.append("nonexistent_cat/foo.bin", b"x").unwrap_err();
    assert!(matches!(err, Error::UnknownCategory { .. }));
}

#[test]
fn append_rejects_missing_archive() {
    let fs = InMemoryFs::new();
    fs.insert("/install/sqpack/ffxiv/marker", b"x".to_vec());
    let mut writer = open_appender(&fs);
    let err = writer.append("exd/foo.exd", b"x").unwrap_err();
    match err {
        Error::Io { .. } => {}
        other => panic!("expected Io, got {other:?}"),
    }
}

#[test]
fn append_routes_to_v1_only_when_index2_absent() {
    let fs = InMemoryFs::new();
    make_install_v1_only(&fs);
    let mut writer = open_appender(&fs);
    writer.append("exd/only_v1.exd", b"v1 only").unwrap();
    writer.commit().unwrap();

    let sq = SqPack::open_with_vfs("/install", Arc::new(fs.clone())).unwrap();
    let bytes = sq.read_file("exd/only_v1.exd").unwrap();
    assert_eq!(bytes, b"v1 only");
}

#[test]
fn append_routes_to_v2_only_when_index_absent() {
    let fs = InMemoryFs::new();
    make_install_v2_only(&fs);
    let mut writer = open_appender(&fs);
    writer.append("exd/only_v2.exd", b"v2 only").unwrap();
    writer.commit().unwrap();

    let sq = SqPack::open_with_vfs("/install", Arc::new(fs.clone())).unwrap();
    let bytes = sq.read_file("exd/only_v2.exd").unwrap();
    assert_eq!(bytes, b"v2 only");
}

#[test]
fn dat_bytes_durable_even_without_commit() {
    let fs = InMemoryFs::new();
    make_install(&fs);
    let original_dat_len = fs
        .snapshot_files()
        .get(&PathBuf::from("/install/sqpack/ffxiv/0a0000.win32.dat0"))
        .unwrap()
        .len();
    {
        let mut writer = open_appender(&fs);
        writer.append("exd/staged.exd", b"abcde").unwrap();
    }
    let new_dat_len = fs
        .snapshot_files()
        .get(&PathBuf::from("/install/sqpack/ffxiv/0a0000.win32.dat0"))
        .unwrap()
        .len();
    assert!(new_dat_len > original_dat_len);
}

#[test]
fn append_byte_offset_is_8_byte_aligned() {
    let fs = InMemoryFs::new();
    make_install(&fs);
    let mut writer = open_appender(&fs);
    let outcome = writer.append("exd/aligned.exd", b"x").unwrap();
    assert_eq!(outcome.byte_offset % 128, 0);
}

#[test]
fn outcome_byte_offset_lives_inside_the_dat() {
    let fs = InMemoryFs::new();
    make_install(&fs);
    let mut writer = open_appender(&fs);
    let outcome = writer.append("exd/inside.exd", b"x").unwrap();
    let dat_size = fs
        .snapshot_files()
        .get(&PathBuf::from("/install/sqpack/ffxiv/0a0000.win32.dat0"))
        .unwrap()
        .len() as u64;
    assert!(outcome.byte_offset < dat_size);
}

#[test]
fn rebuilt_index_carries_new_entry() {
    let fs = InMemoryFs::new();
    make_install(&fs);
    let mut writer = open_appender(&fs);
    writer.append("exd/inspect.exd", b"inspect-me").unwrap();
    writer.commit().unwrap();

    let idx = sqpack_rs::Index::open_in(
        std::path::Path::new("/install/sqpack/ffxiv/0a0000.win32.index"),
        &fs,
    )
    .unwrap();
    assert_eq!(idx.len(), 1);
    let key = sqpack_rs::hash::hash_index1_key("exd/inspect.exd");
    assert!(idx.get(key).is_some());
}

#[test]
fn rebuilt_index2_carries_new_entry() {
    let fs = InMemoryFs::new();
    make_install(&fs);
    let mut writer = open_appender(&fs);
    writer.append("exd/inspect.exd", b"inspect-me").unwrap();
    writer.commit().unwrap();

    let idx = sqpack_rs::Index2::open_in(
        std::path::Path::new("/install/sqpack/ffxiv/0a0000.win32.index2"),
        &fs,
    )
    .unwrap();
    assert_eq!(idx.len(), 1);
    let key = sqpack_rs::hash::hash_index2("exd/inspect.exd");
    assert!(idx.get(key).is_some());
}

#[test]
fn multiple_appends_to_same_archive_all_visible() {
    let fs = InMemoryFs::new();
    make_install(&fs);
    let mut writer = open_appender(&fs);
    for i in 0..10 {
        let name = format!("exd/file_{i:02}.exd");
        let bytes = format!("payload-{i}").into_bytes();
        writer.append(&name, &bytes).unwrap();
    }
    writer.commit().unwrap();

    let sq = SqPack::open_with_vfs("/install", Arc::new(fs.clone())).unwrap();
    for i in 0..10 {
        let name = format!("exd/file_{i:02}.exd");
        let expected = format!("payload-{i}").into_bytes();
        let got = sq.read_file(&name).unwrap();
        assert_eq!(got, expected, "mismatch for {name}");
    }
}

#[test]
fn rebuilt_index_entries_are_sorted_ascending_by_hash() {
    let fs = InMemoryFs::new();
    make_install(&fs);
    let mut writer = open_appender(&fs);
    writer.append("exd/zzz_last.exd", b"C").unwrap();
    writer.append("exd/aaa_first.exd", b"A").unwrap();
    writer.append("exd/mmm_middle.exd", b"B").unwrap();
    writer.commit().unwrap();

    let idx = sqpack_rs::Index::open_in(
        std::path::Path::new("/install/sqpack/ffxiv/0a0000.win32.index"),
        &fs,
    )
    .unwrap();
    assert_eq!(idx.len(), 3, "all three entries must land in the v1 index");

    let hashes: Vec<u64> = idx.entries().iter().map(|e| e.hash).collect();
    for w in hashes.windows(2) {
        assert!(
            w[0] <= w[1],
            "rebuilt v1 index entries must be sorted ascending by hash; found [{:#018x}, {:#018x}] out of order",
            w[0],
            w[1],
        );
    }

    let idx2 = sqpack_rs::Index2::open_in(
        std::path::Path::new("/install/sqpack/ffxiv/0a0000.win32.index2"),
        &fs,
    )
    .unwrap();
    assert_eq!(idx2.len(), 3, "all three entries must land in the v2 index");
    let hashes2: Vec<u32> = idx2.entries().iter().map(|e| e.hash).collect();
    for w in hashes2.windows(2) {
        assert!(
            w[0] <= w[1],
            "rebuilt v2 index entries must be sorted ascending by hash; found [{:#010x}, {:#010x}] out of order",
            w[0],
            w[1],
        );
    }
}

#[test]
fn append_max_file_size_const_pinned_at_1_gib() {
    use sqpack_rs::write::APPEND_MAX_FILE_SIZE;
    assert_eq!(APPEND_MAX_FILE_SIZE, 1u64 << 30);
}

#[test]
#[ignore = "allocates 1 GiB; run with `cargo test -- --ignored`"]
fn append_rejects_oversized_input_ignored() {
    use sqpack_rs::write::APPEND_MAX_FILE_SIZE;
    let oversize = usize::try_from(APPEND_MAX_FILE_SIZE + 1).expect("fits usize on 64-bit");
    let bytes = vec![0u8; oversize];
    let fs = InMemoryFs::new();
    make_install(&fs);
    let mut writer = open_appender(&fs);
    let err = writer
        .append("common/oversized.bin", &bytes)
        .expect_err("oversized append must be rejected");
    assert!(
        matches!(err, sqpack_rs::Error::AppendFileTooLarge { size, max }
            if size == (APPEND_MAX_FILE_SIZE + 1) && max == APPEND_MAX_FILE_SIZE),
        "unexpected error variant: {err:?}",
    );
}

#[test]
fn dat_size_overflow_returned_when_dat_already_at_cap() {
    use sqpack_rs::write::DAT_MAX_SIZE;

    let fs = InMemoryFs::new();
    let mut huge = make_empty_dat0();
    let cap_usize = usize::try_from(DAT_MAX_SIZE).expect("DAT_MAX_SIZE fits in usize");
    huge.resize(cap_usize, 0);
    fs.insert("/install/sqpack/ffxiv/0a0000.win32.dat0", huge);
    fs.insert(
        "/install/sqpack/ffxiv/0a0000.win32.index",
        make_index_file(&[]),
    );
    fs.insert(
        "/install/sqpack/ffxiv/0a0000.win32.index2",
        make_index2_file(&[]),
    );
    let mut writer = open_appender(&fs);
    let err = writer.append("exd/too_big.exd", b"x").unwrap_err();
    match err {
        Error::DatSizeOverflow {
            current_size, max, ..
        } => {
            assert_eq!(current_size, DAT_MAX_SIZE);
            assert_eq!(max, DAT_MAX_SIZE);
        }
        other => panic!("expected DatSizeOverflow, got {other:?}"),
    }
}

#[test]
fn dat_size_overflow_when_alignment_padding_would_overshoot_cap() {
    use sqpack_rs::write::DAT_MAX_SIZE;

    let fs = InMemoryFs::new();
    let mut dat = make_empty_dat0();
    let eof = usize::try_from(DAT_MAX_SIZE - 50).expect("fits usize on 64-bit");
    dat.resize(eof, 0);
    fs.insert("/install/sqpack/ffxiv/0a0000.win32.dat0", dat);
    fs.insert(
        "/install/sqpack/ffxiv/0a0000.win32.index",
        make_index_file(&[]),
    );
    fs.insert(
        "/install/sqpack/ffxiv/0a0000.win32.index2",
        make_index2_file(&[]),
    );
    let mut writer = open_appender(&fs);
    let err = writer
        .append("exd/align_overshoot.exd", b"x")
        .expect_err("append at aligned DAT_MAX_SIZE must be rejected");
    match err {
        Error::DatSizeOverflow {
            current_size, max, ..
        } => {
            assert_eq!(
                current_size, DAT_MAX_SIZE,
                "current_size should be the aligned offset"
            );
            assert_eq!(max, DAT_MAX_SIZE);
        }
        other => panic!("expected DatSizeOverflow, got {other:?}"),
    }
}

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

    let e = Error::DatSizeOverflow {
        current_size: 1,
        additional_bytes: 2,
        max: 3,
    };
    assert!(format!("{e}").contains("dat size cap"));

    let e = Error::AppendFileTooLarge { size: 100, max: 50 };
    assert!(format!("{e}").contains("exceeds maximum"));

    let e = Error::UnsupportedAppendKind {
        kind: sqpack_rs::FileKind::Model,
    };
    assert!(format!("{e}").contains("Standard"));

    let e = Error::WriteAtomicityFailure {
        context: "index_tmp_rename",
        source: std::io::Error::other("boom"),
    };
    assert!(format!("{e}").contains("index_tmp_rename"));
}

#[test]
fn appended_payload_uses_deflate_blocks_not_uncompressed_sentinel() {
    let fs = InMemoryFs::new();
    make_install(&fs);
    let mut writer = open_appender(&fs);
    let outcome = writer
        .append("exd/compressed.exd", b"hello compress")
        .unwrap();
    writer.commit().unwrap();

    let snapshot = fs.snapshot_files();
    let dat = snapshot
        .get(&PathBuf::from("/install/sqpack/ffxiv/0a0000.win32.dat0"))
        .unwrap();
    let off = usize::try_from(outcome.byte_offset).expect("byte_offset fits in usize");
    let bh_dat_block_type_offset = off + 24 + 8 + 8;
    let v = u32::from_le_bytes(
        dat[bh_dat_block_type_offset..bh_dat_block_type_offset + 4]
            .try_into()
            .unwrap(),
    );
    assert_ne!(
        v, 32000,
        "writer should always emit DEFLATE blocks, not uncompressed sentinel"
    );
}

#[test]
fn vfs_write_trait_works_for_memfs_round_trip() {
    use sqpack_rs::vfs::VfsWrite;
    use std::io::Write as _;
    let fs = InMemoryFs::new();
    let path = std::path::Path::new("/raw.bin");
    {
        let mut h = fs.open_write(path).unwrap();
        h.write_all(b"raw bytes").unwrap();
        h.sync_all().unwrap();
    }
    let mut r = fs.open_read(path).unwrap();
    let mut buf = Vec::new();
    r.read_to_end(&mut buf).unwrap();
    assert_eq!(buf, b"raw bytes");
}

#[test]
fn block_header_size_const_re_exported() {
    assert_eq!(BLOCK_HEADER_SIZE, 16);
}

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)
    }
}

fn open_appender_with_failing_writes(fs: &InMemoryFs, fail_rename_after: usize) -> SqPackAppender {
    let inner: Arc<InMemoryFs> = Arc::new(fs.clone());
    let failing: Arc<FailingVfsWrite> =
        Arc::new(FailingVfsWrite::new(inner.clone(), fail_rename_after));
    SqPackAppender::open_with_vfs(
        "/install",
        Platform::Win32,
        inner as Arc<dyn sqpack_rs::Vfs>,
        failing as Arc<dyn sqpack_rs::vfs::VfsWrite>,
    )
    .unwrap()
}

#[test]
fn commit_with_rename_failure_preserves_staging_for_retry() {
    let fs = InMemoryFs::new();
    make_install(&fs);
    let mut writer = open_appender_with_failing_writes(&fs, 0);

    writer.append("exd/a.exd", b"first file").unwrap();
    let err = writer.commit().expect_err("first rename must fail");
    assert!(
        matches!(err, sqpack_rs::Error::WriteAtomicityFailure { .. }),
        "unexpected error: {err:?}",
    );
    assert!(
        writer.staged_count() >= 1,
        "failed-triple staging must persist for caller retry; got {}",
        writer.staged_count(),
    );

    let mut retry_writer = open_appender(&fs);
    retry_writer.append("exd/a.exd", b"first file").unwrap();
    retry_writer.commit().unwrap();
    let sq = SqPack::open_with_vfs("/install", Arc::new(fs.clone())).unwrap();
    let read = sq.read_file("exd/a.exd").unwrap();
    assert_eq!(read, b"first file");
}

#[test]
fn commit_partial_within_triple_leaves_v1_visible_v2_not() {
    let fs = InMemoryFs::new();
    make_install(&fs);
    let mut writer = open_appender_with_failing_writes(&fs, 1);

    writer.append("exd/file.exd", b"PAYLOAD").unwrap();
    let err = writer.commit().expect_err("second rename must fail");
    assert!(matches!(
        err,
        sqpack_rs::Error::WriteAtomicityFailure { .. },
    ));

    assert!(
        writer.staged_count() >= 1,
        "failed triple stays in staging on partial failure; got {}",
        writer.staged_count(),
    );

    let sq = SqPack::open_with_vfs("/install", Arc::new(fs.clone())).unwrap();
    let read = sq.read_file("exd/file.exd").unwrap();
    assert_eq!(read, b"PAYLOAD");
}

#[test]
fn stale_tmpfile_is_overwritten_on_next_commit() {
    let fs = InMemoryFs::new();
    make_install(&fs);
    fs.insert(
        "/install/sqpack/ffxiv/0a0000.win32.index.sqpack-rs.tmp",
        b"GARBAGE_FROM_A_PREVIOUS_CRASHED_RUN".to_vec(),
    );
    let mut writer = open_appender(&fs);
    writer
        .append("exd/post-crash.exd", b"clean payload")
        .unwrap();
    writer.commit().unwrap();

    let sq = SqPack::open_with_vfs("/install", Arc::new(fs.clone())).unwrap();
    let read = sq.read_file("exd/post-crash.exd").unwrap();
    assert_eq!(read, b"clean payload");

    let snapshot = fs.snapshot_files();
    assert!(
        !snapshot.contains_key(&PathBuf::from(
            "/install/sqpack/ffxiv/0a0000.win32.index.sqpack-rs.tmp"
        )),
        "tmpfile must be removed by the rename step",
    );
}
