//! Index hash-table parsing and lookup.
//!
//! Parses a `.index` (v1) file into a collected hash table and exposes a
//! typed lookup API over the parsed [`IndexEntry`] values.
//!
//! The format
//! ---------
//!
//! Each entry in an index v1 hash table is 16 bytes:
//!
//! ```text
//!   off  size  field
//!   0    8     hash    — folder_hash << 32 | file_hash (little-endian u64)
//!   8    4     data    — packed (synonym, data_file_id, byte_offset)
//!   12   4     padding
//! ```
//!
//! The packed `data` field decodes as:
//!
//! - bit 0 — `synonym`: when set, this entry is a redirect into the synonym
//!   table for a legitimate hash collision; the `offset`/`data_file_id`
//!   fields are not directly meaningful.
//! - bits 1..=3 — `data_file_id`: the `.datN` companion file the entry lives
//!   in (`0` → `.dat0`, `1` → `.dat1`, …).
//! - bits 4..=31 — `(data & ~0xF) * 8` is the byte offset within that `.datN`.
//!
//! Collision handling
//! ------------------
//!
//! Two distinct paths can legitimately hash to the same 64-bit key. The
//! on-disk format handles this by giving such entries the synonym bit and a
//! companion record in the separate **synonym table**, indexed by the full
//! UTF-8 path. The main hash-table region therefore should never contain
//! two entries with the same `hash` value — that is the job of the synonym
//! table, not of probing.
//!
//! `sqpack-rs` matches Lumina's behavior: a duplicate hash in the main table
//! is treated as a corrupted index and surfaces as [`crate::Error::DuplicateHash`].
//! The synonym table is parsed alongside the main hash table — each
//! [`SynonymEntry`] is a 256-byte record carrying the colliding path verbatim
//! plus the real `(data_file_id, byte_offset)` to dereference. Look entries
//! up by `(hash, path)` via [`Index::resolve_synonym`] when [`IndexEntry::is_synonym`]
//! is set on the matching main-table entry.
//!
//! Synonym-table layout
//! --------------------
//!
//! Each entry is 256 bytes:
//!
//! ```text
//!   off  size  field
//!   0    8     hash             — same composite key as the parent IndexEntry
//!   8    4     data             — packed (data_file_id, byte_offset) with the
//!                                synonym bit always clear (this *is* the
//!                                resolved dat location)
//!   12   4     conflict_index   — disambiguator within the table; the
//!                                end-of-table sentinel uses 0xFFFFFFFF
//!   16   240   path             — null-terminated UTF-8 game path,
//!                                zero-padded to fill the record
//! ```
//!
//! The region is terminated by a sentinel whose `conflict_index` equals
//! `u32::MAX` (real retail installs ship one or more 256-byte regions that
//! contain *only* the sentinel — the format reserves the region even when no
//! collisions exist). The parser walks until it hits the sentinel rather
//! than dividing `synonym_data_size / 256`, mirroring Lumina.

use std::collections::HashMap;
use std::io::{BufReader, Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};

use binrw::BinRead;
use tracing::{debug, debug_span, info, info_span, trace, warn};

use crate::tracing_schema::span_names;

use crate::error::{Error, Result};
use crate::header::{
    DatLocation, SqPackFileType, SqPackHeader, SqPackIndexHeader, check_data_region_fits_stream,
};
use crate::vfs::{StdFs, Vfs};

/// On-disk size of a single [`IndexEntry`] (v1 hash-table entry).
pub const INDEX_ENTRY_SIZE: u32 = 16;

/// On-disk size of a single [`SynonymEntry`] in the v1 synonym table.
///
/// Every synonym record is a fixed 256 bytes: 8 bytes hash + 4 bytes packed
/// data + 4 bytes `conflict_index` + 240 bytes of zero-padded UTF-8 path.
pub const SYNONYM_ENTRY_SIZE: u32 = 256;

/// Number of bytes reserved for the zero-padded UTF-8 path in a
/// [`SynonymEntry`] (the last field of the on-disk record).
pub const SYNONYM_PATH_LEN: usize = 240;

/// A single hash-table entry from an index v1 file.
///
/// The wire representation is 16 bytes — see the [module docs](self) for the
/// bit layout. Accessor methods decode the packed `data` field into typed
/// values; the raw `u32` is preserved via [`IndexEntry::raw_data`] for
/// callers that need to round-trip the entry unchanged.
///
/// `#[non_exhaustive]`: the public fields may grow as the wire format reveals
/// more structure. Construct via [`BinRead`] or [`Index::read`] rather than
/// a struct literal across crate boundaries.
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, BinRead)]
#[br(little)]
pub struct IndexEntry {
    /// `folder_hash << 32 | file_hash` — a single 64-bit key.
    pub hash: u64,
    /// Packed `(synonym, data_file_id, offset)` field. Use the accessor
    /// methods rather than reading this directly.
    pub(crate) data: u32,
    /// Four bytes of padding that pad the entry out to 16 bytes.
    pub(crate) padding: u32,
}

impl IndexEntry {
    /// Folder-hash half of the 64-bit key (high 32 bits).
    #[must_use]
    pub fn folder_hash(&self) -> u32 {
        #[allow(clippy::cast_possible_truncation)]
        let v = (self.hash >> 32) as u32;
        v
    }

    /// File-hash half of the 64-bit key (low 32 bits).
    #[must_use]
    pub fn file_hash(&self) -> u32 {
        #[allow(clippy::cast_possible_truncation)]
        let v = self.hash as u32;
        v
    }

    /// Raw packed `data` field, exactly as it appears on disk.
    #[must_use]
    pub fn raw_data(&self) -> u32 {
        self.data
    }

    /// `true` when the synonym bit (bit 0) is set on this entry's `data`
    /// field. Such an entry redirects into the synonym table for a
    /// legitimate path collision; its [`Self::data_file_id`] and
    /// [`Self::byte_offset`] are not directly meaningful in that case.
    #[must_use]
    pub fn is_synonym(&self) -> bool {
        (self.data & 0b1) == 0b1
    }

    /// `.datN` companion file the entry lives in (`0` → `.dat0`).
    ///
    /// Encoded in bits 1..=3 of `data`, so the value is always in `0..=7`.
    #[must_use]
    pub fn data_file_id(&self) -> u8 {
        #[allow(clippy::cast_possible_truncation)]
        let id = ((self.data >> 1) & 0b111) as u8;
        id
    }

    /// Byte offset of the entry's payload inside its [`Self::data_file_id`]
    /// companion file.
    ///
    /// Computed as `(data & !0xF) * 8`. The multiplier widens the addressable
    /// space to 32 GiB while only spending 28 bits on the offset itself; the
    /// low nibble holds the synonym bit and the data-file id.
    #[must_use]
    pub fn byte_offset(&self) -> u64 {
        u64::from(self.data & !0xF_u32) * 8
    }

    /// `(data_file_id, byte_offset)` pair — the most common consumer
    /// projection of the decoded `data` field.
    ///
    /// Returns `None` when [`Self::is_synonym`] is true: synonym entries
    /// redirect into the synonym table (see [`Index::resolve_synonym`]) and
    /// their `data_file_id`/`byte_offset` fields do not carry a meaningful
    /// `.datN` location on their own.
    #[must_use]
    pub fn dat_location(&self) -> Option<DatLocation> {
        if self.is_synonym() {
            return None;
        }
        Some(DatLocation {
            data_file_id: self.data_file_id(),
            byte_offset: self.byte_offset(),
        })
    }
}

/// A single 256-byte entry in the v1 synonym table.
///
/// Synonym entries disambiguate legitimate hash collisions in the main v1
/// hash table: when two distinct game paths hash to the same 64-bit
/// composite key, the main-table [`IndexEntry`] for those paths carries the
/// synonym bit set, and the real `(data_file_id, byte_offset)` location
/// lives in a record here — one record per colliding path. The colliding
/// path is stored verbatim in the record's [`Self::path`] field, which is
/// how the resolver distinguishes which record belongs to which path.
///
/// See the [module docs](self) for the binary layout. Note that the `data`
/// field on a synonym record always has the synonym bit clear: a synonym
/// record *is* the resolved location, it never redirects further.
///
/// `#[non_exhaustive]`: the public fields may grow as new structure is
/// surfaced from the wire format. Construct via [`BinRead`] or [`Index::read`]
/// rather than a struct literal across crate boundaries.
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, BinRead)]
#[br(little)]
pub struct SynonymEntry {
    /// 64-bit composite key — same shape as [`IndexEntry::hash`]. Multiple
    /// synonym records can share the same `hash` value (that's the whole
    /// point of the table); the distinct path-strings in [`Self::path_raw`]
    /// (and the distinct [`Self::conflict_index`] values) disambiguate them.
    pub hash: u64,
    /// Packed `(data_file_id, byte_offset)` — same encoding as
    /// [`IndexEntry::data`] but with the synonym bit (bit 0) guaranteed
    /// clear. Use [`Self::data_file_id`] / [`Self::byte_offset`] to decode.
    pub data: u32,
    /// Per-record disambiguator within the synonym table. The end-of-table
    /// sentinel uses `0xFFFFFFFF`; non-sentinel records use sequential
    /// small integers (and crucially this field is the secondary key in
    /// the [`IndexHashKey::V1Synonym`](crate::verify::IndexHashKey::V1Synonym)
    /// variant the hash-keyed walker uses to keep multiple synonyms for the
    /// same parent hash distinct).
    pub conflict_index: u32,
    /// Raw 240-byte zero-padded UTF-8 game path. Use [`Self::path`] to get a
    /// borrowed `&str` view trimmed at the first null byte.
    pub path_raw: [u8; SYNONYM_PATH_LEN],
}

