//! Wire-format header structs for `SqPack` files.
//!
//! Two headers sit back-to-back at the start of every `.index` / `.index2`
//! file: the generic 1024-byte [`SqPackHeader`] (shared with `.dat` files)
//! followed by the index-specific [`SqPackIndexHeader`] which describes the
//! offsets, sizes, and SHA-1 hashes of the hash-table, synonym, empty-block,
//! and directory-index sections.
//!
//! Both structs are pure data: parsing one only consumes bytes from the
//! supplied reader, it never touches the filesystem.

use binrw::BinRead;

use crate::error::{Error, Result};

/// The 8-byte magic that opens every `SqPack` file: `SqPack\0\0`.
pub const SQPACK_MAGIC: [u8; 8] = *b"SqPack\0\0";

/// The 1024-byte `SqPack` header is padded out from a much smaller logical
/// payload so the index hash-table region can begin on a stable file offset.
///
/// The header's own `size` field carries the real on-disk length; in practice
/// it has been `1024` for every retail `SqPack` file ever shipped.
pub const SQPACK_HEADER_SIZE: u32 = 1024;

/// On-disk size of the [`SqPackIndexHeader`] in bytes.
///
/// The header carries a `size` field (read at parse time) that has held
/// `1024` for every retail `.index` / `.index2` file ever shipped — the
/// same fixed envelope as the outer [`SQPACK_HEADER_SIZE`]. Writers
/// emit at this size; readers honour the parsed value so a future SE
/// bump that grows the header would still parse.
pub const INDEX_HEADER_SIZE: u32 = 1024;

/// `SqPack` file type discriminator carried in [`SqPackHeader::file_type`].
///
/// Wire values confirmed against shipped retail FFXIV files:
///
/// | Value | Extension(s) | Variant |
/// |-------|--------------|---------|
/// | `1`   | `.dat0`, `.dat1`, … | [`SqPackFileType::Dat`] |
/// | `2`   | `.index`, `.index2` | [`SqPackFileType::Index`] |
///
/// The value `0` has not been observed in shipping files and is treated as
/// [`SqPackFileType::Unknown`]. Everything else is also unknown.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum SqPackFileType {
    /// `.dat` data file. Wire value `1`.
    Dat,
    /// `.index` / `.index2` hash-table file. Wire value `2`.
    Index,
    /// An unrecognised file-type value preserved from the header for the
    /// caller's inspection. The inner [`UnknownFileType`] wraps a non-public
    /// `u32`, so values must be constructed via [`SqPackFileType::from_raw`],
    /// which canonicalises `1`/`2` to the named variants and only stores
    /// genuinely unknown discriminants here.
    Unknown(UnknownFileType),
}

/// Opaque wrapper around a raw file-type discriminant the parser did not
/// recognise. Construct via [`SqPackFileType::from_raw`]; inspect via
/// [`UnknownFileType::raw`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct UnknownFileType(u32);

impl UnknownFileType {
    /// Raw discriminant as it appeared on disk.
    #[must_use]
    pub fn raw(self) -> u32 {
        self.0
    }
}

impl SqPackFileType {
    /// Raw `u32` value as it appears on the wire.
    #[must_use]
    pub fn as_raw(self) -> u32 {
        match self {
            SqPackFileType::Dat => 1,
            SqPackFileType::Index => 2,
            SqPackFileType::Unknown(v) => v.0,
        }
    }

    /// Decode a raw `u32` into a typed variant. The known wire values `1`/`2`
    /// canonicalise to [`SqPackFileType::Dat`]/[`SqPackFileType::Index`];
    /// anything else surfaces as [`SqPackFileType::Unknown`].
    #[must_use]
    pub fn from_raw(raw: u32) -> Self {
        match raw {
            1 => SqPackFileType::Dat,
            2 => SqPackFileType::Index,
            other => SqPackFileType::Unknown(UnknownFileType(other)),
        }
    }
}

