use std::path::{Path, PathBuf};

use tracing::{debug, info, info_span, trace};

use crate::error::{Error, Result};
use crate::hash::{hash_index1_key, hash_index2};
use crate::header::{
    DatLocation, INDEX_HEADER_SIZE, Platform, SQPACK_HEADER_SIZE, SQPACK_MAGIC, SqPackFileType,
};
use crate::index::INDEX_ENTRY_SIZE;
use crate::index2::INDEX2_ENTRY_SIZE;
use crate::route::ArchiveTriple;
use crate::tracing_schema::span_names;
use crate::vfs::{StdFs, VfsWrite};
use crate::write::atomic::replace_atomically;
use crate::write::index_writer::pack_index_data;
use crate::write::index2_writer::pack_index2_data;
use crate::write::payload::encode_standard;
use crate::write::sha1::{pad_to_64, sha1};
use crate::write::{APPEND_MAX_FILE_SIZE, DAT_MAX_SIZE};

/// Maximum number of files that can be packed into a single [`SqPackRepacker`] build.
pub const REPACK_MAX_FILES: usize = 65_536;

/// Documents the byte-level determinism contract for DEFLATE output produced by this crate.
///
/// Consumers that compare archive bytes across builds or machines should read this note.
pub const FLATE2_DETERMINISM_NOTE: &str = "Output bytes are deterministic within a fixed (flate2 version, backend feature) tuple. \
     The crate ships three flate2 backends as Cargo features: `rust-backend` (default, miniz_oxide), \
     `zlib-rs` (pure-Rust port of zlib), and `zlib-ng` (C-based fork). \
     Different backends produce different DEFLATE bytes for the same input at the same compression level. \
     Changing either the flate2 dependency version OR the backend feature is a SemVer-minor bump for sqpack-rs.";

const DAT_REGION_ALIGN: u64 = 128;

fn align_up_dat(offset: u64) -> u64 {
    let rem = offset % DAT_REGION_ALIGN;
    if rem == 0 {
        offset
    } else {
        offset + (DAT_REGION_ALIGN - rem)
    }
}

/// The in-memory output of [`SqPackRepacker::build`]: a complete `.dat` file plus optional
/// `.index` and `.index2` files depending on which index variants were requested.
///
/// `#[non_exhaustive]` — new fields may be added in future minor releases.
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct RepackedArchive {
    /// Serialized `.dat` file bytes including the `SqPack` header and all file payloads.
    pub dat: Vec<u8>,
    /// Serialized `.index` (v1, 64-bit hash) file bytes, or `None` if
    /// [`with_index_v1`](SqPackRepacker::with_index_v1) was not called.
    pub index: Option<Vec<u8>>,
    /// Serialized `.index2` (v2, 32-bit hash) file bytes, or `None` if
    /// [`with_index_v2`](SqPackRepacker::with_index_v2) was not called.
    pub index2: Option<Vec<u8>>,
}

#[derive(Debug, Clone)]
struct PendingInput {
    game_path: String,
    bytes: Vec<u8>,
    v1_hash: u64,
    v2_hash: u32,
}

/// Builder that constructs a complete `SqPack` archive from scratch, entirely in memory.
///
/// Add files with [`add_file`](SqPackRepacker::add_file), select which index variants to
/// emit with [`with_index_v1`](SqPackRepacker::with_index_v1) and/or
/// [`with_index_v2`](SqPackRepacker::with_index_v2), then call
/// [`build`](SqPackRepacker::build) to obtain a [`RepackedArchive`] or
/// [`write_to`](SqPackRepacker::write_to) / [`write_to_install`](SqPackRepacker::write_to_install)
/// to persist directly.
///
/// All files are DEFLATE-compressed and laid out in the `.dat` in hash-sorted order. The
/// resulting bytes are byte-deterministic within a fixed `(flate2 version, backend)` tuple;
/// see [`FLATE2_DETERMINISM_NOTE`].
///
/// `#[non_exhaustive]` — the struct has no public fields; new builder methods may be added.
#[non_exhaustive]
pub struct SqPackRepacker {
    triple: ArchiveTriple,
    platform: Platform,
    emit_v1: bool,
    emit_v2: bool,
    inputs: Vec<PendingInput>,
}