impl SynonymEntry {
    /// 64-bit composite hash key — `folder_hash << 32 | file_hash`. Same
    /// shape as [`IndexEntry::hash`].
    #[must_use]
    pub fn hash(&self) -> u64 {
        self.hash
    }

    /// Raw packed `data` field, exactly as it appears on disk. Symmetric
    /// with [`IndexEntry::raw_data`].
    #[must_use]
    pub fn raw_data(&self) -> u32 {
        self.data
    }

    /// `.datN` companion file the entry lives in (`0` → `.dat0`). Decoded
    /// from bits 1..=3 of [`Self::data`] — same encoding as
    /// [`IndexEntry::data_file_id`].
    #[must_use]
    pub fn data_file_id(&self) -> u8 {
        #[allow(clippy::cast_possible_truncation)]
        let id = ((self.data >> 1) & 0b111) as u8;
        id
    }

    /// Byte offset of the entry's payload inside its
    /// [`Self::data_file_id`] companion file. Same encoding as
    /// [`IndexEntry::byte_offset`].
    #[must_use]
    pub fn byte_offset(&self) -> u64 {
        u64::from(self.data & !0xF_u32) * 8
    }

    /// Per-record disambiguator within the synonym table.
    ///
    /// Non-sentinel records use sequentially-allocated small integers;
    /// the end-of-table sentinel uses `u32::MAX`. The parser strips the
    /// sentinel before storing entries, so any value returned by this
    /// accessor on a [`SynonymEntry`] read out of [`Index::synonyms`] is
    /// **not** `u32::MAX`.
    #[must_use]
    pub fn conflict_index(&self) -> u32 {
        self.conflict_index
    }

    /// Resolved [`DatLocation`] for this synonym record.
    ///
    /// Always returns a usable location: synonym records always have the
    /// synonym bit clear on their `data` field by construction (a synonym
    /// record *is* the resolved location — it never redirects further).
    /// Symmetric with [`IndexEntry::dat_location`] but infallible.
    #[must_use]
    pub fn dat_location(&self) -> DatLocation {
        DatLocation {
            data_file_id: self.data_file_id(),
            byte_offset: self.byte_offset(),
        }
    }

    /// Borrowed UTF-8 view of the stored path.
    ///
    /// Trims at the first `\0` (the path is null-terminated and zero-padded
    /// to fill 240 bytes). The path is supposed to be ASCII; bytes that
    /// fail UTF-8 validation surface as a borrowed [`std::str::Utf8Error`]
    /// — see [`Self::path`] for the lossy convenience accessor that
    /// callers usually want.
    ///
    /// Returns `&str` borrowing into `self`; no allocation.
    ///
    /// # Errors
    ///
    /// Returns [`std::str::Utf8Error`] when the bytes up to the first null
    /// (or to the end of the field if no null is present) are not valid
    /// UTF-8.
    pub fn path_utf8(&self) -> std::result::Result<&str, std::str::Utf8Error> {
        let end = self
            .path_raw
            .iter()
            .position(|&b| b == 0)
            .unwrap_or(self.path_raw.len());
        std::str::from_utf8(&self.path_raw[..end])
    }

    /// Borrowed UTF-8 view of the stored path, replacing invalid bytes with
    /// `U+FFFD`. Trims at the first `\0`. Allocates a fresh `String` only
    /// when the source bytes are not valid UTF-8 — the common (all-ASCII)
    /// case borrows.
    #[must_use]
    pub fn path(&self) -> std::borrow::Cow<'_, str> {
        let end = self
            .path_raw
            .iter()
            .position(|&b| b == 0)
            .unwrap_or(self.path_raw.len());
        String::from_utf8_lossy(&self.path_raw[..end])
    }
}

/// A parsed `.index` (v1) file.
///
/// Holds the two header structs, the list of entries in on-disk order, the
/// parsed [`SynonymEntry`] list (sentinel stripped), and lookup tables for
/// constant-time access by hash key.
#[derive(Debug)]
#[allow(clippy::struct_field_names)]
pub struct Index {
    header: SqPackHeader,
    index_header: SqPackIndexHeader,
    entries: Vec<IndexEntry>,
    by_hash: HashMap<u64, usize>,
    synonyms: Vec<SynonymEntry>,
    /// For each unique hash key in `synonyms`, the indices of the records
    /// that share that key. Multiple synonyms can carry the same `hash`
    /// (that's the whole point of the table), so this maps `hash → Vec<usize>`
    /// rather than `hash → usize`.
    synonyms_by_hash: HashMap<u64, Vec<usize>>,
    source_path: Option<PathBuf>,
}