/// Platform identifier carried in the `SqPack` header.
///
/// Stored as a single byte on disk directly after the magic. The known wire
/// values are Win32 (`0`), PS3 (`1`), and PS4 (`2`); anything else is
/// preserved as [`Platform::Unknown`] so a newer client variant does not
/// fail to open here.
///
/// # On-disk vs in-enum width
///
/// `SqPack` stores `platform_id` as a single byte (see
/// [`SqPackHeader::platform_id`]) and [`Platform::from_raw`] takes that byte
/// directly. The [`Platform::Unknown`] variant nevertheless carries a `u16`
/// rather than a `u8`: this widening matches the shape used by the sibling
/// `zipatch-rs` crate's `Platform`, which parses its target-platform field
/// from a `u16`-shaped `ZiPatch` chunk. Keeping both crates' enums in
/// lock-step at the type level lets a future bridge translate between them
/// without a lossy narrowing cast. The widening happens at the variant
/// construction site (`from_raw` performs `u16::from(other)`); the wire
/// format on the `SqPack` side is unchanged. [`Platform::from_raw_u16`] is
/// the entry point for callers — for example a hypothetical
/// `zipatch-rs`-to-`sqpack-rs` bridge — that already hold the byte as a
/// `u16`.
///
/// # Forward compatibility
///
/// The enum is `#[non_exhaustive]` because Square Enix has shipped console
/// targets on a sliding schedule and a future named platform would land here
/// as a new variant. The [`Platform::Unknown`] variant preserves
/// unrecognised IDs so that newer files do not fail parsing when a new
/// platform is introduced. Routing for `SqPack` `.dat`/`.index` files
/// refuses to guess and produces `None` from the path-builders / rejects at
/// open time with [`crate::Error::UnsupportedPlatform`] — silently
/// substituting a default layout would risk reading platform-specific data
/// from the wrong file.
///
/// # Display
///
/// Implements [`std::fmt::Display`]: `"Win32"`, `"PS3"`, `"PS4"`, or
/// `"Unknown(N)"` where `N` is the raw platform ID widened to `u16`. The
/// format matches `zipatch-rs::Platform`'s `Display` so cross-crate
/// diagnostic output is consistent.
///
/// # Example
///
/// ```
/// use sqpack_rs::Platform;
///
/// assert_eq!(Platform::from_raw(0), Platform::Win32);
/// assert_eq!(format!("{}", Platform::Win32), "Win32");
/// assert_eq!(format!("{}", Platform::Unknown(99)), "Unknown(99)");
/// ```
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Platform {
    /// Windows / PC client (`win32` path suffix). Wire value `0`.
    Win32,
    /// `PlayStation` 3 client (`ps3` path suffix). Wire value `1`.
    ///
    /// The PS3 is the only platform that stores its numeric fields
    /// big-endian; this crate currently parses little-endian only and will
    /// reject PS3 files at the header boundary.
    Ps3,
    /// `PlayStation` 4 client (`ps4` path suffix). Wire value `2`.
    Ps4,
    /// Unrecognised platform ID preserved from the header.
    ///
    /// The inner value is the raw platform identifier — a wire byte widened
    /// to `u16` by [`Platform::from_raw`], or the `u16` value passed
    /// straight through by [`Platform::from_raw_u16`]. See the type-level
    /// docs for why the in-enum width is wider than the on-disk byte.
    /// Routing layers refuse to construct paths for this variant rather
    /// than guess at a platform tag.
    Unknown(u16),
}

impl Platform {
    /// Decode the raw on-disk platform byte into a typed variant.
    ///
    /// The known wire values `0`/`1`/`2` canonicalise to
    /// [`Platform::Win32`]/[`Platform::Ps3`]/[`Platform::Ps4`]; anything
    /// else surfaces as [`Platform::Unknown`] with the byte widened to
    /// `u16` (see the type-level docs).
    #[must_use]
    pub fn from_raw(raw: u8) -> Self {
        match raw {
            0 => Platform::Win32,
            1 => Platform::Ps3,
            2 => Platform::Ps4,
            other => Platform::Unknown(u16::from(other)),
        }
    }

    /// Decode a raw platform identifier already widened to `u16` into a
    /// typed variant.
    ///
    /// Useful for callers that hold the identifier in a `u16` rather than a
    /// `u8` — for example a hypothetical bridge from `zipatch-rs`, whose
    /// `SqpkTargetInfo` chunk parses `platform_id` as a `u16`. Values `0..=2`
    /// map to [`Platform::Win32`]/[`Platform::Ps3`]/[`Platform::Ps4`]; any
    /// other value surfaces as [`Platform::Unknown`] carrying the original
    /// `u16` without truncation.
    #[must_use]
    pub fn from_raw_u16(raw: u16) -> Self {
        match raw {
            0 => Platform::Win32,
            1 => Platform::Ps3,
            2 => Platform::Ps4,
            other => Platform::Unknown(other),
        }
    }
}