impl std::fmt::Debug for SqPackRepacker {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("SqPackRepacker")
            .field("triple", &self.triple)
            .field("platform", &self.platform)
            .field("emit_v1", &self.emit_v1)
            .field("emit_v2", &self.emit_v2)
            .field("input_count", &self.inputs.len())
            .finish_non_exhaustive()
    }
}

impl SqPackRepacker {
    /// Creates a new repacker targeting the given archive triple and platform.
    ///
    /// No index variant is selected by default; call [`with_index_v1`](Self::with_index_v1)
    /// and/or [`with_index_v2`](Self::with_index_v2) before [`build`](Self::build).
    #[must_use]
    pub fn new(triple: ArchiveTriple, platform: Platform) -> Self {
        Self {
            triple,
            platform,
            emit_v1: false,
            emit_v2: false,
            inputs: Vec::new(),
        }
    }

    /// Configures the builder to emit a `.index` (v1, 64-bit hash) file in [`build`](Self::build).
    #[must_use]
    pub fn with_index_v1(mut self) -> Self {
        self.emit_v1 = true;
        self
    }

    /// Configures the builder to emit a `.index2` (v2, 32-bit hash) file in [`build`](Self::build).
    #[must_use]
    pub fn with_index_v2(mut self) -> Self {
        self.emit_v2 = true;
        self
    }

    /// Returns `true` if the v1 index will be emitted by [`build`](Self::build).
    #[must_use]
    pub fn emits_index_v1(&self) -> bool {
        self.emit_v1
    }

    /// Returns `true` if the v2 index will be emitted by [`build`](Self::build).
    #[must_use]
    pub fn emits_index_v2(&self) -> bool {
        self.emit_v2
    }

    /// Returns the number of files staged with [`add_file`](Self::add_file).
    #[must_use]
    pub fn input_count(&self) -> usize {
        self.inputs.len()
    }

    /// Stages a file for inclusion in the archive.
    ///
    /// Returns an error if the file count would exceed [`REPACK_MAX_FILES`], if `game_path`
    /// does not route to this repacker's archive triple, if `bytes` exceeds
    /// [`APPEND_MAX_FILE_SIZE`](crate::write::APPEND_MAX_FILE_SIZE), or if a hash collision
    /// is detected against an already-staged path in any enabled index variant.
    pub fn add_file(
        &mut self,
        game_path: impl Into<String>,
        bytes: impl Into<Vec<u8>>,
    ) -> Result<()> {
        if self.inputs.len() >= REPACK_MAX_FILES {
            return Err(Error::RepackTooManyFiles {
                count: self.inputs.len().saturating_add(1),
                max: REPACK_MAX_FILES,
            });
        }
        let game_path: String = game_path.into();
        let bytes: Vec<u8> = bytes.into();

        let routed =
            ArchiveTriple::from_game_path(&game_path).ok_or_else(|| Error::UnknownCategory {
                name: game_path.clone(),
            })?;
        if routed != self.triple {
            return Err(Error::PathRoutesToWrongArchive {
                game_path,
                expected: self.triple,
                found: routed,
            });
        }

        if bytes.len() as u64 > APPEND_MAX_FILE_SIZE {
            return Err(Error::AppendFileTooLarge {
                size: bytes.len() as u64,
                max: APPEND_MAX_FILE_SIZE,
            });
        }

        let v1_hash = hash_index1_key(&game_path);
        let v2_hash = hash_index2(&game_path);

        if self.emit_v1 {
            for existing in &self.inputs {
                if existing.v1_hash == v1_hash {
                    return Err(Error::DuplicatePath {
                        game_path,
                        kind: "v1",
                    });
                }
            }
        }
        if self.emit_v2 {
            for existing in &self.inputs {
                if existing.v2_hash == v2_hash {
                    return Err(Error::DuplicatePath {
                        game_path,
                        kind: "v2",
                    });
                }
            }
        }

        self.inputs.push(PendingInput {
            game_path,
            bytes,
            v1_hash,
            v2_hash,
        });
        Ok(())
    }