impl Index {
    /// Open and parse a `.index` file from `path` using the default
    /// [`StdFs`] backend.
    ///
    /// Thin wrapper around [`Index::open_in`] that supplies [`StdFs`] as the
    /// filesystem source. The two-stage shape lets the test suite (and
    /// downstream consumers) inject an [`InMemoryFs`](crate::vfs::InMemoryFs) or
    /// any other [`Vfs`] without giving up the ergonomic one-arg path
    /// constructor on the common case.
    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
        Index::open_in(path, &StdFs::new())
    }

    /// Open and parse a `.index` file from `path` via an explicit [`Vfs`].
    ///
    /// The [`Vfs`] is consulted exactly once (to acquire the read handle);
    /// the parsed [`Index`] does not retain a reference. The `source_path`
    /// is recorded as the supplied `path` regardless of which backend
    /// resolved it.
    pub fn open_in(path: impl AsRef<Path>, vfs: &dyn Vfs) -> Result<Self> {
        let path = path.as_ref();
        let span = info_span!(span_names::READ_INDEX, path = %path.display());
        let _enter = span.enter();
        let handle = vfs.open_read(path).map_err(|e| Error::io_at(path, e))?;
        let mut reader = BufReader::new(handle);
        let mut idx = Index::read_inner(&mut reader)?;
        idx.source_path = Some(path.to_path_buf());
        Ok(idx)
    }

    /// Parse a `.index` (v1) file from an arbitrary reader.
    ///
    /// The reader must support [`Seek`] because the index-specific header
    /// declares the hash-table region's offset as an absolute file position,
    /// independent of where the headers end.
    pub fn read<R: Read + Seek>(reader: &mut R) -> Result<Self> {
        let span = info_span!(span_names::READ_INDEX);
        let _enter = span.enter();
        Index::read_inner(reader)
    }

    fn read_inner<R: Read + Seek>(reader: &mut R) -> Result<Self> {
        // The 24-byte minimum is the fixed logical prefix of SqPackHeader
        // (magic..file_type) — any declared `header.size` below that is a
        // structurally impossible value that would seek backwards.
        const SQPACK_HEADER_MIN: u32 = 24;

        let started_at = std::time::Instant::now();

        let header = {
            let _span = debug_span!(span_names::SQPACK_HEADER).entered();
            SqPackHeader::parse(reader, SqPackFileType::Index)?
        };
        debug!(
            platform_id = header.platform_id,
            version = header.version,
            "parsed SqPack header"
        );

        // The header's declared `size` is where the index header starts.
        // A corrupt `size = 0` (or any value smaller than the bytes already
        // consumed) would seek backwards and re-parse the magic — reject that
        // before issuing the seek.
        if header.size < SQPACK_HEADER_MIN {
            return Err(Error::InvalidHeaderSize {
                field: "sqpack_header.size",
                value: header.size,
                minimum: SQPACK_HEADER_MIN,
            });
        }

        // The generic SqPack header has a small fixed logical payload but is
        // padded out to `header.size` bytes (in practice always 1024) before
        // the index-specific header starts. Seek over the padding rather than
        // letting binrw read zero bytes from the gap.
        reader.seek(SeekFrom::Start(u64::from(header.size)))?;

        let index_header = {
            let _span = debug_span!(span_names::INDEX_HEADER).entered();
            SqPackIndexHeader::parse(reader)?
        };
        debug!(
            index_data_offset = index_header.index_data_offset,
            index_data_size = index_header.index_data_size,
            number_of_data_file = index_header.number_of_data_file,
            "parsed SqPack index header"
        );

        if index_header.index_data_size % INDEX_ENTRY_SIZE != 0 {
            return Err(Error::MisalignedIndexData {
                size: index_header.index_data_size,
                entry_size: INDEX_ENTRY_SIZE,
            });
        }
        let entry_count = (index_header.index_data_size / INDEX_ENTRY_SIZE) as usize;

        // Reject an `index_data_offset` that would land inside (or before) the
        // two headers we already consumed — same class of corruption as
        // `header.size = 0`.
        let min_offset = header.size.saturating_add(index_header.size);
        if index_header.index_data_offset < min_offset {
            return Err(Error::InvalidHeaderSize {
                field: "index_header.index_data_offset",
                value: index_header.index_data_offset,
                minimum: min_offset,
            });
        }

        // Bound the declared hash-table region by the stream length BEFORE
        // sizing any `Vec`/`HashMap` from `entry_count`. An adversarial
        // `index_data_size` (e.g. 0x8500_0000) would otherwise drive
        // `Vec::with_capacity(~138M entries)` and OOM before any per-entry
        // read could fail on EOF.
        check_data_region_fits_stream(
            reader,
            index_header.index_data_offset,
            index_header.index_data_size,
        )?;

        reader.seek(SeekFrom::Start(u64::from(index_header.index_data_offset)))?;

        let mut entries = Vec::with_capacity(entry_count);
        let mut by_hash = HashMap::with_capacity(entry_count);
        {
            let _span = debug_span!(span_names::HASH_TABLE, entry_count).entered();
            for i in 0..entry_count {
                let entry =
                    IndexEntry::read(reader).map_err(|e| Error::decode("index_entry", &e))?;
                trace!(
                    index = i,
                    hash = format_args!("{:#018x}", entry.hash),
                    data = format_args!("{:#010x}", entry.data),
                    "read index entry"
                );
                // Check for duplicates BEFORE mutating either container so the
                // two stay in sync even if this loop ever grows a
                // continue-on-error path.
                if by_hash.contains_key(&entry.hash) {
                    return Err(Error::DuplicateHash { hash: entry.hash });
                }
                by_hash.insert(entry.hash, i);
                entries.push(entry);
            }
        }

        let (synonyms, synonyms_by_hash) = read_synonym_table(reader, &index_header)?;

        #[allow(clippy::cast_possible_truncation)]
        let elapsed_ms = started_at.elapsed().as_millis() as u64;
        info!(
            entries = entries.len(),
            synonyms = synonyms.len(),
            dat_files = index_header.number_of_data_file,
            elapsed_ms,
            "opened SqPack index"
        );

        Ok(Index {
            header,
            index_header,
            entries,
            by_hash,
            synonyms,
            synonyms_by_hash,
            source_path: None,
        })
    }

    /// Path the index was opened from, if it was opened via [`Index::open`].
    /// `None` for indexes parsed via [`Index::read`].
    #[must_use]
    pub fn source_path(&self) -> Option<&Path> {
        self.source_path.as_deref()
    }

    /// Generic [`SqPackHeader`] this index was parsed from.
    #[must_use]
    pub fn header(&self) -> &SqPackHeader {
        &self.header
    }

    /// Index-specific segment header.
    #[must_use]
    pub fn index_header(&self) -> &SqPackIndexHeader {
        &self.index_header
    }

    /// Number of `.datN` companion files this index points into.
    #[must_use]
    pub fn data_file_count(&self) -> u32 {
        self.index_header.number_of_data_file
    }

    /// All parsed entries, in on-disk order.
    ///
    /// On-disk order is stable per file but format-defined — the v1 index
    /// emits its hash table sorted ascending by the 64-bit composite key, but
    /// the format layer does not re-sort on parse. Callers that need a
    /// guaranteed ordering across implementations should use
    /// [`Index::iter`] (the canonical iteration entry point) which carries
    /// the explicit ordering contract in its rustdoc.
    #[must_use]
    pub fn entries(&self) -> &[IndexEntry] {
        &self.entries
    }

    /// Iterate over every entry in this index.
    ///
    /// Returns a borrowed iterator over the parsed [`IndexEntry`] list. The
    /// iterator is [`ExactSizeIterator`] (the entry count is known up front)
    /// and yields entries in **on-disk order**, which for any single `.index`
    /// file is stable and deterministic across calls on the same instance.
    ///
    /// Shipped retail `.index` files emit their hash table already sorted
    /// ascending by the 64-bit composite key; the parser preserves that
    /// order verbatim. A caller that needs a strict cross-implementation
    /// "sorted by hash" guarantee can sort the collected output explicitly.
    ///
    /// Typical use: a launcher's integrity scan iterating every entry,
    /// resolving each to a `(dat_file_id, byte_offset)` via
    /// [`IndexEntry::dat_location`], and streaming each `.dat` payload.
    /// Iteration is cheap — entries are already in memory — so the cost of
    /// a full scan is dominated by the downstream payload reads, not by the
    /// index walk itself.
    pub fn iter(&self) -> std::slice::Iter<'_, IndexEntry> {
        self.entries.iter()
    }

    /// Number of entries in the hash table.
    #[must_use]
    pub fn len(&self) -> usize {
        self.entries.len()
    }

    /// `true` when the hash table is empty.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }

    /// Look up an entry by its 64-bit hash key (`folder_hash << 32 | file_hash`).
    #[must_use]
    pub fn get(&self, hash: u64) -> Option<&IndexEntry> {
        let idx = *self.by_hash.get(&hash)?;
        Some(&self.entries[idx])
    }

    /// Look up an entry by the separate folder + file hash halves.
    #[must_use]
    pub fn get_by_parts(&self, folder_hash: u32, file_hash: u32) -> Option<&IndexEntry> {
        let key = (u64::from(folder_hash) << 32) | u64::from(file_hash);
        self.get(key)
    }

    /// All parsed synonym-table entries, in on-disk order. Sentinel-only
    /// archives (the common case — all 59 retail v1 archives ship a
    /// sentinel-only synonym region) return an empty slice.
    #[must_use]
    pub fn synonyms(&self) -> &[SynonymEntry] {
        &self.synonyms
    }

    /// Iterate over the synonym records (if any) sharing the supplied
    /// 64-bit composite hash key.
    ///
    /// Multiple records can share a single hash — that's the whole point
    /// of the synonym table. The returned iterator yields each matching
    /// record in on-disk order; an empty iterator means "no synonym
    /// records for this hash", which is the common case in retail files
    /// today (every shipping v1 archive currently ships a sentinel-only
    /// region).
    #[must_use]
    pub fn synonyms_by_hash(&self, hash: u64) -> SynonymsByHash<'_> {
        let indices = self.synonyms_by_hash.get(&hash).map(Vec::as_slice);
        SynonymsByHash {
            synonyms: &self.synonyms,
            indices: indices.unwrap_or(&[]).iter(),
        }
    }

    /// Resolve a synonym entry by `(hash, game_path)`.
    ///
    /// Walks the records sharing `hash` and returns the first whose stored
    /// path matches `game_path` byte-for-byte (after stripping the record's
    /// trailing null padding). Returns `None` when no record's path
    /// matches, which the lookup pipeline treats as "not in this archive"
    /// rather than as a parse failure — a well-formed install never lands
    /// here, but a partially-corrupted synonym region shouldn't panic.
    ///
    /// The lookup is byte-comparative against [`SynonymEntry::path`]'s
    /// borrowed view. Callers should pass an already-normalised path
    /// (lowercase ASCII, `/` separators) — same canonical shape as the
    /// rest of the v1 lookup pipeline.
    #[must_use]
    pub fn resolve_synonym(&self, hash: u64, game_path: &str) -> Option<&SynonymEntry> {
        let indices = self.synonyms_by_hash.get(&hash)?;
        for &idx in indices {
            let entry = &self.synonyms[idx];
            if entry.path() == game_path {
                return Some(entry);
            }
        }
        None
    }
}

/// Parsed-synonym-region return pair: the sentinel-stripped record list
/// plus a `hash → Vec<index-into-list>` lookup map for O(1) by-hash
/// fan-out from the hash-keyed walker.
type ParsedSynonyms = (Vec<SynonymEntry>, HashMap<u64, Vec<usize>>);

