//! Index2 hash-table parsing and lookup.
//!
//! Parses a `.index2` (v2) file, the v2 companion to [`crate::index`].
//! `.index2` files share the generic [`crate::header::SqPackHeader`] and
//! [`crate::header::SqPackIndexHeader`] with v1, but the per-entry layout is
//! halved: a single 32-bit CRC32 over the full UTF-8 path replaces v1's
//! `folder_hash << 32 | file_hash` composite, and the on-disk entry shrinks
//! from 16 to 8 bytes.
//!
//! The format
//! ---------
//!
//! Each entry in an index v2 hash table is 8 bytes:
//!
//! ```text
//!   off  size  field
//!   0    4     hash    — CRC32 of the full path (little-endian u32)
//!   4    4     data    — packed (data_file_id, byte_offset)
//! ```
//!
//! The packed `data` field decodes as:
//!
//! - bit 0 — **reserved**, expected to be zero. See "No synonym table" below.
//! - bits 1..=3 — `data_file_id`: the `.datN` companion file the entry lives
//!   in.
//! - bits 4..=31 — `(data & ~0xF) * 8` is the byte offset within that `.datN`.
//!
//! No synonym table
//! ----------------
//!
//! Unlike v1, the v2 format has **no synonym mechanism**. The v1 synonym table
//! exists because v1's composite `folder_hash << 32 | file_hash` is keyed on
//! two CRC32s of path fragments, which collide often enough that the full
//! UTF-8 path needs to be carried alongside the entry to disambiguate. v2
//! replaces that with a single CRC32 over the full path — there is no path
//! string left to disambiguate against, so the synonym mechanism is
//! structurally meaningless. Bit 0 of `data` is therefore treated as reserved.
//! Empirically every shipped `.index2` we have inspected has bit 0 clear on
//! every entry; the upstream Lumina C# `SqPackIndex2` reader likewise has no
//! synonym handling. A `warn!` event in the entry-construction path surfaces
//! any real file with bit 0 set so a default-level subscriber sees it,
//! without aborting the parse.
//!
//! A duplicate hash in the main table is still treated as a corrupted index
//! and surfaces as [`crate::Error::DuplicateHash`]; the v2 key is a `u32` and
//! is widened to `u64` for the shared error variant.

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 [`Index2Entry`] (v2 hash-table entry).
pub const INDEX2_ENTRY_SIZE: u32 = 8;

/// A single hash-table entry from an index v2 file.
///
/// The wire representation is 8 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 [`Index2Entry::raw_data`].
///
/// `#[non_exhaustive]`: the public fields may grow as the wire format reveals
/// more structure. Construct via [`BinRead`] or [`Index2::read`] rather than
/// a struct literal across crate boundaries.
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, BinRead)]
#[br(little)]
pub struct Index2Entry {
    /// CRC32 of the full UTF-8 path — a single 32-bit key.
    pub hash: u32,
    /// Packed `(data_file_id, offset)` field. Bit 0 is reserved (see the
    /// [module docs](self) — v2 has no synonym table). Use the accessor
    /// methods rather than reading this directly.
    pub data: u32,
}

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

    /// `.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`. Same encoding as v1.
    #[must_use]
    pub fn byte_offset(&self) -> u64 {
        u64::from(self.data & !0xF_u32) * 8
    }

    /// `(data_file_id, byte_offset)` pair — the decoded projection of `data`.
    ///
    /// v2 has no synonym mechanism (see the [module docs](self)) so this is
    /// always meaningful and returns [`DatLocation`] directly.
    #[must_use]
    pub fn dat_location(&self) -> DatLocation {
        DatLocation {
            data_file_id: self.data_file_id(),
            byte_offset: self.byte_offset(),
        }
    }
}

/// A parsed `.index2` (v2) file.
///
/// Holds the two header structs, the list of entries in on-disk order, and a
/// `HashMap` from the 32-bit CRC32 key to its position in that list for
/// constant-time lookup.
#[derive(Debug)]
#[allow(clippy::struct_field_names)]
pub struct Index2 {
    header: SqPackHeader,
    index_header: SqPackIndexHeader,
    entries: Vec<Index2Entry>,
    by_hash: HashMap<u32, usize>,
    source_path: Option<PathBuf>,
}