    /// Encodes all staged files and assembles a complete [`RepackedArchive`] in memory.
    ///
    /// Returns an error if no files have been staged or if no index variant has been selected.
    /// The `.dat` payload is byte-deterministic across repeated calls; see [`FLATE2_DETERMINISM_NOTE`].
    pub fn build(&self) -> Result<RepackedArchive> {
        let span = info_span!(
            span_names::SQPACK_REPACK_BUILD,
            file_count = self.inputs.len(),
        );
        let _enter = span.enter();
        let started_at = std::time::Instant::now();

        if self.inputs.is_empty() {
            return Err(Error::RepackEmptyArchive);
        }
        if !self.emit_v1 && !self.emit_v2 {
            return Err(Error::RepackNoIndexSelected);
        }

        let order: Vec<usize> = if self.emit_v1 {
            let mut idx: Vec<usize> = (0..self.inputs.len()).collect();
            idx.sort_by_key(|&i| self.inputs[i].v1_hash);
            idx
        } else {
            let mut idx: Vec<usize> = (0..self.inputs.len()).collect();
            idx.sort_by_key(|&i| self.inputs[i].v2_hash);
            idx
        };

        let dat_header = build_dat_header();
        let mut dat_bytes = Vec::with_capacity(SQPACK_HEADER_SIZE as usize);
        dat_bytes.extend_from_slice(&dat_header);
        debug_assert_eq!(dat_bytes.len(), SQPACK_HEADER_SIZE as usize);

        let mut locations: Vec<DatLocation> = vec![
            DatLocation {
                data_file_id: 0,
                byte_offset: 0,
            };
            self.inputs.len()
        ];

        for &i in &order {
            let input = &self.inputs[i];
            let encoded = encode_standard(&input.bytes)?;

            let cur = dat_bytes.len() as u64;
            let aligned = align_up_dat(cur);
            if aligned + encoded.bytes.len() as u64 > DAT_MAX_SIZE {
                return Err(Error::DatSizeOverflow {
                    current_size: aligned,
                    additional_bytes: encoded.bytes.len() as u64,
                    max: DAT_MAX_SIZE,
                });
            }
            if aligned != cur {
                let pad =
                    usize::try_from(aligned - cur).map_err(|_| Error::InvalidBlockLayout {
                        context: "repack.dat_pad",
                        message: format!("alignment pad {} exceeds usize::MAX", aligned - cur),
                    })?;
                dat_bytes.resize(dat_bytes.len() + pad, 0);
            }

            let byte_offset = dat_bytes.len() as u64;
            dat_bytes.extend_from_slice(&encoded.bytes);

            locations[i] = DatLocation {
                data_file_id: 0,
                byte_offset,
            };

            trace!(
                game_path = %input.game_path,
                byte_offset,
                bytes_written = encoded.bytes.len(),
                block_count = encoded.block_count,
                "repack.payload"
            );
        }

        let index = if self.emit_v1 {
            Some(self.build_v1_index(&order, &locations)?)
        } else {
            None
        };
        let index2 = if self.emit_v2 {
            Some(self.build_v2_index(&order, &locations)?)
        } else {
            None
        };

        #[allow(clippy::cast_possible_truncation)]
        let elapsed_ms = started_at.elapsed().as_millis() as u64;
        info!(
            file_count = self.inputs.len(),
            dat_bytes = dat_bytes.len(),
            index_bytes = index.as_ref().map_or(0usize, Vec::len),
            index2_bytes = index2.as_ref().map_or(0usize, Vec::len),
            elapsed_ms,
            "sqpack.repack.build"
        );

        Ok(RepackedArchive {
            dat: dat_bytes,
            index,
            index2,
        })
    }

    fn build_v1_index(&self, sorted_order: &[usize], locations: &[DatLocation]) -> Result<Vec<u8>> {
        let mut entries: Vec<(u64, u32)> = Vec::with_capacity(sorted_order.len());
        let mut idx_by_v1: Vec<usize> = sorted_order.to_vec();
        idx_by_v1.sort_by_key(|&i| self.inputs[i].v1_hash);

        for &i in &idx_by_v1 {
            let data = pack_index_data(false, locations[i])?;
            entries.push((self.inputs[i].v1_hash, data));
        }
        serialise_index_v1(&entries)
    }