/// Parse the synonym-table region of a v1 `.index` file from `reader`,
/// using the offset + size declared by `index_header`. Returns the
/// sentinel-stripped record list plus a `hash → Vec<index>` lookup map.
///
/// Detection rule: walk 256-byte records in order; stop at the first
/// record whose `conflict_index == u32::MAX` (the end-of-table sentinel).
/// The crate does **not** trust `synonym_data_size / 256` as the record
/// count — a real retail install reserves a full 256-byte region for
/// indexes with no real collisions, populated with only the sentinel.
fn read_synonym_table<R: Read + Seek>(
    reader: &mut R,
    index_header: &SqPackIndexHeader,
) -> Result<ParsedSynonyms> {
    let offset = index_header.synonym_data_offset;
    let size = index_header.synonym_data_size;

    // No declared region — nothing to do.
    if size == 0 {
        return Ok((Vec::new(), HashMap::new()));
    }

    // Size must be a multiple of the fixed 256-byte record stride.
    if size % SYNONYM_ENTRY_SIZE != 0 {
        return Err(Error::MisalignedIndexData {
            size,
            entry_size: SYNONYM_ENTRY_SIZE,
        });
    }

    // Bound the declared region against the stream before sizing any
    // allocations — same defensive shape as `check_data_region_fits_stream`.
    check_data_region_fits_stream(reader, offset, size)?;

    let max_records = (size / SYNONYM_ENTRY_SIZE) as usize;
    reader.seek(SeekFrom::Start(u64::from(offset)))?;

    // Capacity is at most `max_records - 1` (the sentinel itself is
    // stripped). In the common retail case `max_records == 1` and the
    // sentinel is the only record, so we end up with an empty Vec.
    let mut synonyms: Vec<SynonymEntry> = Vec::new();
    let mut synonyms_by_hash: HashMap<u64, Vec<usize>> = HashMap::new();

    // Tracks whether the loop exited via the sentinel `break` or via the
    // count cap. A region with no sentinel still parses successfully — every
    // record is treated as a real entry — but the format docs say the parser
    // walks until it hits the sentinel, so the count-cap exit is an
    // observability-worthy anomaly worth a one-time `warn!`.
    let mut sentinel_seen = false;
    for i in 0..max_records {
        let entry = SynonymEntry::read(reader).map_err(|e| Error::decode("synonym_entry", &e))?;

        // End-of-table sentinel: `conflict_index == u32::MAX`. The other
        // sentinel fields (`hash == u64::MAX`, `data == 0`, all-zero path)
        // are not strictly required for detection per the format docs,
        // but they shape what a well-formed install looks like; trace
        // anomalies for forensics rather than failing parse.
        if entry.conflict_index == u32::MAX {
            trace!(
                index = i,
                "synonym table sentinel reached after {} records",
                synonyms.len()
            );
            sentinel_seen = true;
            break;
        }

        let idx = synonyms.len();
        synonyms_by_hash.entry(entry.hash).or_default().push(idx);
        synonyms.push(entry);
    }

    if !sentinel_seen {
        // Region exhausted with no sentinel. Every 256-byte slot was a
        // real-looking record; this violates the format's "walk until
        // sentinel" rule but the parse still completes successfully so
        // callers see the records. Emit one `warn!` so a default-level
        // subscriber sees the anomaly.
        warn!(
            entries = synonyms.len(),
            bytes_reserved = size,
            "synonym table missing end-of-table sentinel, parsed N records by size cap",
        );
    }

    debug!(
        records = synonyms.len(),
        bytes_reserved = size,
        "parsed v1 synonym table"
    );

    Ok((synonyms, synonyms_by_hash))
}

impl<'a> IntoIterator for &'a Index {
    type Item = &'a IndexEntry;
    type IntoIter = std::slice::Iter<'a, IndexEntry>;

    fn into_iter(self) -> Self::IntoIter {
        self.iter()
    }
}

/// Iterator yielded by [`Index::synonyms_by_hash`].
///
/// Borrows into the parsed [`Index`] and yields `&SynonymEntry` references
/// in on-disk order for every record sharing the supplied hash key. The
/// iterator is [`ExactSizeIterator`] — the record count is known up front.
#[derive(Debug, Clone)]
pub struct SynonymsByHash<'a> {
    synonyms: &'a [SynonymEntry],
    indices: std::slice::Iter<'a, usize>,
}

impl<'a> Iterator for SynonymsByHash<'a> {
    type Item = &'a SynonymEntry;

    fn next(&mut self) -> Option<Self::Item> {
        self.indices.next().map(|&i| &self.synonyms[i])
    }

    fn size_hint(&self) -> (usize, Option<usize>) {
        self.indices.size_hint()
    }
}