impl Index2 {
    /// Open and parse a `.index2` file from `path` using the default
    /// [`StdFs`] backend.
    ///
    /// Thin wrapper around [`Index2::open_in`] — same shape as
    /// [`crate::Index::open`].
    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
        Index2::open_in(path, &StdFs::new())
    }

    /// Open and parse a `.index2` file from `path` via an explicit [`Vfs`].
    ///
    /// Mirrors [`crate::Index::open_in`]; see that doc for the contract.
    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_INDEX2, 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 = Index2::read_inner(&mut reader)?;
        idx.source_path = Some(path.to_path_buf());
        Ok(idx)
    }

    /// Parse a `.index2` (v2) 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_INDEX2);
        let _enter = span.enter();
        Index2::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"
        );

        if header.size < SQPACK_HEADER_MIN {
            return Err(Error::InvalidHeaderSize {
                field: "sqpack_header.size",
                value: header.size,
                minimum: SQPACK_HEADER_MIN,
            });
        }

        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 % INDEX2_ENTRY_SIZE != 0 {
            return Err(Error::MisalignedIndexData {
                size: index_header.index_data_size,
                entry_size: INDEX2_ENTRY_SIZE,
            });
        }
        let entry_count = (index_header.index_data_size / INDEX2_ENTRY_SIZE) as usize;

        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`. Symmetric with the
        // v1 reader: an adversarial `index_data_size` would otherwise drive
        // `Vec::with_capacity` to 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 =
                    Index2Entry::read(reader).map_err(|e| Error::decode("index2_entry", &e))?;
                if entry.data & 1 != 0 {
                    warn!(
                        idx = i,
                        hash = format_args!("{:#010x}", entry.hash),
                        data = format_args!("{:#010x}", entry.data),
                        "index2 entry bit 0 of data is reserved (no synonym table in v2); tolerating",
                    );
                }
                trace!(
                    index = i,
                    hash = format_args!("{:#010x}", entry.hash),
                    data = format_args!("{:#010x}", entry.data),
                    "read index2 entry"
                );
                if by_hash.contains_key(&entry.hash) {
                    return Err(Error::DuplicateHash {
                        hash: u64::from(entry.hash),
                    });
                }
                by_hash.insert(entry.hash, i);
                entries.push(entry);
            }
        }

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

        Ok(Index2 {
            header,
            index_header,
            entries,
            by_hash,
            source_path: None,
        })
    }

    /// Path the index was opened from, if it was opened via [`Index2::open`].
    /// `None` for indexes parsed via [`Index2::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 — shipped retail
    /// `.index2` files emit their hash table sorted ascending by the 32-bit
    /// CRC32 key, and the parser preserves that order verbatim. Callers that
    /// need an iteration contract documented at the API level should use
    /// [`Index2::iter`].
    #[must_use]
    pub fn entries(&self) -> &[Index2Entry] {
        &self.entries
    }

    /// Iterate over every entry in this index.
    ///
    /// Returns a borrowed iterator over the parsed [`Index2Entry`] list. The
    /// iterator is [`ExactSizeIterator`] (the entry count is known up front)
    /// and yields entries in **on-disk order**, which for any single
    /// `.index2` file is stable and deterministic across calls on the same
    /// instance.
    ///
    /// Shipped retail `.index2` files emit their hash table already sorted
    /// ascending by the 32-bit full-path CRC32; 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
    /// [`Index2Entry::dat_location`], and streaming each `.dat` payload.
    pub fn iter(&self) -> std::slice::Iter<'_, Index2Entry> {
        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 32-bit CRC32 hash key (the full-path CRC32).
    #[must_use]
    pub fn get(&self, hash: u32) -> Option<&Index2Entry> {
        let idx = *self.by_hash.get(&hash)?;
        Some(&self.entries[idx])
    }
}

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

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