    fn build_v2_index(&self, sorted_order: &[usize], locations: &[DatLocation]) -> Result<Vec<u8>> {
        let mut entries: Vec<(u32, u32)> = Vec::with_capacity(sorted_order.len());
        let mut idx_by_v2: Vec<usize> = sorted_order.to_vec();
        idx_by_v2.sort_by_key(|&i| self.inputs[i].v2_hash);

        for &i in &idx_by_v2 {
            let data = pack_index2_data(locations[i])?;
            entries.push((self.inputs[i].v2_hash, data));
        }
        serialise_index_v2(&entries)
    }

    /// Builds the archive and atomically writes all output files to `install_root` via `vfs`.
    ///
    /// Equivalent to calling [`build`](Self::build) and then writing each file with
    /// [`replace_atomically`](crate::write::atomic::replace_atomically). Suitable for
    /// test-injected [`VfsWrite`](crate::vfs::VfsWrite) implementations.
    pub fn write_to(&self, install_root: impl AsRef<Path>, vfs: &dyn VfsWrite) -> Result<()> {
        let install_root = install_root.as_ref();
        let span = info_span!(
            span_names::SQPACK_REPACK_WRITE,
            file_count = self.inputs.len(),
        );
        let _enter = span.enter();
        let started_at = std::time::Instant::now();

        let archive = self.build()?;
        let mut bytes_written: u64 = 0;

        let dat_path = self.triple.dat_path(install_root, self.platform, 0).ok_or(
            Error::UnsupportedPlatform {
                platform: self.platform,
            },
        )?;
        debug!(path = %dat_path.display(), bytes = archive.dat.len(), "repack.write.dat");
        replace_atomically(vfs, &dat_path, &archive.dat)?;
        bytes_written += archive.dat.len() as u64;

        if let Some(index_bytes) = archive.index.as_ref() {
            let index_path = self.triple.index_path(install_root, self.platform).ok_or(
                Error::UnsupportedPlatform {
                    platform: self.platform,
                },
            )?;
            debug!(path = %index_path.display(), bytes = index_bytes.len(), "repack.write.index");
            replace_atomically(vfs, &index_path, index_bytes)?;
            bytes_written += index_bytes.len() as u64;
        }
        if let Some(index2_bytes) = archive.index2.as_ref() {
            let index2_path = self.triple.index2_path(install_root, self.platform).ok_or(
                Error::UnsupportedPlatform {
                    platform: self.platform,
                },
            )?;
            debug!(path = %index2_path.display(), bytes = index2_bytes.len(), "repack.write.index2");
            replace_atomically(vfs, &index2_path, index2_bytes)?;
            bytes_written += index2_bytes.len() as u64;
        }

        #[allow(clippy::cast_possible_truncation)]
        let elapsed_ms = started_at.elapsed().as_millis() as u64;
        info!(
            file_count = self.inputs.len(),
            bytes_written, elapsed_ms, "sqpack.repack.write"
        );
        Ok(())
    }

    /// Builds the archive and writes all output files to `install_root` on the real filesystem.
    pub fn write_to_install(&self, install_root: impl Into<PathBuf>) -> Result<()> {
        let install_root: PathBuf = install_root.into();
        let vfs = StdFs::new();
        self.write_to(&install_root, &vfs)
    }
}

fn build_dat_header() -> Vec<u8> {
    let mut out = Vec::with_capacity(SQPACK_HEADER_SIZE as usize);
    out.extend_from_slice(&SQPACK_MAGIC);
    out.push(0);
    out.extend_from_slice(&[0u8; 3]);
    out.extend_from_slice(&SQPACK_HEADER_SIZE.to_le_bytes());
    out.extend_from_slice(&1u32.to_le_bytes());
    out.extend_from_slice(&SqPackFileType::Dat.as_raw().to_le_bytes());
    out.resize(SQPACK_HEADER_SIZE as usize, 0);
    out
}