impl std::fmt::Display for Platform {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Platform::Win32 => f.write_str("Win32"),
            Platform::Ps3 => f.write_str("PS3"),
            Platform::Ps4 => f.write_str("PS4"),
            Platform::Unknown(id) => write!(f, "Unknown({id})"),
        }
    }
}

/// Generic 1024-byte `SqPack` header shared by `.dat`, `.index`, and `.index2`.
///
/// Fields are parsed little-endian. The PS3 platform stores them big-endian;
/// see [`Platform::Ps3`].
#[derive(Debug, Clone, BinRead)]
#[br(little)]
pub struct SqPackHeader {
    /// 8-byte magic: `SqPack\0\0`. Validated by [`SqPackHeader::parse`].
    pub magic: [u8; 8],
    /// Platform identifier (single byte). See [`Platform::from_raw`].
    pub platform_id: u8,
    /// Three reserved bytes following `platform_id`. Held for layout-correct
    /// parsing only; not exposed to consumers.
    #[allow(dead_code)]
    pub(crate) unknown: [u8; 3],
    /// On-disk size of this header in bytes. Practically always `1024`.
    pub size: u32,
    /// Header version. Always `1` in shipped files.
    pub version: u32,
    /// File-type discriminator. See [`SqPackFileType::from_raw`].
    pub file_type: u32,
}

impl SqPackHeader {
    /// Parse a `SqPack` header from `reader`, validating the magic and the
    /// declared file type against `expected_type`.
    pub fn parse<R: std::io::Read + std::io::Seek>(
        reader: &mut R,
        expected_type: SqPackFileType,
    ) -> Result<Self> {
        let hdr = SqPackHeader::read(reader).map_err(|e| Error::decode("sqpack_header", &e))?;
        if hdr.magic != SQPACK_MAGIC {
            return Err(Error::InvalidMagic);
        }
        let raw_expected = expected_type.as_raw();
        if hdr.file_type != raw_expected {
            return Err(Error::UnexpectedFileType {
                expected: raw_expected,
                found: hdr.file_type,
            });
        }
        Ok(hdr)
    }

    /// Decode the platform field into a typed variant.
    #[must_use]
    pub fn platform(&self) -> Platform {
        Platform::from_raw(self.platform_id)
    }

    /// Decode the file-type field into a typed variant.
    #[must_use]
    pub fn typed_file_type(&self) -> SqPackFileType {
        SqPackFileType::from_raw(self.file_type)
    }
}

/// Index-specific segment header that immediately follows the generic
/// [`SqPackHeader`] in a `.index` / `.index2` file.
///
/// The four `(offset, size, hash)` triples describe four contiguous regions
/// inside the same file:
///
/// 1. **Index data** — the hash table itself (`IndexHashTableEntry` for v1,
///    `Index2HashTableEntry` for v2).
/// 2. **Synonym data** — secondary table used when two paths legitimately
///    collide on the same 64-bit hash. Entries in the index data point here
///    via the synonym bit on their `data` field.
/// 3. **Empty-block data** — bookkeeping for freed regions.
/// 4. **Directory-index data** — present in v1 only.
///
/// Each `*_hash` field is a 64-byte SHA-1 digest padded out with zeros (only
/// the first 20 bytes carry the hash).
#[derive(Debug, Clone, BinRead)]
#[br(little)]
pub struct SqPackIndexHeader {
    /// On-disk size of this header in bytes.
    pub size: u32,
    /// Header version. `1` in shipped files.
    pub version: u32,
    /// Byte offset of the start of the hash table, measured from the start
    /// of the file.
    pub index_data_offset: u32,
    /// Size of the hash-table region in bytes.
    pub index_data_size: u32,
    /// SHA-1 (in a 64-byte field) of the hash-table region.
    pub index_data_hash: [u8; 64],
    /// Number of `.datN` companion files that this index points into.
    pub number_of_data_file: u32,
    /// Byte offset of the synonym-table region.
    pub synonym_data_offset: u32,
    /// Size of the synonym-table region in bytes.
    pub synonym_data_size: u32,
    /// SHA-1 of the synonym region.
    pub synonym_data_hash: [u8; 64],
    /// Byte offset of the empty-block bookkeeping region.
    pub empty_block_data_offset: u32,
    /// Size of the empty-block region in bytes.
    pub empty_block_data_size: u32,
    /// SHA-1 of the empty-block region.
    pub empty_block_data_hash: [u8; 64],
    /// Byte offset of the directory-index region (v1 only).
    pub dir_index_data_offset: u32,
    /// Size of the directory-index region in bytes (v1 only).
    pub dir_index_data_size: u32,
    /// SHA-1 of the directory-index region.
    pub dir_index_data_hash: [u8; 64],
    /// Index variant tag.
    pub index_type: u32,
    /// 656-byte reserved padding. Held for layout-correct parsing only; not
    /// exposed to consumers.
    #[allow(dead_code)]
    pub(crate) reserved: [u8; 656],
    /// SHA-1 over the rest of this header.
    pub self_hash: [u8; 64],
}