#[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: u32, data: u32) -> [u8; 8] {
        let mut buf = [0u8; 8];
        buf[..4].copy_from_slice(&hash.to_le_bytes());
        buf[4..8].copy_from_slice(&data.to_le_bytes());
        buf
    }

    /// Build a minimal valid index2 file with the given entries.
    fn make_index2_file(entries: &[(u32, u32)]) -> Vec<u8> {
        let mut buf = Vec::new();
        buf.extend_from_slice(&make_sqpack_header(2)); // 2 = Index wire value (shared)
        let index_data_offset = SQPACK_HEADER_SIZE + 1024;
        let index_data_size = u32::try_from(entries.len()).unwrap() * INDEX2_ENTRY_SIZE;
        buf.extend_from_slice(&make_index_header(index_data_offset, index_data_size, 1));
        assert_eq!(buf.len(), index_data_offset as usize);
        for (hash, data) in entries {
            buf.extend_from_slice(&make_entry_bytes(*hash, *data));
        }
        buf
    }

    /// Build an index2 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_index2_file_with_layout(
        sqpack_header_size: u32,
        extra_data_offset_padding: u32,
        entries: &[(u32, u32)],
    ) -> Vec<u8> {
        let mut buf = Vec::new();
        let mut hdr = make_sqpack_header(2);
        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() * INDEX2_ENTRY_SIZE;
        buf.extend_from_slice(&make_index_header(index_data_offset, index_data_size, 1));
        buf.resize(index_data_offset as usize, 0);
        for (hash, data) in entries {
            buf.extend_from_slice(&make_entry_bytes(*hash, *data));
        }
        buf
    }

    // ----- Index2Entry decoding -----

    #[test]
    fn entry_decodes_clean_offset() {
        let entry = Index2Entry {
            hash: 0xCAFE_BABE,
            data: 0x0012_3450,
        };
        assert_eq!(entry.data_file_id(), 0);
        assert_eq!(entry.byte_offset(), 0x0012_3450u64 * 8);
        assert_eq!(entry.raw_data(), 0x0012_3450);
    }

    #[test]
    fn entry_decodes_data_file_id() {
        let entry = Index2Entry {
            hash: 0,
            data: 0b0000_0110,
        };
        assert_eq!(entry.data_file_id(), 3);
        assert_eq!(entry.byte_offset(), 0);
    }

    #[test]
    fn entry_max_offset() {
        let entry = Index2Entry {
            hash: 0,
            data: 0xFFFF_FFF0,
        };
        assert_eq!(entry.byte_offset(), u64::from(0xFFFF_FFF0u32) * 8);
        assert!(entry.byte_offset() < (1u64 << 35));
    }

    #[test]
    fn all_data_file_ids_decode_correctly() {
        for id in 0u8..=7u8 {
            let data = u32::from(id) << 1;
            let entry = Index2Entry { hash: 0, data };
            assert_eq!(entry.data_file_id(), id, "failed for id={id}");
        }
    }

    #[test]
    fn dat_location_round_trip() {
        let entry = Index2Entry {
            hash: 0,
            data: 0x1234_5678 & !0x1,
        };
        let loc = entry.dat_location();
        assert_eq!(loc.data_file_id, entry.data_file_id());
        assert_eq!(loc.byte_offset, entry.byte_offset());
    }

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

    #[test]
    fn parses_valid_index2() {
        let entries = [
            (0x1111_2222u32, 0x0000_0020u32),
            (0x3333_4444u32, 0x0000_0044u32),
        ];
        let buf = make_index2_file(&entries);
        let mut cur = Cursor::new(buf);
        let idx = Index2::read(&mut cur).unwrap();
        assert_eq!(idx.len(), 2);
        assert!(!idx.is_empty());

        let e0 = idx.get(0x1111_2222).unwrap();
        assert_eq!(e0.byte_offset(), 0x20u64 * 8);
        assert_eq!(e0.data_file_id(), 0);

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

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

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

    #[test]
    fn rejects_dat_file_type() {
        // wire value 1 = Dat — caller asked for Index, so this must fail.
        let mut buf = Vec::new();
        buf.extend_from_slice(&make_sqpack_header(1));
        buf.extend_from_slice(&make_index_header(SQPACK_HEADER_SIZE + 1024, 0, 1));
        let mut cur = Cursor::new(buf);
        let err = Index2::read(&mut cur).unwrap_err();
        match err {
            Error::UnexpectedFileType { expected, found } => {
                assert_eq!(expected, 2);
                assert_eq!(found, 1);
            }
            other => panic!("unexpected: {other:?}"),
        }
    }

    #[test]
    fn rejects_unknown_file_type() {
        // wire value 7 = Unknown — caller asked for Index, so this must fail.
        let mut buf = Vec::new();
        buf.extend_from_slice(&make_sqpack_header(7));
        buf.extend_from_slice(&make_index_header(SQPACK_HEADER_SIZE + 1024, 0, 1));
        let mut cur = Cursor::new(buf);
        let err = Index2::read(&mut cur).unwrap_err();
        match err {
            Error::UnexpectedFileType { expected, found } => {
                assert_eq!(expected, 2);
                assert_eq!(found, 7);
            }
            other => panic!("unexpected: {other:?}"),
        }
    }

    #[test]
    fn rejects_truncated_index2() {
        let buf = make_sqpack_header(2);
        let mut cur = Cursor::new(buf);
        assert!(Index2::read(&mut cur).is_err());
    }

    #[test]
    fn rejects_truncated_hash_table() {
        // Declare two entries, supply one.
        let entries = [(0x1u32, 0u32), (0x2u32, 0u32)];
        let mut buf = make_index2_file(&entries);
        buf.truncate(buf.len() - 8); // drop one entry
        let mut cur = Cursor::new(buf);
        assert!(Index2::read(&mut cur).is_err());
    }

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

    #[test]
    fn rejects_misaligned_index_data_seven() {
        let mut buf = Vec::new();
        buf.extend_from_slice(&make_sqpack_header(2));
        buf.extend_from_slice(&make_index_header(SQPACK_HEADER_SIZE + 1024, 7, 1));
        let mut cur = Cursor::new(buf);
        let err = Index2::read(&mut cur).unwrap_err();
        assert!(matches!(
            err,
            Error::MisalignedIndexData {
                size: 7,
                entry_size: 8
            }
        ));
    }

    #[test]
    fn rejects_misaligned_index_data_one() {
        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 = Index2::read(&mut cur).unwrap_err();
        assert!(matches!(
            err,
            Error::MisalignedIndexData {
                size: 1,
                entry_size: 8
            }
        ));
    }

    #[test]
    fn rejects_sqpack_header_size_at_minimum_boundary() {
        // size = 23 is one below the 24-byte minimum.
        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 = Index2::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() {
        let min = SQPACK_HEADER_SIZE + 1024;
        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 = Index2::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 index2_zero_entries_is_empty() {
        let buf = make_index2_file(&[]);
        let mut cur = Cursor::new(buf);
        let idx = Index2::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());
    }

    #[test]
    fn decode_error_context_sqpack_header() {
        // A 4-byte buffer is enough to start reading but trips Decode("sqpack_header").
        let buf = vec![0u8; 4];
        let mut cur = Cursor::new(buf);
        let err = Index2::read(&mut cur).unwrap_err();
        match err {
            Error::Decode { context, .. } => assert_eq!(context, "sqpack_header"),
            other => panic!("expected Decode(sqpack_header), got {other:?}"),
        }
    }

    #[test]
    fn decode_error_context_index_header() {
        // Valid SqPack header but no index header following.
        let buf = make_sqpack_header(2);
        let mut cur = Cursor::new(buf);
        let err = Index2::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_index2_entry() {
        // Headers valid, declare 1 entry's worth of data, but body is missing.
        // The new bounds check 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("index2_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, INDEX2_ENTRY_SIZE, 1));
        // No entry bytes appended.
        let mut cur = Cursor::new(buf);
        let err = Index2::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 source_path_is_none_when_parsed_from_reader() {
        let buf = make_index2_file(&[]);
        let mut cur = Cursor::new(buf);
        let idx = Index2::read(&mut cur).unwrap();
        assert!(idx.source_path().is_none());
    }

    #[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_2222u32, 0x0000_0030u32)];
        let buf = make_index2_file_with_layout(2048, 0, &entries);
        let mut cur = Cursor::new(buf);
        let idx = Index2::read(&mut cur).unwrap();
        assert_eq!(idx.len(), 1);
        assert_eq!(idx.header().size, 2048);
        let e = idx.get(0x1111_2222).unwrap();
        assert_eq!(e.byte_offset(), 0x30u64 * 8);
    }

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

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

    #[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 = Index2::read(&mut cur).unwrap();
        assert!(idx.is_empty());
    }

    #[test]
    fn data_file_count_echoes_header() {
        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 = Index2::read(&mut cur).unwrap();
        assert_eq!(idx.data_file_count(), 5);
    }

    // ----- Additional entry-level coverage (parity with index.rs) -----

    #[test]
    fn entry_decodes_max_data_file_id() {
        // bits 1..=3 max pattern is 0b1110 = 7 (bit 0 clear, all three id bits set).
        let entry = Index2Entry {
            hash: 0,
            data: 0b1110,
        };
        assert_eq!(entry.data_file_id(), 7);
        assert_eq!(entry.byte_offset(), 0);
    }

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

    #[test]
    fn max_data_file_id_with_non_zero_offset() {
        // data_file_id = 7, low nibble clear, large offset.
        // (data & !0xF) * 8 with data = 0xFFFF_FFFE:
        //   synonym=0, file_id = bits[1..=3] = 0b111 = 7,
        //   offset = 0xFFFF_FFF0 * 8 (within 35-bit space).
        let entry = Index2Entry {
            hash: 0xCAFE_BABE,
            data: 0xFFFF_FFFE,
        };
        assert_eq!(entry.data_file_id(), 7);
        let expected_offset = u64::from(0xFFFF_FFF0u32) * 8;
        assert_eq!(entry.byte_offset(), expected_offset);
        assert!(entry.byte_offset() < (1u64 << 35));
    }

    #[test]
    fn byte_offset_encode_decode_samples() {
        // Spot checks: low nibble clear, verify masking and multiplying.
        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 = Index2Entry { hash: 0, data };
            assert_eq!(
                entry.byte_offset(),
                u64::from(data) * 8,
                "mismatch for data={data:#010x}"
            );
        }
    }

    #[test]
    fn large_realistic_byte_offset() {
        // Same 1 GiB spot-check as the v1 equivalent.
        // byte_offset = 0x4000_0000 → (data & !0xF) * 8 = 0x4000_0000
        // → data = 0x0800_0000 (low nibble zero).
        let data: u32 = 0x0800_0000;
        let entry = Index2Entry { hash: 0, data };
        assert_eq!(entry.byte_offset(), 0x4000_0000u64); // 1 GiB
        assert!(entry.byte_offset() < 4 * 1024 * 1024 * 1024u64);
        assert_eq!(entry.data_file_id(), 0);
    }

    // ----- Additional full-parse coverage (parity with index.rs) -----

    #[test]
    fn rejects_zero_sqpack_header_size() {
        // size = 0 would seek back over the magic — reject before issuing the seek.
        let mut buf = make_sqpack_header(2);
        buf[12..16].copy_from_slice(&0u32.to_le_bytes());
        buf.extend_from_slice(&make_index_header(SQPACK_HEADER_SIZE + 1024, 0, 1));
        let mut cur = Cursor::new(buf);
        let err = Index2::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.
        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 = Index2::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` — proves DuplicateHash fires on the key
        // collision, not on byte-for-byte identical entries.
        let entries = [(0x99u32, 0x0000_0020u32), (0x99u32, 0xDEAD_BEE0u32)];
        let buf = make_index2_file(&entries);
        let mut cur = Cursor::new(buf);
        let err = Index2::read(&mut cur).unwrap_err();
        match err {
            Error::DuplicateHash { hash } => assert_eq!(hash, 0x99u64),
            other => panic!("unexpected: {other:?}"),
        }
    }

    #[test]
    fn index2_entries_slice_matches_len() {
        let entries = [
            (0x1111_2222u32, 0x0000_0020u32),
            (0x3333_4444u32, 0x0000_0040u32),
            (0x5555_6666u32, 0x0000_0060u32),
        ];
        let buf = make_index2_file(&entries);
        let mut cur = Cursor::new(buf);
        let idx = Index2::read(&mut cur).unwrap();
        assert_eq!(idx.entries().len(), idx.len());
        assert_eq!(idx.len(), 3);
    }

    #[test]
    fn index2_header_accessor_echoes_fields() {
        let buf = make_index2_file(&[]);
        let mut cur = Cursor::new(buf);
        let idx = Index2::read(&mut cur).unwrap();
        // make_index_header sets size=1024 and version=1.
        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_index2_file(&[]);
        let mut cur = Cursor::new(buf);
        let idx = Index2::read(&mut cur).unwrap();
        assert_eq!(idx.header().magic, crate::header::SQPACK_MAGIC);
        assert_eq!(idx.header().size, SQPACK_HEADER_SIZE);
    }

    // ----- reserved bit 0 tolerated -----

    /// Verify that constructing an `Index2` from a file that has bit 0 set on
    /// any entry's `data` field still succeeds — the reserved-bit observation
    /// is surfaced via a `warn!` event, not by aborting the parse. The
    /// previous `debug_assert!` panicked under any debug build (including
    /// `cargo-fuzz`, which forces `-Cdebug-assertions`), which made the
    /// parser hostile to any real `.index2` that surprised us.
    #[test]
    fn reserved_bit0_is_tolerated() {
        // Craft an entry with bit 0 set — this is the "reserved" synonym bit
        // that should never appear in a real v2 index file, but if it does
        // we log and continue rather than panic.
        let entries = [(0xDEAD_BEEFu32, 0x0000_0021u32)]; // bit 0 set
        let buf = make_index2_file(&entries);
        let mut cur = Cursor::new(buf);
        let idx = Index2::read(&mut cur).expect("parse must tolerate reserved bit 0");
        assert_eq!(idx.len(), 1);
        let e = idx.get(0xDEAD_BEEF).expect("entry present");
        assert_eq!(e.raw_data(), 0x0000_0021);
    }
}