fn build_index_sqpack_header() -> Vec<u8> {
    let mut out = Vec::with_capacity(SQPACK_HEADER_SIZE as usize);
    out.extend_from_slice(&SQPACK_MAGIC);
    out.push(0);
    out.extend_from_slice(&[0u8; 3]);
    out.extend_from_slice(&SQPACK_HEADER_SIZE.to_le_bytes());
    out.extend_from_slice(&1u32.to_le_bytes());
    out.extend_from_slice(&SqPackFileType::Index.as_raw().to_le_bytes());
    out.resize(SQPACK_HEADER_SIZE as usize, 0);
    out
}

fn serialise_v1_hash_table(entries: &[(u64, u32)]) -> Vec<u8> {
    let mut out = Vec::with_capacity(entries.len() * INDEX_ENTRY_SIZE as usize);
    for (hash, data) in entries {
        out.extend_from_slice(&hash.to_le_bytes());
        out.extend_from_slice(&data.to_le_bytes());
        out.extend_from_slice(&0u32.to_le_bytes());
    }
    out
}

fn serialise_v2_hash_table(entries: &[(u32, u32)]) -> Vec<u8> {
    let mut out = Vec::with_capacity(entries.len() * INDEX2_ENTRY_SIZE as usize);
    for (hash, data) in entries {
        out.extend_from_slice(&hash.to_le_bytes());
        out.extend_from_slice(&data.to_le_bytes());
    }
    out
}

fn serialise_index_v1(entries: &[(u64, u32)]) -> Result<Vec<u8>> {
    let index_data_size = u32::try_from(entries.len())
        .map_err(|_| Error::InvalidBlockLayout {
            context: "repack.index.entry_count",
            message: format!("entry count {} exceeds u32::MAX", entries.len()),
        })?
        .checked_mul(INDEX_ENTRY_SIZE)
        .ok_or_else(|| Error::InvalidBlockLayout {
            context: "repack.index.index_data_size",
            message: format!("entry_count × {INDEX_ENTRY_SIZE} overflows u32"),
        })?;
    assemble_index_file(index_data_size, &serialise_v1_hash_table(entries))
}

fn serialise_index_v2(entries: &[(u32, u32)]) -> Result<Vec<u8>> {
    let index_data_size = u32::try_from(entries.len())
        .map_err(|_| Error::InvalidBlockLayout {
            context: "repack.index2.entry_count",
            message: format!("entry count {} exceeds u32::MAX", entries.len()),
        })?
        .checked_mul(INDEX2_ENTRY_SIZE)
        .ok_or_else(|| Error::InvalidBlockLayout {
            context: "repack.index2.index_data_size",
            message: format!("entry_count × {INDEX2_ENTRY_SIZE} overflows u32"),
        })?;
    assemble_index_file(index_data_size, &serialise_v2_hash_table(entries))
}