impl ExactSizeIterator for SynonymsByHash<'_> {}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::header::SQPACK_HEADER_SIZE;
    use crate::header::tests::make_sqpack_header;
    use std::io::Cursor;

    /// Build a 1024-byte index header. We only care about a small handful of
    /// fields for testing; the rest can stay zero.
    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()); // size
        buf.extend_from_slice(&1u32.to_le_bytes()); // version
        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); // index_data_hash
        buf.extend_from_slice(&number_of_data_file.to_le_bytes());
        buf.extend_from_slice(&0u32.to_le_bytes()); // synonym_data_offset
        buf.extend_from_slice(&0u32.to_le_bytes()); // synonym_data_size
        buf.resize(buf.len() + 64, 0); // synonym_data_hash
        buf.extend_from_slice(&0u32.to_le_bytes()); // empty_block_data_offset
        buf.extend_from_slice(&0u32.to_le_bytes()); // empty_block_data_size
        buf.resize(buf.len() + 64, 0); // empty_block_data_hash
        buf.extend_from_slice(&0u32.to_le_bytes()); // dir_index_data_offset
        buf.extend_from_slice(&0u32.to_le_bytes()); // dir_index_data_size
        buf.resize(buf.len() + 64, 0); // dir_index_data_hash
        buf.extend_from_slice(&0u32.to_le_bytes()); // index_type
        buf.resize(buf.len() + 656, 0); // _reserved
        buf.resize(buf.len() + 64, 0); // self_hash
        assert_eq!(buf.len(), 1024);
        buf
    }

    fn make_entry_bytes(hash: u64, data: u32) -> [u8; 16] {
        let mut buf = [0u8; 16];
        buf[..8].copy_from_slice(&hash.to_le_bytes());
        buf[8..12].copy_from_slice(&data.to_le_bytes());
        // padding stays zero
        buf
    }

    /// Build a minimal valid index file with the given entries.
    fn make_index_file(entries: &[(u64, u32)]) -> Vec<u8> {
        let mut buf = Vec::new();
        buf.extend_from_slice(&make_sqpack_header(2)); // 1024; 2 = Index wire value
        // Place the hash table right after the two headers.
        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));
        assert_eq!(buf.len(), index_data_offset as usize);
        for (hash, data) in entries {
            buf.extend_from_slice(&make_entry_bytes(*hash, *data));
        }
        buf
    }

    // ----- IndexEntry decoding -----

    #[test]
    fn entry_decodes_clean_offset() {
        // data = 0x123450 → low nibble 0, so synonym=false, data_file_id=0,
        // raw_offset = 0x123450 * 8 = 0x919_2800.
        let entry = IndexEntry {
            hash: 0xAAAA_BBBB_CCCC_DDDD,
            data: 0x0012_3450,
            padding: 0,
        };
        assert!(!entry.is_synonym());
        assert_eq!(entry.data_file_id(), 0);
        assert_eq!(entry.byte_offset(), 0x0012_3450u64 * 8);
        assert_eq!(entry.folder_hash(), 0xAAAA_BBBB);
        assert_eq!(entry.file_hash(), 0xCCCC_DDDD);
    }

    #[test]
    fn entry_decodes_data_file_id() {
        // bits 1..=3 = 0b011 = 3
        let entry = IndexEntry {
            hash: 0,
            data: 0b0000_0000_0000_0000_0000_0000_0000_0110,
            padding: 0,
        };
        assert!(!entry.is_synonym());
        assert_eq!(entry.data_file_id(), 3);
        // (data & !0xF) = 0; byte_offset = 0.
        assert_eq!(entry.byte_offset(), 0);
    }

    #[test]
    fn entry_decodes_synonym_bit() {
        let entry = IndexEntry {
            hash: 0,
            data: 0b0000_0001,
            padding: 0,
        };
        assert!(entry.is_synonym());
        assert_eq!(entry.data_file_id(), 0);
        assert_eq!(entry.byte_offset(), 0);
    }

    #[test]
    fn entry_max_offset() {
        // Top bit set, low nibble zero.
        let entry = IndexEntry {
            hash: 0,
            data: 0xFFFF_FFF0,
            padding: 0,
        };
        assert_eq!(entry.byte_offset(), u64::from(0xFFFF_FFF0u32) * 8);
        // The total addressable space is 32 GiB.
        assert!(entry.byte_offset() < (1u64 << 35));
    }

    // ----- Full file parsing -----

    #[test]
    fn parses_valid_index() {
        let entries = [
            (0x0000_0001_0000_0002u64, 0x0000_0020u32), // file_id 0, offset 0x100
            (0x0000_0003_0000_0004u64, 0x0000_0044u32), // file_id 2, offset 0x200
        ];
        let buf = make_index_file(&entries);
        let mut cur = Cursor::new(buf);
        let idx = Index::read(&mut cur).unwrap();
        assert_eq!(idx.len(), 2);
        assert!(!idx.is_empty());

        let e0 = idx.get(0x0000_0001_0000_0002).unwrap();
        // 0x20 & !0xF = 0x20, *8 = 0x100
        assert_eq!(e0.byte_offset(), 0x20u64 * 8);
        assert_eq!(e0.data_file_id(), 0);

        let e1 = idx.get_by_parts(0x0000_0003, 0x0000_0004).unwrap();
        assert_eq!(e1.data_file_id(), 2);
        assert_eq!(e1.byte_offset(), 0x40u64 * 8);
    }

    #[test]
    fn lookup_miss_returns_none() {
        let buf = make_index_file(&[(0x1u64, 0)]);
        let mut cur = Cursor::new(buf);
        let idx = Index::read(&mut cur).unwrap();
        assert!(idx.get(0xDEAD_BEEF).is_none());
        assert!(idx.get_by_parts(0, 0xDEAD_BEEF).is_none());
    }

    #[test]
    fn rejects_bad_magic() {
        let mut buf = make_index_file(&[]);
        buf[0] = b'X';
        let mut cur = Cursor::new(buf);
        let err = Index::read(&mut cur).unwrap_err();
        assert!(matches!(err, Error::InvalidMagic));
    }

    #[test]
    fn rejects_truncated_index() {
        // Only the 1024-byte SqPack header — the index header read should fail.
        let buf = make_sqpack_header(2);
        let mut cur = Cursor::new(buf);
        assert!(Index::read(&mut cur).is_err());
    }

    #[test]
    fn rejects_truncated_hash_table() {
        // Declare two entries, supply one.
        let entries = [(0x1u64, 0u32), (0x2u64, 0u32)];
        let mut buf = make_index_file(&entries);
        // Cut the last 16 bytes — the second entry.
        buf.truncate(buf.len() - 16);
        let mut cur = Cursor::new(buf);
        assert!(Index::read(&mut cur).is_err());
    }

    #[test]
    fn rejects_duplicate_hash() {
        let entries = [(0x42u64, 0x20u32), (0x42u64, 0x44u32)];
        let buf = make_index_file(&entries);
        let mut cur = Cursor::new(buf);
        let err = Index::read(&mut cur).unwrap_err();
        match err {
            Error::DuplicateHash { hash } => assert_eq!(hash, 0x42),
            other => panic!("unexpected: {other:?}"),
        }
    }

    #[test]
    fn rejects_misaligned_index_data() {
        // Build a header that lies about the size — not a multiple of 16.
        let mut buf = Vec::new();
        buf.extend_from_slice(&make_sqpack_header(2));
        buf.extend_from_slice(&make_index_header(SQPACK_HEADER_SIZE + 1024, 17, 1));
        // No need to supply the body — the misalignment check fires first.
        let mut cur = Cursor::new(buf);
        let err = Index::read(&mut cur).unwrap_err();
        assert!(matches!(
            err,
            Error::MisalignedIndexData {
                size: 17,
                entry_size: 16
            }
        ));
    }

    #[test]
    fn dat_location_round_trip() {
        let entry = IndexEntry {
            hash: 0,
            data: 0x1234_5678 & !0x1, // ensure synonym clear
            padding: 0,
        };
        let loc = entry.dat_location().expect("non-synonym entry");
        assert_eq!(loc.data_file_id, entry.data_file_id());
        assert_eq!(loc.byte_offset, entry.byte_offset());
    }

    #[test]
    fn dat_location_is_none_for_synonym() {
        // Synonym bit set AND non-zero file_id/offset — accessors still decode
        // the raw values, but the typed location refuses to claim it points
        // anywhere useful.
        let entry = IndexEntry {
            hash: 0,
            data: 0x1234_5679, // bit 0 set, bits 1..=3 = 0b100 = 4
            padding: 0,
        };
        assert!(entry.is_synonym());
        assert_eq!(entry.data_file_id(), 4);
        assert_eq!(entry.byte_offset(), u64::from(0x1234_5679u32 & !0xFu32) * 8);
        assert!(entry.dat_location().is_none());
    }

    #[test]
    fn entry_decodes_max_data_file_id() {
        // data_file_id is encoded in bits 1..=3, so the max bit pattern is
        // 0b1110 = 7.
        let entry = IndexEntry {
            hash: 0,
            data: 0b1110,
            padding: 0,
        };
        assert!(!entry.is_synonym());
        assert_eq!(entry.data_file_id(), 7);
    }

    /// Build an index file with a tunable `header.size` (controls the padding
    /// gap between the `SqPack` header and the index header) and an
    /// `extra_data_offset_padding` gap between the two headers and the start
    /// of the hash table.
    fn make_index_file_with_layout(
        sqpack_header_size: u32,
        extra_data_offset_padding: u32,
        entries: &[(u64, u32)],
    ) -> Vec<u8> {
        let mut buf = Vec::new();
        // SqPack header with the requested declared `size`.
        let mut hdr = make_sqpack_header(2); // 2 = Index wire value
        hdr[12..16].copy_from_slice(&sqpack_header_size.to_le_bytes());
        hdr.resize(sqpack_header_size as usize, 0);
        buf.extend_from_slice(&hdr);

        let index_data_offset = sqpack_header_size + 1024 + extra_data_offset_padding;
        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));
        // Padding gap before the hash table.
        buf.resize(index_data_offset as usize, 0);
        for (hash, data) in entries {
            buf.extend_from_slice(&make_entry_bytes(*hash, *data));
        }
        buf
    }

    #[test]
    fn parses_larger_sqpack_header_size() {
        // header.size = 2048 — the padding-aware seek must skip past the
        // declared size, not the fixed 1024 default.
        let entries = [(0x1111_2222_3333_4444u64, 0x0000_0030u32)];
        let buf = make_index_file_with_layout(2048, 0, &entries);
        let mut cur = Cursor::new(buf);
        let idx = Index::read(&mut cur).unwrap();
        assert_eq!(idx.len(), 1);
        assert_eq!(idx.header().size, 2048);
        let e = idx.get(0x1111_2222_3333_4444).unwrap();
        assert_eq!(e.byte_offset(), 0x30u64 * 8);
    }

    #[test]
    fn parses_index_with_data_offset_padding_gap() {
        // index_data_offset sits 256 bytes past the back-to-back headers.
        let entries = [(0xAAAAu64, 0x0000_0020u32)];
        let buf = make_index_file_with_layout(1024, 256, &entries);
        let mut cur = Cursor::new(buf);
        let idx = Index::read(&mut cur).unwrap();
        assert_eq!(idx.len(), 1);
        let e = idx.get(0xAAAA).unwrap();
        assert_eq!(e.byte_offset(), 0x20u64 * 8);
    }

    #[test]
    fn rejects_zero_sqpack_header_size() {
        // header.size = 0 would seek back over the magic and re-parse it as
        // the index header — reject before issuing the seek.
        let mut buf = make_sqpack_header(2);
        buf[12..16].copy_from_slice(&0u32.to_le_bytes());
        // Append a stub index header + entry region so a buggy parser could
        // keep going past the bad size.
        buf.extend_from_slice(&make_index_header(SQPACK_HEADER_SIZE + 1024, 0, 1));
        let mut cur = Cursor::new(buf);
        let err = Index::read(&mut cur).unwrap_err();
        match err {
            Error::InvalidHeaderSize { field, value, .. } => {
                assert_eq!(field, "sqpack_header.size");
                assert_eq!(value, 0);
            }
            other => panic!("unexpected: {other:?}"),
        }
    }

    #[test]
    fn rejects_zero_index_data_offset() {
        // index_data_offset = 0 would seek back to the start of the file and
        // re-parse the magic as hash-table entries.
        let mut buf = Vec::new();
        buf.extend_from_slice(&make_sqpack_header(2));
        buf.extend_from_slice(&make_index_header(0, 0, 1));
        let mut cur = Cursor::new(buf);
        let err = Index::read(&mut cur).unwrap_err();
        match err {
            Error::InvalidHeaderSize { field, value, .. } => {
                assert_eq!(field, "index_header.index_data_offset");
                assert_eq!(value, 0);
            }
            other => panic!("unexpected: {other:?}"),
        }
    }

    #[test]
    fn rejects_duplicate_hash_with_distinct_payloads() {
        // Same hash, *different* `data` payloads — proves DuplicateHash fires
        // on the hash collision itself rather than on byte-for-byte identical
        // entries.
        let entries = [(0x99u64, 0x0000_0020u32), (0x99u64, 0xDEAD_BEE0u32)];
        let buf = make_index_file(&entries);
        let mut cur = Cursor::new(buf);
        let err = Index::read(&mut cur).unwrap_err();
        match err {
            Error::DuplicateHash { hash } => assert_eq!(hash, 0x99),
            other => panic!("unexpected: {other:?}"),
        }
    }

    // ----- Additional accessor coverage -----

    #[test]
    fn raw_data_accessor_matches_field() {
        let entry = IndexEntry {
            hash: 0,
            data: 0xABCD_EF12,
            padding: 0,
        };
        assert_eq!(entry.raw_data(), 0xABCD_EF12);
    }

    #[test]
    fn all_data_file_ids_decode_correctly() {
        // bits 1..=3 of `data` encode the file id; verify all 8 values.
        for id in 0u8..=7u8 {
            let data = u32::from(id) << 1;
            let entry = IndexEntry {
                hash: 0,
                data,
                padding: 0,
            };
            assert_eq!(entry.data_file_id(), id, "failed for id={id}");
            assert!(!entry.is_synonym(), "id={id} should not be a synonym");
        }
    }

    #[test]
    fn max_data_file_id_with_non_zero_offset() {
        // data_file_id = 7 (bits 1..=3 = 0b111) with a realistic offset.
        // data = 0b1110 | (0x1000_0000 << 4) but we just directly craft the word.
        // byte_offset = (data & !0xF) * 8. With data = 0xFFFF_FFFE:
        //   synonym = 0, file_id = bits[1..=3] = 0b111 = 7,
        //   offset  = (0xFFFF_FFF0) * 8 = 0x7FFF_FFF8_0 (within 35-bit space).
        let entry = IndexEntry {
            hash: 0xCAFE_BABE_DEAD_BEEF,
            data: 0xFFFF_FFFE,
            padding: 0,
        };
        assert!(!entry.is_synonym());
        assert_eq!(entry.data_file_id(), 7);
        assert_eq!(entry.folder_hash(), 0xCAFE_BABE);
        assert_eq!(entry.file_hash(), 0xDEAD_BEEF);
        let expected_offset = u64::from(0xFFFF_FFF0u32) * 8;
        assert_eq!(entry.byte_offset(), expected_offset);
        // Must still fit within the 35-bit address space.
        assert!(entry.byte_offset() < (1u64 << 35));
    }

    #[test]
    fn byte_offset_encode_decode_samples() {
        // Property-style spot checks: for several representative data words
        // with a clear low nibble, verify that masking and multiplying round-trip.
        let samples: &[u32] = &[
            0x0000_0010,
            0x0000_0020,
            0x0000_0FF0,
            0x0001_0000,
            0x1234_5670,
            0xFFFF_FFF0,
        ];
        for &data in samples {
            assert_eq!(
                data & 0xF,
                0,
                "sample {data:#010x} has a non-zero low nibble"
            );
            let entry = IndexEntry {
                hash: 0,
                data,
                padding: 0,
            };
            assert_eq!(
                entry.byte_offset(),
                u64::from(data) * 8,
                "mismatch for data={data:#010x}"
            );
        }
    }

    #[test]
    fn index_zero_entries_is_empty() {
        let buf = make_index_file(&[]);
        let mut cur = Cursor::new(buf);
        let idx = Index::read(&mut cur).unwrap();
        assert_eq!(idx.len(), 0);
        assert!(idx.is_empty());
        assert_eq!(idx.entries().len(), 0);
        assert!(idx.get(0).is_none());
        assert!(idx.get_by_parts(0, 0).is_none());
    }

    #[test]
    fn index_exposes_data_file_count() {
        // number_of_data_file = 5 in the header — data_file_count() must echo it.
        let mut buf = Vec::new();
        buf.extend_from_slice(&make_sqpack_header(2));
        buf.extend_from_slice(&make_index_header(SQPACK_HEADER_SIZE + 1024, 0, 5));
        let mut cur = Cursor::new(buf);
        let idx = Index::read(&mut cur).unwrap();
        assert_eq!(idx.data_file_count(), 5);
    }

    #[test]
    fn index_source_path_is_none_when_parsed_from_reader() {
        let buf = make_index_file(&[]);
        let mut cur = Cursor::new(buf);
        let idx = Index::read(&mut cur).unwrap();
        assert!(idx.source_path().is_none());
    }

    #[test]
    fn index_entries_slice_matches_len() {
        let entries = [
            (0x0000_0001_0000_0002u64, 0x0000_0020u32),
            (0x0000_0003_0000_0004u64, 0x0000_0040u32),
            (0x0000_0005_0000_0006u64, 0x0000_0060u32),
        ];
        let buf = make_index_file(&entries);
        let mut cur = Cursor::new(buf);
        let idx = Index::read(&mut cur).unwrap();
        assert_eq!(idx.entries().len(), idx.len());
        assert_eq!(idx.len(), 3);
    }

    #[test]
    fn index_header_accessor_echoes_fields() {
        let buf = make_index_file(&[]);
        let mut cur = Cursor::new(buf);
        let idx = Index::read(&mut cur).unwrap();
        // The helper puts size=1024 and version=1 in the index header.
        assert_eq!(idx.index_header().size, 1024);
        assert_eq!(idx.index_header().version, 1);
    }

    #[test]
    fn sqpack_header_accessor_echoes_magic_and_size() {
        let buf = make_index_file(&[]);
        let mut cur = Cursor::new(buf);
        let idx = Index::read(&mut cur).unwrap();
        assert_eq!(idx.header().magic, crate::header::SQPACK_MAGIC);
        assert_eq!(idx.header().size, SQPACK_HEADER_SIZE);
    }

    // ----- Non-standard layout / boundary arithmetic -----

    #[test]
    fn parses_sqpack_header_size_1536() {
        // 1536 is neither 1024 nor 2048 — confirms the seek is value-driven.
        let entries = [(0xF00D_C0DE_DEAD_BEEFu64, 0x0000_0080u32)];
        let buf = make_index_file_with_layout(1536, 0, &entries);
        let mut cur = Cursor::new(buf);
        let idx = Index::read(&mut cur).unwrap();
        assert_eq!(idx.header().size, 1536);
        assert_eq!(idx.len(), 1);
        let e = idx.get(0xF00D_C0DE_DEAD_BEEF).unwrap();
        assert_eq!(e.byte_offset(), 0x80u64 * 8);
    }

    #[test]
    fn rejects_sqpack_header_size_at_minimum_boundary() {
        // size = 23 is one below the 24-byte minimum — must be rejected.
        let mut buf = make_sqpack_header(2);
        buf[12..16].copy_from_slice(&23u32.to_le_bytes());
        buf.extend_from_slice(&make_index_header(SQPACK_HEADER_SIZE + 1024, 0, 1));
        let mut cur = Cursor::new(buf);
        let err = Index::read(&mut cur).unwrap_err();
        match err {
            Error::InvalidHeaderSize {
                field,
                value,
                minimum,
            } => {
                assert_eq!(field, "sqpack_header.size");
                assert_eq!(value, 23);
                assert_eq!(minimum, 24);
            }
            other => panic!("unexpected: {other:?}"),
        }
    }

    #[test]
    fn rejects_index_data_offset_one_below_minimum() {
        // index_data_offset = header.size + index_header.size - 1 → lands inside
        // the index header itself — must be rejected.
        let min = SQPACK_HEADER_SIZE + 1024; // 2048
        let too_small = min - 1;
        let mut buf = Vec::new();
        buf.extend_from_slice(&make_sqpack_header(2));
        buf.extend_from_slice(&make_index_header(too_small, 0, 1));
        let mut cur = Cursor::new(buf);
        let err = Index::read(&mut cur).unwrap_err();
        match err {
            Error::InvalidHeaderSize { field, value, .. } => {
                assert_eq!(field, "index_header.index_data_offset");
                assert_eq!(value, too_small);
            }
            other => panic!("unexpected: {other:?}"),
        }
    }

    #[test]
    fn accepts_index_data_offset_exactly_at_minimum() {
        // index_data_offset = header.size + index_header.size (= 2048) → just
        // passes the boundary check and should produce an empty index.
        let offset = SQPACK_HEADER_SIZE + 1024;
        let mut buf = Vec::new();
        buf.extend_from_slice(&make_sqpack_header(2));
        buf.extend_from_slice(&make_index_header(offset, 0, 1));
        let mut cur = Cursor::new(buf);
        let idx = Index::read(&mut cur).unwrap();
        assert!(idx.is_empty());
    }

    #[test]
    fn rejects_index_data_size_one() {
        // size=1 is not a multiple of 16 — MisalignedIndexData must fire.
        let mut buf = Vec::new();
        buf.extend_from_slice(&make_sqpack_header(2));
        buf.extend_from_slice(&make_index_header(SQPACK_HEADER_SIZE + 1024, 1, 1));
        let mut cur = Cursor::new(buf);
        let err = Index::read(&mut cur).unwrap_err();
        assert!(matches!(
            err,
            Error::MisalignedIndexData {
                size: 1,
                entry_size: 16
            }
        ));
    }

    #[test]
    fn decode_error_context_index_header() {
        // Supply a valid SqPack header then truncate — the index header BinRead
        // should produce a Decode("index_header") error.
        let buf = make_sqpack_header(2); // 1024 bytes; index header starts here
        let mut cur = Cursor::new(buf);
        let err = Index::read(&mut cur).unwrap_err();
        match err {
            Error::Decode { context, .. } => assert_eq!(context, "index_header"),
            other => panic!("expected Decode(index_header), got {other:?}"),
        }
    }

    #[test]
    fn decode_error_context_index_entry() {
        // Header pair valid and declares 1 entry, but the data region is
        // partially missing — the bounds check now catches "declared region
        // overruns the stream" before issuing any per-entry read, so the
        // structural rejection lands as `InvalidHeaderSize` rather than the
        // per-entry `Decode("index_entry")` path the old (OOM-vulnerable)
        // reader would have produced.
        let offset = SQPACK_HEADER_SIZE + 1024;
        let mut buf = Vec::new();
        buf.extend_from_slice(&make_sqpack_header(2));
        buf.extend_from_slice(&make_index_header(offset, INDEX_ENTRY_SIZE, 1));
        // Do NOT append any entry bytes — the bounds check fires first.
        let mut cur = Cursor::new(buf);
        let err = Index::read(&mut cur).unwrap_err();
        match err {
            Error::InvalidHeaderSize { field, .. } => {
                assert_eq!(field, "index_header.index_data_size");
            }
            other => panic!("expected InvalidHeaderSize, got {other:?}"),
        }
    }

    #[test]
    fn get_by_parts_matches_get_with_composed_key() {
        let folder = 0xABCD_1234u32;
        let file = 0xEF98_7654u32;
        let hash = (u64::from(folder) << 32) | u64::from(file);
        let buf = make_index_file(&[(hash, 0x0000_0050)]);
        let mut cur = Cursor::new(buf);
        let idx = Index::read(&mut cur).unwrap();
        let by_parts = idx.get_by_parts(folder, file);
        let by_hash = idx.get(hash);
        assert!(by_parts.is_some());
        assert_eq!(by_parts, by_hash);
    }

    #[test]
    fn synonym_entry_in_parsed_index_is_detected() {
        // An entry with bit 0 set in `data` is a synonym entry; dat_location
        // returns None even after full parse.
        let hash = 0x1111_2222_3333_4444u64;
        let data = 0x0000_00F1u32; // bit 0 set → synonym
        let buf = make_index_file(&[(hash, data)]);
        let mut cur = Cursor::new(buf);
        let idx = Index::read(&mut cur).unwrap();
        let e = idx.get(hash).expect("entry must be present");
        assert!(e.is_synonym());
        assert!(e.dat_location().is_none());
    }

    // ----- Synonym-table parsing -----

    /// Build a 256-byte synonym entry from constituent parts.
    fn make_synonym_entry_bytes(
        hash: u64,
        data: u32,
        conflict_index: u32,
        path: &[u8],
    ) -> [u8; SYNONYM_ENTRY_SIZE as usize] {
        let mut buf = [0u8; SYNONYM_ENTRY_SIZE as usize];
        buf[0..8].copy_from_slice(&hash.to_le_bytes());
        buf[8..12].copy_from_slice(&data.to_le_bytes());
        buf[12..16].copy_from_slice(&conflict_index.to_le_bytes());
        let n = path.len().min(SYNONYM_PATH_LEN);
        buf[16..16 + n].copy_from_slice(&path[..n]);
        buf
    }

    fn make_synonym_sentinel_bytes() -> [u8; SYNONYM_ENTRY_SIZE as usize] {
        // Same shape as observed on retail: hash = u64::MAX, data = 0,
        // conflict_index = u32::MAX, path = all zeros.
        let mut buf = [0u8; SYNONYM_ENTRY_SIZE as usize];
        buf[0..8].copy_from_slice(&u64::MAX.to_le_bytes());
        // data stays zero
        buf[12..16].copy_from_slice(&u32::MAX.to_le_bytes());
        buf
    }

    /// Build an index file with a populated synonym table. The synonym
    /// region is placed immediately after the hash table; the sentinel is
    /// always appended automatically.
    fn make_index_file_with_synonyms(
        entries: &[(u64, u32)],
        synonyms: &[(u64, u32, u32, &str)],
    ) -> Vec<u8> {
        let index_data_offset = SQPACK_HEADER_SIZE + 1024;
        let index_data_size = u32::try_from(entries.len()).unwrap() * INDEX_ENTRY_SIZE;
        let synonym_data_offset = index_data_offset + index_data_size;
        // +1 record for the mandatory sentinel.
        let synonym_records = u32::try_from(synonyms.len()).unwrap() + 1;
        let synonym_data_size = synonym_records * SYNONYM_ENTRY_SIZE;

        let mut buf = Vec::new();
        buf.extend_from_slice(&make_sqpack_header(2));
        // We need a custom index header with synonym fields populated; the
        // module's `make_index_header` zeros those, so build one inline.
        let mut hdr = Vec::with_capacity(1024);
        hdr.extend_from_slice(&1024u32.to_le_bytes()); // size
        hdr.extend_from_slice(&1u32.to_le_bytes()); // version
        hdr.extend_from_slice(&index_data_offset.to_le_bytes());
        hdr.extend_from_slice(&index_data_size.to_le_bytes());
        hdr.resize(hdr.len() + 64, 0); // index_data_hash
        hdr.extend_from_slice(&1u32.to_le_bytes()); // number_of_data_file
        hdr.extend_from_slice(&synonym_data_offset.to_le_bytes());
        hdr.extend_from_slice(&synonym_data_size.to_le_bytes());
        hdr.resize(hdr.len() + 64, 0); // synonym_data_hash
        hdr.extend_from_slice(&0u32.to_le_bytes()); // empty_block_data_offset
        hdr.extend_from_slice(&0u32.to_le_bytes()); // empty_block_data_size
        hdr.resize(hdr.len() + 64, 0); // empty_block_data_hash
        hdr.extend_from_slice(&0u32.to_le_bytes()); // dir_index_data_offset
        hdr.extend_from_slice(&0u32.to_le_bytes()); // dir_index_data_size
        hdr.resize(hdr.len() + 64, 0); // dir_index_data_hash
        hdr.extend_from_slice(&0u32.to_le_bytes()); // index_type
        hdr.resize(hdr.len() + 656, 0); // _reserved
        hdr.resize(hdr.len() + 64, 0); // self_hash
        assert_eq!(hdr.len(), 1024);
        buf.extend_from_slice(&hdr);
        assert_eq!(buf.len(), index_data_offset as usize);

        // Hash table.
        for (hash, data) in entries {
            buf.extend_from_slice(&make_entry_bytes(*hash, *data));
        }

        // Synonym table — real records first, then the sentinel.
        for (hash, data, conflict_index, path) in synonyms {
            buf.extend_from_slice(&make_synonym_entry_bytes(
                *hash,
                *data,
                *conflict_index,
                path.as_bytes(),
            ));
        }
        buf.extend_from_slice(&make_synonym_sentinel_bytes());

        buf
    }

    #[test]
    fn synonym_entry_decodes_dat_location() {
        // data = 0x20 → data_file_id = 0, byte_offset = 0x100.
        let bytes = make_synonym_entry_bytes(0xABCD, 0x20, 1, b"exd/foo.exh");
        let mut cur = Cursor::new(bytes.to_vec());
        let e = SynonymEntry::read(&mut cur).unwrap();
        assert_eq!(e.hash(), 0xABCD);
        assert_eq!(e.data_file_id(), 0);
        assert_eq!(e.byte_offset(), 0x20u64 * 8);
        let loc = e.dat_location();
        assert_eq!(loc.data_file_id, 0);
        assert_eq!(loc.byte_offset, 0x100);
        assert_eq!(e.conflict_index(), 1);
        assert_eq!(e.path(), "exd/foo.exh");
    }

    #[test]
    fn synonym_entry_path_trims_at_null() {
        // 240-byte path field is null-terminated; the parser must trim.
        let bytes = make_synonym_entry_bytes(0, 0x20, 0, b"abc\0\0\0\0junk");
        let mut cur = Cursor::new(bytes.to_vec());
        let e = SynonymEntry::read(&mut cur).unwrap();
        assert_eq!(e.path(), "abc");
        assert_eq!(e.path_utf8().unwrap(), "abc");
    }

    #[test]
    fn synonym_entry_path_lossy_for_invalid_utf8() {
        // Bytes that fail UTF-8 surface as the Cow::Owned replacement.
        let mut path = [0u8; SYNONYM_PATH_LEN];
        path[..2].copy_from_slice(&[0xFF, 0xFE]);
        let bytes = make_synonym_entry_bytes(0, 0x20, 0, &path);
        let mut cur = Cursor::new(bytes.to_vec());
        let e = SynonymEntry::read(&mut cur).unwrap();
        // path() never panics; path_utf8() surfaces the error.
        let _ = e.path();
        assert!(e.path_utf8().is_err());
    }

    #[test]
    fn parses_sentinel_only_synonym_region() {
        // Real retail v1 archives all ship with synonym_data_size = 256
        // containing only the sentinel; the parsed Index must report an
        // empty synonym list.
        let buf = make_index_file_with_synonyms(&[(0x1u64, 0x20)], &[]);
        let mut cur = Cursor::new(buf);
        let idx = Index::read(&mut cur).unwrap();
        assert_eq!(idx.synonyms().len(), 0);
        assert_eq!(idx.synonyms_by_hash(0xDEAD).count(), 0);
    }

    #[test]
    fn parses_populated_synonym_region() {
        // Three synonyms sharing the same hash, plus a fourth distinct one.
        let shared_hash = 0xAAAA_BBBB_CCCC_DDDDu64;
        let other_hash = 0x1111_2222_3333_4444u64;
        let main_hash = 0xFFFF_FFFF_0000_0000u64;
        let buf = make_index_file_with_synonyms(
            &[(main_hash, 0x21)], // synonym bit set; data field doesn't matter post-resolve
            &[
                (shared_hash, 0x20, 1, "exd/a.exl"),
                (shared_hash, 0x30, 2, "exd/b.exl"),
                (shared_hash, 0x40, 3, "exd/c.exl"),
                (other_hash, 0x50, 1, "exd/other.exl"),
            ],
        );
        let mut cur = Cursor::new(buf);
        let idx = Index::read(&mut cur).unwrap();
        assert_eq!(idx.synonyms().len(), 4);

        // Three records bucket against the shared hash.
        let paths: Vec<String> = idx
            .synonyms_by_hash(shared_hash)
            .map(|e| e.path().into_owned())
            .collect();
        assert_eq!(paths, ["exd/a.exl", "exd/b.exl", "exd/c.exl"]);

        // resolve_synonym filters by path within a bucket.
        let hit = idx.resolve_synonym(shared_hash, "exd/b.exl").unwrap();
        assert_eq!(hit.byte_offset(), 0x30u64 * 8);
        assert_eq!(hit.conflict_index(), 2);

        // The fourth record is reachable via its own hash.
        let other: Vec<_> = idx.synonyms_by_hash(other_hash).collect();
        assert_eq!(other.len(), 1);
        assert_eq!(other[0].path(), "exd/other.exl");
    }

    #[test]
    fn resolve_synonym_returns_none_for_unknown_path() {
        let shared_hash = 0xAAAAu64;
        let buf = make_index_file_with_synonyms(
            &[(0x1u64, 0x21)],
            &[(shared_hash, 0x20, 1, "exd/a.exl")],
        );
        let mut cur = Cursor::new(buf);
        let idx = Index::read(&mut cur).unwrap();
        assert!(
            idx.resolve_synonym(shared_hash, "exd/missing.exl")
                .is_none()
        );
        // Unknown hash → empty bucket.
        assert!(idx.resolve_synonym(0xDEAD, "exd/a.exl").is_none());
    }

    #[test]
    fn rejects_synonym_region_misaligned_size() {
        // synonym_data_size = 200 (not a multiple of 256) — must be
        // rejected as MisalignedIndexData before any per-entry read.
        let index_data_offset = SQPACK_HEADER_SIZE + 1024;
        let mut buf = Vec::new();
        buf.extend_from_slice(&make_sqpack_header(2));
        let mut hdr = Vec::with_capacity(1024);
        hdr.extend_from_slice(&1024u32.to_le_bytes());
        hdr.extend_from_slice(&1u32.to_le_bytes());
        hdr.extend_from_slice(&index_data_offset.to_le_bytes());
        hdr.extend_from_slice(&0u32.to_le_bytes()); // index_data_size = 0
        hdr.resize(hdr.len() + 64, 0);
        hdr.extend_from_slice(&1u32.to_le_bytes()); // number_of_data_file
        // Synonym region right after the hash table (which is empty).
        hdr.extend_from_slice(&index_data_offset.to_le_bytes()); // synonym_data_offset
        hdr.extend_from_slice(&200u32.to_le_bytes()); // synonym_data_size = 200
        hdr.resize(hdr.len() + 64, 0);
        hdr.extend_from_slice(&0u32.to_le_bytes());
        hdr.extend_from_slice(&0u32.to_le_bytes());
        hdr.resize(hdr.len() + 64, 0);
        hdr.extend_from_slice(&0u32.to_le_bytes());
        hdr.extend_from_slice(&0u32.to_le_bytes());
        hdr.resize(hdr.len() + 64, 0);
        hdr.extend_from_slice(&0u32.to_le_bytes());
        hdr.resize(hdr.len() + 656, 0);
        hdr.resize(hdr.len() + 64, 0);
        assert_eq!(hdr.len(), 1024);
        buf.extend_from_slice(&hdr);
        // Pad to the declared synonym offset.
        buf.resize(index_data_offset as usize + 200, 0);

        let mut cur = Cursor::new(buf);
        let err = Index::read(&mut cur).unwrap_err();
        match err {
            Error::MisalignedIndexData { size, entry_size } => {
                assert_eq!(size, 200);
                assert_eq!(entry_size, SYNONYM_ENTRY_SIZE);
            }
            other => panic!("expected MisalignedIndexData, got {other:?}"),
        }
    }

    #[test]
    fn rejects_truncated_synonym_region() {
        // Declare a 512-byte synonym region but supply only 256 bytes.
        let index_data_offset = SQPACK_HEADER_SIZE + 1024;
        let mut buf = Vec::new();
        buf.extend_from_slice(&make_sqpack_header(2));
        let synonym_data_offset = index_data_offset; // empty hash table
        let mut hdr = Vec::with_capacity(1024);
        hdr.extend_from_slice(&1024u32.to_le_bytes());
        hdr.extend_from_slice(&1u32.to_le_bytes());
        hdr.extend_from_slice(&index_data_offset.to_le_bytes());
        hdr.extend_from_slice(&0u32.to_le_bytes());
        hdr.resize(hdr.len() + 64, 0);
        hdr.extend_from_slice(&1u32.to_le_bytes());
        hdr.extend_from_slice(&synonym_data_offset.to_le_bytes());
        hdr.extend_from_slice(&512u32.to_le_bytes()); // declared 512
        hdr.resize(hdr.len() + 64, 0);
        hdr.extend_from_slice(&0u32.to_le_bytes());
        hdr.extend_from_slice(&0u32.to_le_bytes());
        hdr.resize(hdr.len() + 64, 0);
        hdr.extend_from_slice(&0u32.to_le_bytes());
        hdr.extend_from_slice(&0u32.to_le_bytes());
        hdr.resize(hdr.len() + 64, 0);
        hdr.extend_from_slice(&0u32.to_le_bytes());
        hdr.resize(hdr.len() + 656, 0);
        hdr.resize(hdr.len() + 64, 0);
        buf.extend_from_slice(&hdr);
        // Only 256 bytes of synonym data — half of what was declared.
        buf.resize(buf.len() + 256, 0);

        let mut cur = Cursor::new(buf);
        let err = Index::read(&mut cur).unwrap_err();
        match err {
            Error::InvalidHeaderSize { field, .. } => {
                assert_eq!(field, "index_header.index_data_size");
            }
            other => panic!("expected InvalidHeaderSize for truncated synonyms, got {other:?}"),
        }
    }

    #[test]
    fn large_realistic_byte_offset() {
        // Offset approximately 1 GiB into a dat file — realistic for large archives.
        // 1 GiB = 0x4000_0000 bytes → encoded as 0x4000_0000 / 8 = 0x0800_0000,
        // stored in bits [4..=31] so raw data word = 0x0800_0000 * 1 (no shift
        // needed since formula is (data & !0xF) * 8).
        // We want byte_offset = 0x4000_0000, so (data & !0xF) * 8 = 0x4000_0000
        // → data & !0xF = 0x0800_0000 → data = 0x0800_0000 (low nibble clear).
        let data: u32 = 0x0800_0000;
        let entry = IndexEntry {
            hash: 0,
            data,
            padding: 0,
        };
        assert_eq!(entry.byte_offset(), 0x4000_0000u64); // 1 GiB
        assert!(entry.byte_offset() < 4 * 1024 * 1024 * 1024u64); // < 4 GiB
        assert_eq!(entry.data_file_id(), 0);
        assert!(!entry.is_synonym());
    }
}