impl SqPackIndexHeader {
    /// Parse the index-specific header from `reader`.
    pub fn parse<R: std::io::Read + std::io::Seek>(reader: &mut R) -> Result<Self> {
        SqPackIndexHeader::read(reader).map_err(|e| Error::decode("index_header", &e))
    }
}

/// Decoded `(data_file_id, byte_offset)` projection from a v1 or v2 index entry.
///
/// `data_file_id` indexes into the `.datN` companion files that sit next to
/// the `.index` / `.index2` file (`0` → `.dat0`, `1` → `.dat1`, …);
/// `byte_offset` is the payload's offset inside that file.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DatLocation {
    /// Which `.datN` companion file the payload lives in.
    pub data_file_id: u8,
    /// Byte offset of the payload inside that companion file.
    pub byte_offset: u64,
}

/// Reject a declared `(index_data_offset, index_data_size)` pair whose end
/// would fall past the actual stream length. Used by both [`crate::Index`]
/// and [`crate::Index2`] to bound `Vec::with_capacity` /
/// `HashMap::with_capacity` sizing on the derived entry count, so an
/// adversarial header value cannot drive a multi-GiB allocation before any
/// per-entry read fails on EOF. Restores the stream position to wherever it
/// was on entry.
pub(crate) fn check_data_region_fits_stream<R: std::io::Read + std::io::Seek>(
    reader: &mut R,
    index_data_offset: u32,
    index_data_size: u32,
) -> Result<()> {
    let pos_before = reader.stream_position()?;
    let stream_len = reader.seek(std::io::SeekFrom::End(0))?;
    reader.seek(std::io::SeekFrom::Start(pos_before))?;
    let data_end = u64::from(index_data_offset).saturating_add(u64::from(index_data_size));
    if data_end > stream_len {
        return Err(Error::InvalidHeaderSize {
            field: "index_header.index_data_size",
            value: index_data_size,
            minimum: 0,
        });
    }
    Ok(())
}

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

    /// Build a minimal valid `SqPack` header byte buffer.
    pub(crate) fn make_sqpack_header(file_type: u32) -> Vec<u8> {
        let mut buf = Vec::with_capacity(SQPACK_HEADER_SIZE as usize);
        buf.extend_from_slice(&SQPACK_MAGIC); // 8
        buf.push(0); // platform_id = Win32
        buf.extend_from_slice(&[0, 0, 0]); // _unknown
        buf.extend_from_slice(&SQPACK_HEADER_SIZE.to_le_bytes()); // size
        buf.extend_from_slice(&1u32.to_le_bytes()); // version
        buf.extend_from_slice(&file_type.to_le_bytes()); // file_type
        // pad out to 1024
        buf.resize(SQPACK_HEADER_SIZE as usize, 0);
        buf
    }

    #[test]
    fn parses_valid_index_header() {
        // Index wire value is 2.
        let buf = make_sqpack_header(2);
        let mut cur = Cursor::new(buf);
        let hdr = SqPackHeader::parse(&mut cur, SqPackFileType::Index).unwrap();
        assert_eq!(hdr.magic, SQPACK_MAGIC);
        assert_eq!(hdr.platform(), Platform::Win32);
        assert_eq!(hdr.typed_file_type(), SqPackFileType::Index);
        assert_eq!(hdr.size, SQPACK_HEADER_SIZE);
    }

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

    #[test]
    fn rejects_wrong_file_type() {
        // Dat wire value is 1, but we ask for Index (wire value 2).
        let buf = make_sqpack_header(1);
        let mut cur = Cursor::new(buf);
        let err = SqPackHeader::parse(&mut cur, SqPackFileType::Index).unwrap_err();
        match err {
            Error::UnexpectedFileType { expected, found } => {
                assert_eq!(expected, 2); // Index wire value
                assert_eq!(found, 1); // Dat wire value
            }
            other => panic!("unexpected: {other:?}"),
        }
    }

    #[test]
    fn rejects_truncated_header() {
        let buf = vec![0u8; 4];
        let mut cur = Cursor::new(buf);
        assert!(SqPackHeader::parse(&mut cur, SqPackFileType::Index).is_err());
    }

    #[test]
    fn platform_decoding() {
        assert_eq!(Platform::from_raw(0), Platform::Win32);
        assert_eq!(Platform::from_raw(1), Platform::Ps3);
        assert_eq!(Platform::from_raw(2), Platform::Ps4);
        assert_eq!(Platform::from_raw(99), Platform::Unknown(99));
    }

    #[test]
    fn platform_dropped_wire_values_surface_as_unknown() {
        // The previous PS5 (wire 3) and Lys (wire 4) variants were dropped to
        // mirror `zipatch-rs::Platform`; neither has been observed in a shipping
        // SqPack file. The bytes still parse — they just surface as the
        // forward-compatibility `Unknown` variant carrying the original byte
        // widened to `u16`.
        assert_eq!(Platform::from_raw(3), Platform::Unknown(3));
        assert_eq!(Platform::from_raw(4), Platform::Unknown(4));
    }

    #[test]
    fn platform_from_raw_u16_round_trip() {
        // `from_raw_u16` mirrors `from_raw` for the known wire values and
        // preserves arbitrary `u16` values for the unknown case without
        // truncating to `u8`.
        assert_eq!(Platform::from_raw_u16(0), Platform::Win32);
        assert_eq!(Platform::from_raw_u16(1), Platform::Ps3);
        assert_eq!(Platform::from_raw_u16(2), Platform::Ps4);
        assert_eq!(Platform::from_raw_u16(99), Platform::Unknown(99));
        // A value that does not fit in `u8` round-trips intact — this is the
        // whole point of widening the variant payload to `u16`. Both bytes
        // are non-zero so a stray `as u8` truncation regression would change
        // the observed value (it wouldn't with e.g. `0x0F00`, where the low
        // byte is already `0`).
        assert_eq!(Platform::from_raw_u16(0xDEAD), Platform::Unknown(0xDEAD));
    }

    #[test]
    fn platform_display_matches_zipatch_rs() {
        // Cross-crate-symmetry contract with `zipatch-rs::Platform`'s Display:
        // named variants render as `Win32`/`PS3`/`PS4` (note PS3/PS4 are
        // uppercase, Win32 is mixed-case); unknown renders as `Unknown(N)`.
        assert_eq!(format!("{}", Platform::Win32), "Win32");
        assert_eq!(format!("{}", Platform::Ps3), "PS3");
        assert_eq!(format!("{}", Platform::Ps4), "PS4");
        assert_eq!(format!("{}", Platform::Unknown(99)), "Unknown(99)");
    }

    #[test]
    fn platform_from_raw_and_from_raw_u16_agree_for_known_values() {
        // The two constructors must produce identical variants for every wire
        // value that maps to a named variant. A divergence here would mean
        // `from_raw` and `from_raw_u16` have drifted out of sync — e.g. one
        // updated for a new named variant while the other was missed.
        assert_eq!(Platform::from_raw(0), Platform::from_raw_u16(0));
        assert_eq!(Platform::from_raw(1), Platform::from_raw_u16(1));
        assert_eq!(Platform::from_raw(2), Platform::from_raw_u16(2));
    }

    #[test]
    fn platform_from_raw_u16_for_dropped_wire_values_matches_from_raw() {
        // Wire values 3 and 4 (previously Ps5 / Lys, now dropped) must produce
        // the same `Unknown(N)` from both constructors. Pinning `from_raw_u16`
        // here keeps the two entry points in sync for the documented break.
        assert_eq!(Platform::from_raw_u16(3), Platform::Unknown(3));
        assert_eq!(Platform::from_raw_u16(4), Platform::Unknown(4));
        assert_eq!(Platform::from_raw_u16(3), Platform::from_raw(3));
        assert_eq!(Platform::from_raw_u16(4), Platform::from_raw(4));
    }

    #[test]
    fn platform_from_raw_u16_at_u8_max_is_unknown() {
        // `u8::MAX` (255) is the largest value the on-disk byte can carry.
        // It must surface as `Unknown(255)` — not silently truncated or wrapped.
        assert_eq!(Platform::from_raw_u16(255), Platform::Unknown(255));
    }

    #[test]
    fn platform_from_raw_u16_above_u8_max_is_honestly_widened() {
        // 256 is the smallest value that cannot be represented in a `u8`. If
        // someone regresses `Unknown(u16)` back to `Unknown(u8)`, the variant
        // payload would either fail to compile or silently truncate 256 to 0 —
        // and `Unknown(0)` would be wrong since 0 maps to `Win32`. This test
        // catches both failure modes: compile-error (caught by CI) and silent
        // truncation (caught by the assert).
        assert_eq!(Platform::from_raw_u16(256), Platform::Unknown(256));
    }

    #[test]
    fn file_type_round_trip() {
        // 1 = Dat, 2 = Index, everything else = Unknown (preserves the raw value).
        for raw in [0u32, 1, 2, 7] {
            assert_eq!(SqPackFileType::from_raw(raw).as_raw(), raw);
        }
    }

    #[test]
    fn file_type_unknown_inner_raw() {
        // UnknownFileType::raw() must surface the original discriminant.
        let ft = SqPackFileType::from_raw(42);
        match ft {
            SqPackFileType::Unknown(u) => assert_eq!(u.raw(), 42),
            other => panic!("expected Unknown, got {other:?}"),
        }
    }

    #[test]
    fn file_type_known_variants_roundtrip() {
        // Named variants decode and re-encode to their correct wire values.
        assert_eq!(SqPackFileType::Dat.as_raw(), 1);
        assert_eq!(SqPackFileType::Index.as_raw(), 2);
    }

    #[test]
    fn file_type_zero_is_unknown() {
        // Wire value 0 has never been observed in shipping files; it surfaces
        // as Unknown so it round-trips without loss.
        let ft = SqPackFileType::from_raw(0);
        assert!(matches!(ft, SqPackFileType::Unknown(_)));
        assert_eq!(ft.as_raw(), 0);
    }

    #[test]
    fn rejects_wrong_type_dat_expected() {
        // Header says Index (wire 2), but caller expected Dat (wire 1) — fields swap.
        let buf = make_sqpack_header(2);
        let mut cur = Cursor::new(buf);
        let err = SqPackHeader::parse(&mut cur, SqPackFileType::Dat).unwrap_err();
        match err {
            Error::UnexpectedFileType { expected, found } => {
                assert_eq!(expected, 1); // Dat wire value
                assert_eq!(found, 2); // Index wire value
            }
            other => panic!("unexpected: {other:?}"),
        }
    }

    #[test]
    fn decode_error_carries_sqpack_header_context() {
        // Four bytes is enough to start parsing but the BinRead for SqPackHeader
        // needs at least 24 bytes for the fixed logical payload — a 4-byte input
        // produces an Io/unexpected-EOF error wrapped as Decode("sqpack_header").
        let buf = vec![0u8; 4];
        let mut cur = Cursor::new(buf);
        let err = SqPackHeader::parse(&mut cur, SqPackFileType::Index).unwrap_err();
        match err {
            Error::Decode { context, .. } => assert_eq!(context, "sqpack_header"),
            other => panic!("expected Decode, got {other:?}"),
        }
    }

    #[test]
    fn sqpack_header_size_minimum_boundary() {
        // size = 23 is one below the 24-byte minimum — should be rejected as
        // InvalidHeaderSize (if we reach the check; in practice the test
        // environment only calls parse(), so the magic/type checks run first).
        // We need a valid magic + type so only the size triggers.
        // Use wire value 2 (= Index) so the type check passes; only the size
        // field (at offset 12) is mutated below to the out-of-bounds value.
        let mut buf = make_sqpack_header(2);
        buf[12..16].copy_from_slice(&23u32.to_le_bytes()); // size field
        let mut cur = Cursor::new(buf);
        // parse() itself doesn't check header.size; the caller (Index::read) does.
        // Confirm the header round-trips the raw value so the outer check can fire.
        let hdr = SqPackHeader::parse(&mut cur, SqPackFileType::Index).unwrap();
        assert_eq!(hdr.size, 23);
    }
}