fn assemble_index_file(index_data_size: u32, index_data_bytes: &[u8]) -> Result<Vec<u8>> {
    debug_assert_eq!(
        index_data_size as usize,
        index_data_bytes.len(),
        "index_data_size must equal the hash-table region byte length"
    );

    let sqpack_header_size = SQPACK_HEADER_SIZE;
    let index_header_size: u32 = INDEX_HEADER_SIZE;
    let index_data_offset = sqpack_header_size
        .checked_add(index_header_size)
        .ok_or_else(|| Error::InvalidBlockLayout {
            context: "repack.index.index_data_offset",
            message: "SQPACK_HEADER_SIZE + INDEX_HEADER_SIZE overflows u32".to_string(),
        })?;

    let post_hash_table_offset =
        index_data_offset
            .checked_add(index_data_size)
            .ok_or_else(|| Error::InvalidBlockLayout {
                context: "repack.index.post_hash_table_offset",
                message: "index_data_offset + index_data_size overflows u32".to_string(),
            })?;
    let synonym_data_offset = post_hash_table_offset;
    let synonym_data_size: u32 = 0;
    let empty_block_data_offset = post_hash_table_offset;
    let empty_block_data_size: u32 = 0;
    let dir_index_data_offset = post_hash_table_offset;
    let dir_index_data_size: u32 = 0;

    let index_data_hash = pad_to_64(sha1(index_data_bytes));
    let empty_region: &[u8] = &[];
    let synonym_data_hash = pad_to_64(sha1(empty_region));
    let empty_block_data_hash = pad_to_64(sha1(empty_region));
    let dir_index_data_hash = pad_to_64(sha1(empty_region));

    let number_of_data_file: u32 = 1;
    let index_type: u32 = 0;
    let reserved = [0u8; 656];

    let mut index_header_bytes = Vec::with_capacity(index_header_size as usize);
    index_header_bytes.extend_from_slice(&index_header_size.to_le_bytes());
    index_header_bytes.extend_from_slice(&1u32.to_le_bytes());
    index_header_bytes.extend_from_slice(&index_data_offset.to_le_bytes());
    index_header_bytes.extend_from_slice(&index_data_size.to_le_bytes());
    index_header_bytes.extend_from_slice(&index_data_hash);
    index_header_bytes.extend_from_slice(&number_of_data_file.to_le_bytes());
    index_header_bytes.extend_from_slice(&synonym_data_offset.to_le_bytes());
    index_header_bytes.extend_from_slice(&synonym_data_size.to_le_bytes());
    index_header_bytes.extend_from_slice(&synonym_data_hash);
    index_header_bytes.extend_from_slice(&empty_block_data_offset.to_le_bytes());
    index_header_bytes.extend_from_slice(&empty_block_data_size.to_le_bytes());
    index_header_bytes.extend_from_slice(&empty_block_data_hash);
    index_header_bytes.extend_from_slice(&dir_index_data_offset.to_le_bytes());
    index_header_bytes.extend_from_slice(&dir_index_data_size.to_le_bytes());
    index_header_bytes.extend_from_slice(&dir_index_data_hash);
    index_header_bytes.extend_from_slice(&index_type.to_le_bytes());
    index_header_bytes.extend_from_slice(&reserved);
    let self_hash = pad_to_64(sha1(&index_header_bytes));
    index_header_bytes.extend_from_slice(&self_hash);
    debug_assert_eq!(
        index_header_bytes.len(),
        index_header_size as usize,
        "assembled index header must match the declared 1024-byte size",
    );

    let total_size = usize::try_from(post_hash_table_offset).unwrap_or(0);
    let mut out = Vec::with_capacity(total_size);
    out.extend_from_slice(&build_index_sqpack_header());
    out.extend_from_slice(&index_header_bytes);
    debug_assert_eq!(out.len(), index_data_offset as usize);
    out.extend_from_slice(index_data_bytes);

    Ok(out)
}

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

    fn exd_triple() -> ArchiveTriple {
        ArchiveTriple::from_game_path("exd/probe.exh").expect("exd/* must route")
    }

    #[test]
    fn align_up_dat_round_trips_aligned() {
        assert_eq!(align_up_dat(0), 0);
        assert_eq!(align_up_dat(128), 128);
        assert_eq!(align_up_dat(256), 256);
    }

    #[test]
    fn align_up_dat_rounds_to_next_boundary() {
        assert_eq!(align_up_dat(1), 128);
        assert_eq!(align_up_dat(127), 128);
        assert_eq!(align_up_dat(129), 256);
    }

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

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

    #[test]
    fn build_rejects_empty_archive() {
        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_rejects_no_index_selected() {
        let mut r = SqPackRepacker::new(exd_triple(), Platform::Win32);
        r.add_file("exd/a.exd", b"x".to_vec()).unwrap();
        let err = r.build().unwrap_err();
        assert!(matches!(err, Error::RepackNoIndexSelected));
    }

    #[test]
    fn add_file_oversized_cap_pinned_at_1_gib() {
        assert_eq!(APPEND_MAX_FILE_SIZE, 1u64 << 30);
    }

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

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

    #[test]
    fn build_one_file_produces_dat_and_v1_index() {
        let mut r = SqPackRepacker::new(exd_triple(), Platform::Win32).with_index_v1();
        r.add_file("exd/a.exd", b"hello".to_vec()).unwrap();
        let archive = r.build().unwrap();
        assert!(archive.dat.len() > SQPACK_HEADER_SIZE as usize);
        assert!(archive.index.is_some());
        assert!(archive.index2.is_none());
    }

    #[test]
    fn build_both_indexes_when_both_selected() {
        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 archive = r.build().unwrap();
        assert!(archive.index.is_some());
        assert!(archive.index2.is_some());
    }

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

    #[test]
    fn build_is_byte_deterministic_across_calls() {
        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();
        r.add_file("exd/b.exd", b"world".to_vec()).unwrap();
        let a = r.build().unwrap();
        let b = r.build().unwrap();
        assert_eq!(a.dat, b.dat);
        assert_eq!(a.index, b.index);
        assert_eq!(a.index2, b.index2);
    }

    #[test]
    fn build_dat_header_is_deterministic() {
        let h1 = build_dat_header();
        let h2 = build_dat_header();
        assert_eq!(h1, h2);
        assert_eq!(h1.len(), SQPACK_HEADER_SIZE as usize);
    }

    #[test]
    fn build_index_sqpack_header_distinguishable_by_file_type() {
        let dat_hdr = build_dat_header();
        let idx_hdr = build_index_sqpack_header();
        let dat_ft = u32::from_le_bytes(dat_hdr[20..24].try_into().unwrap());
        let idx_ft = u32::from_le_bytes(idx_hdr[20..24].try_into().unwrap());
        assert_eq!(dat_ft, SqPackFileType::Dat.as_raw());
        assert_eq!(idx_ft, SqPackFileType::Index.as_raw());
    }

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

    #[test]
    fn build_emits_hash_tables_sorted_when_both_indexes_enabled() {
        let mut r = SqPackRepacker::new(exd_triple(), Platform::Win32)
            .with_index_v1()
            .with_index_v2();
        for name in [
            "exd/zzz.exd",
            "exd/aaa.exd",
            "exd/mmm.exd",
            "exd/qqq.exd",
            "exd/ccc.exd",
        ] {
            r.add_file(name, b"x".to_vec()).unwrap();
        }
        let archive = r.build().unwrap();

        let v1_bytes = archive.index.as_ref().expect("v1 index selected");
        let hash_start = (SQPACK_HEADER_SIZE + INDEX_HEADER_SIZE) as usize;
        let entry_size = INDEX_ENTRY_SIZE as usize;
        let entry_count = (v1_bytes.len() - hash_start) / entry_size;
        let mut prev_v1: u64 = 0;
        for i in 0..entry_count {
            let off = hash_start + i * entry_size;
            let h = u64::from_le_bytes(v1_bytes[off..off + 8].try_into().unwrap());
            assert!(
                i == 0 || h >= prev_v1,
                "v1 hash table not sorted at index {i}: {prev_v1:#x} > {h:#x}",
            );
            prev_v1 = h;
        }

        let v2_bytes = archive.index2.as_ref().expect("v2 index selected");
        let entry_size2 = INDEX2_ENTRY_SIZE as usize;
        let entry_count2 = (v2_bytes.len() - hash_start) / entry_size2;
        let mut prev_v2: u32 = 0;
        for i in 0..entry_count2 {
            let off = hash_start + i * entry_size2;
            let h = u32::from_le_bytes(v2_bytes[off..off + 4].try_into().unwrap());
            assert!(
                i == 0 || h >= prev_v2,
                "v2 hash table not sorted at index {i}: {prev_v2:#x} > {h:#x}",
            );
            prev_v2 = h;
        }
    }

    #[test]
    fn flate2_determinism_note_is_non_empty() {
        assert!(!FLATE2_DETERMINISM_NOTE.is_empty());
        assert!(FLATE2_DETERMINISM_NOTE.contains("flate2"));
    }

    #[test]
    fn flate2_determinism_note_covers_backend_dimension() {
        assert!(
            FLATE2_DETERMINISM_NOTE.contains("backend"),
            "determinism note must mention the backend dimension"
        );
        assert!(
            FLATE2_DETERMINISM_NOTE.contains("rust-backend"),
            "determinism note must name the default rust-backend feature"
        );
        assert!(
            FLATE2_DETERMINISM_NOTE.contains("zlib-rs"),
            "determinism note must name the zlib-rs backend feature"
        );
        assert!(
            FLATE2_DETERMINISM_NOTE.contains("zlib-ng"),
            "determinism note must name the zlib-ng backend feature"
        );
        assert!(
            FLATE2_DETERMINISM_NOTE.contains("version"),
            "determinism note must mention the flate2 dependency version dimension"
        );
    }
}
