//! One-call public API: open an FFXIV install and read files by game path.
//!
//! The primary entry point for consumers that want "give me the bytes for
//! `exd/root.exl`" without plumbing index lookup, dat-file selection, or
//! chunk probing themselves.
//!
//! v0 contract: no caching
//! -----------------------
//!
//! [`SqPack::open`] only validates that `<install_root>/sqpack/` exists and
//! stores the platform. It does **not** pre-scan archives — there's no
//! background indexing and no in-memory cache of entries.
//! [`SqPack::read_file`] re-opens the relevant `.index` / `.index2` / `.dat`
//! files on every call.
//!
//! This trades throughput for simplicity. An eager-scan + LRU-cache variant
//! is a follow-up; introducing caching later is much easier than removing
//! it once consumers depend on the lifetimes / invalidation semantics.
//!
//! Lookup flow
//! -----------
//!
//! For a game path like `exd/root.exl`:
//!
//! 1. Route to an [`crate::route::ArchiveTriple`]. If the category is
//!    unknown, return [`Error::UnknownCategory`].
//! 2. Probe chunks starting at `0`. For each chunk `N`:
//!    a. Build the `(category, expansion, chunk = N)` triple.
//!    b. If **neither** the `.index` nor the `.index2` file for that triple
//!    exists on disk, stop probing — there are no chunks past this one.
//!    (Defensive cap: stop unconditionally past chunk 7.)
//!    c. Otherwise try the `.index` (v1) file. If present, look up the v1
//!    64-bit composite key. A hit that's not a synonym yields a
//!    [`crate::header::DatLocation`]. A synonym hit resolves through the v1
//!    synonym table — the matching `(hash, game_path)` record carries
//!    the real `(data_file_id, byte_offset)`. A synonym hit with no
//!    matching record falls through to v2 / the next chunk (treated as
//!    "not present in this archive" rather than as an error).
//!    d. If v1 was absent or missed, try the `.index2` (v2) file with the
//!    full-path CRC32. A hit yields a [`crate::header::DatLocation`].
//!    e. If neither matched, move on to the next chunk.
//! 3. If the probe loop exits without a hit, return [`Error::PathNotFound`].
//! 4. With the [`crate::header::DatLocation`], open the `.dat{N}` file and read the
//!    payload via [`crate::DatFile::read_file`].

#[cfg(feature = "path-list")]
use std::collections::HashMap;
use std::io::{Seek, SeekFrom};
use std::path::{Path, PathBuf};
use std::sync::Arc;

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

use crate::tracing_schema::span_names;

use crate::dat::{BlockChunk, BlockInfo, DatFile, FileInfo, FileKind, SqPackPayload};
use crate::error::{Error, Result};
use crate::excel::language_path_suffix;
use crate::excel::{ExcelData, ExcelHeader, ExcelList, Language, Sheet, SheetIter, SheetNameIter};
use crate::hash::{hash_index1_key_normalized, hash_index2_normalized, normalize};
use crate::header::{DatLocation, Platform};
use crate::index::Index;
use crate::index2::Index2;
use crate::route::{ArchiveTriple, Category, KNOWN_CATEGORIES};
use crate::vfs::{StdFs, Vfs};

/// Defensive upper bound for the chunk-probe loop in [`SqPack::read_file`].
///
/// The real stopping condition is "neither `.index` nor `.index2` exists on
/// disk for the current chunk" — see the lookup-flow docs at the top of the
/// module. This cap exists only to bound the loop in case of a malformed
/// install (or a future asset layout that drops the index files but keeps
/// the dats). No shipping retail archive has used chunks `>= 8`; the cap is
/// a safety belt, not a correctness boundary.
const CHUNK_PROBE_CAP: u8 = 7;

/// `expect` message used at the eight call sites that depend on
/// [`SqPack::open_with_vfs`] defaulting to [`Platform::Win32`] and
/// [`SqPack::with_platform`] rejecting [`Platform::Unknown`] at
/// builder time. The path-builders on [`ArchiveTriple`] return `None`
/// only for `Platform::Unknown` and `expansion > 15`; the latter is
/// surfaced via the same routing-layer fail-fast as `UnknownCategory`.
/// Anchoring this invariant in one place keeps the eight `.expect()`
/// sites consistent if the rationale ever shifts.
const PATH_BUILDER_INVARIANT: &str =
    "path-builder returns Some after open rejected Unknown platforms";

/// One-call public entry point for reading files from an FFXIV install.
///
/// Construct with [`SqPack::open`] (or [`SqPack::open_with_vfs`] for
/// test/mock backends), optionally chain
/// [`.with_platform`](SqPack::with_platform), then call
/// [`SqPack::read_file`] with a game-relative path
/// (e.g. `"exd/root.exl"`).
#[derive(Clone)]
pub struct SqPack {
    install_root: PathBuf,
    platform: Platform,
    vfs: Arc<dyn Vfs>,
    /// Optional [`BlockCache`](crate::BlockCache) attached via
    /// [`SqPack::with_block_cache`]. Cheap to clone (an `Option` of
    /// an `Arc`); every per-task [`DatFile`] inherits this `Arc` so
    /// rayon workers all consult the same physical cache. The field
    /// is `#[cfg]`-gated so the `SqPack` struct carries no per-handle
    /// storage for it on the no-feature build.
    #[cfg(feature = "block-cache")]
    block_cache: Option<Arc<crate::block_cache::BlockCache>>,
    /// Opaque per-install id derived once from the canonical
    /// `install_root` at [`SqPack::with_block_cache`] attach time.
    /// Threaded into every [`crate::block_cache::BlockKey`] this
    /// handle's reads produce so a shared
    /// `Arc<BlockCache>` fronting two installs does not collide on
    /// `(triple, dat_id, byte_offset)` alone. Always present (not
    /// gated to the cache field) so an `unset_block_cache` /
    /// `set_block_cache` cycle keeps reusing the same id without
    /// recomputing.
    #[cfg(feature = "block-cache")]
    install_id: u64,
    /// Optional [`PathList`](crate::PathList) attached via
    /// [`SqPack::with_path_list`]. Cheap to clone (an `Option` of
    /// an `Arc`); one `Arc<PathList>` can front many [`SqPack`]
    /// handles at once. The field is `#[cfg]`-gated so the
    /// `SqPack` struct carries no per-handle storage for it on the
    /// no-feature build.
    #[cfg(feature = "path-list")]
    path_list: Option<Arc<crate::path_list::PathList>>,
}

impl std::fmt::Debug for SqPack {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("SqPack")
            .field("install_root", &self.install_root)
            .field("platform", &self.platform)
            .finish_non_exhaustive()
    }
}

impl SqPack {
    /// Open an FFXIV install at `install_root` using the default
    /// [`StdFs`] backend and [`Platform::Win32`].
    ///
    /// Validates that `<install_root>/sqpack/` exists; per the v0 contract
    /// no archives are touched until [`SqPack::read_file`] is called.
    ///
    /// To target a non-Win32 platform, chain
    /// [`.with_platform`](SqPack::with_platform):
    ///
    /// ```no_run
    /// use sqpack_rs::{Platform, SqPack};
    ///
    /// let sq = SqPack::open("/path/to/ffxiv")?.with_platform(Platform::Ps4)?;
    /// # Ok::<(), sqpack_rs::Error>(())
    /// ```
    ///
    /// # Errors
    ///
    /// Returns [`Error::Io`] if `<install_root>/sqpack/` does not exist or
    /// cannot be stat'd.
    pub fn open(install_root: impl Into<PathBuf>) -> Result<Self> {
        Self::open_with_vfs(install_root, Arc::new(StdFs::new()))
    }

    /// Open an FFXIV install at `install_root` using an explicit [`Vfs`]
    /// backend and [`Platform::Win32`].
    ///
    /// The canonical constructor — [`SqPack::open`] delegates here.
    /// Lets the launcher's test suite (and the in-process integration
    /// tests in `tests/`) drive the entire `SqPack` pipeline against an
    /// [`crate::vfs::InMemoryFs`] populated with hand-built fixtures, without
    /// ever touching the host filesystem.
    ///
    /// To target a non-Win32 platform, chain
    /// [`.with_platform`](SqPack::with_platform).
    ///
    /// # Errors
    ///
    /// Returns [`Error::Io`] if `<install_root>/sqpack/` does not exist or
    /// cannot be stat'd (via the supplied [`Vfs`]).
    pub fn open_with_vfs(install_root: impl Into<PathBuf>, vfs: Arc<dyn Vfs>) -> Result<Self> {
        let install_root = install_root.into();
        let platform = Platform::Win32;
        let span = info_span!(span_names::SQPACK_OPEN, path = %install_root.display());
        let _enter = span.enter();
        let sqpack_dir = install_root.join("sqpack");
        let meta = vfs
            .metadata(&sqpack_dir)
            .map_err(|e| Error::io_at(&sqpack_dir, e))?;
        // `VfsMetadata` only carries the `is_file` discriminant — directory
        // vs non-file vs symlink is not differentiated. Treat any
        // non-file entry as a directory candidate; subsequent archive
        // lookups will surface a mismatch via Io errors if the path is
        // structurally wrong. Reject only the file case explicitly to keep
        // the existing "sqpack must not be a regular file" guard.
        if meta.is_file {
            return Err(Error::io_at(
                &sqpack_dir,
                std::io::Error::new(
                    std::io::ErrorKind::NotFound,
                    "expected `sqpack` to be a directory",
                ),
            ));
        }
        info!(
            path = %install_root.display(),
            ?platform,
            "opened SqPack install",
        );
        #[cfg(feature = "block-cache")]
        let install_id = crate::block_cache::install_id_from_path(&install_root);
        Ok(SqPack {
            install_root,
            platform,
            vfs,
            #[cfg(feature = "block-cache")]
            block_cache: None,
            #[cfg(feature = "block-cache")]
            install_id,
            #[cfg(feature = "path-list")]
            path_list: None,
        })
    }

    /// Switch the platform on this handle.
    ///
    /// Builder-style method — returns `Result<Self>` because
    /// [`Platform::Unknown`] is rejected. The path-routing layer can
    /// only build on-disk filenames for the named platform variants
    /// (`Win32`/`Ps3`/`Ps4`); refusing at builder time keeps the
    /// downstream lookup paths from having to handle a structurally
    /// unusable handle.
    ///
    /// ```no_run
    /// use sqpack_rs::{Platform, SqPack};
    ///
    /// let sq = SqPack::open("/path/to/ffxiv")?.with_platform(Platform::Ps4)?;
    /// assert_eq!(sq.platform(), Platform::Ps4);
    /// # Ok::<(), sqpack_rs::Error>(())
    /// ```
    ///
    /// # Errors
    ///
    /// Returns [`Error::UnsupportedPlatform`] if `platform` is
    /// [`Platform::Unknown`].
    pub fn with_platform(mut self, platform: Platform) -> Result<Self> {
        if matches!(platform, Platform::Unknown(_)) {
            return Err(Error::UnsupportedPlatform { platform });
        }
        self.platform = platform;
        Ok(self)
    }

    /// Attach a shared [`BlockCache`](crate::BlockCache) to this
    /// [`SqPack`] handle. Every [`DatFile`] this handle opens (via
    /// [`SqPack::read_file`] / [`SqPack::read_file_typed`] /
    /// [`SqPack::read_file_streaming`] / [`crate::SqPackPathVerifier`] /
    /// the parallel sweepers) inherits the same [`Arc<BlockCache>`]
    /// so repeat block reads short-circuit the inflate step.
    ///
    /// The cache is shared by [`Arc`](std::sync::Arc): the same
    /// `Arc<BlockCache>` can attach to multiple [`SqPack`] handles
    /// for **different installs** — the [`BlockKey`](crate::block_cache::BlockKey)
    /// carries an opaque `install_id` derived from each handle's
    /// canonical install root, so distinct installs never collide on
    /// `(triple, dat_id, byte_offset)` alone. Cloning an [`SqPack`]
    /// preserves the attached cache.
    ///
    /// Returns `self` so the call chains with the `open_*`
    /// constructors. For consumers with a long-lived handle that need
    /// to attach a cache mid-life without cloning, see
    /// [`SqPack::set_block_cache`].
    ///
    /// **Invalidation**: the cache is **not** auto-invalidated when
    /// the underlying `.dat` files change. Any external mutation of an
    /// archive — a patcher rewriting bytes,
    /// [`SqPackAppender::commit`](crate::write::SqPackAppender::commit),
    /// or [`SqPackRepacker::write_to`](crate::write::SqPackRepacker::write_to)
    /// — requires either a [`BlockCache::clear`](crate::BlockCache::clear)
    /// (whole-cache flush) or a
    /// [`BlockCache::evict_dat`](crate::BlockCache::evict_dat)
    /// (archive-scoped flush) before the next read off the touched
    /// archive. See the [`crate::block_cache`] module rustdoc's
    /// "Invalidation" section.
    ///
    /// ```no_run
    /// # #[cfg(feature = "block-cache")]
    /// # fn run() -> sqpack_rs::Result<()> {
    /// use std::sync::Arc;
    /// use sqpack_rs::{BlockCache, SqPack};
    ///
    /// let cache = Arc::new(BlockCache::new());
    /// let sq = SqPack::open("/path/to/ffxiv")?.with_block_cache(cache);
    /// let _bytes = sq.read_file("exd/root.exl")?;
    /// # Ok(())
    /// # }
    /// ```
    ///
    /// See [`crate::block_cache`] for the design rationale (sharing
    /// model, key shape, concurrency posture, capacity semantics,
    /// composition with `parallel` and `mmap`).
    #[cfg(feature = "block-cache")]
    #[cfg_attr(docsrs, doc(cfg(feature = "block-cache")))]
    #[must_use]
    pub fn with_block_cache(mut self, cache: Arc<crate::block_cache::BlockCache>) -> Self {
        self.block_cache = Some(cache);
        self
    }

    /// In-place sibling of [`SqPack::with_block_cache`].
    ///
    /// Attaches (or replaces) the [`BlockCache`](crate::BlockCache)
    /// without consuming `self`, so a consumer holding a long-lived
    /// [`SqPack`] handle can wire a cache in after construction
    /// without cloning the whole handle. Returns `&mut Self` for
    /// further chaining. Drops any previously-attached cache.
    ///
    /// Same invalidation contract as [`SqPack::with_block_cache`] —
    /// see that method's rustdoc.
    #[cfg(feature = "block-cache")]
    #[cfg_attr(docsrs, doc(cfg(feature = "block-cache")))]
    pub fn set_block_cache(&mut self, cache: Arc<crate::block_cache::BlockCache>) -> &mut Self {
        self.block_cache = Some(cache);
        self
    }

    /// Detach any [`BlockCache`](crate::BlockCache) currently attached
    /// to this handle. Subsequent reads fall back to the no-cache hot
    /// path. Returns `&mut Self` for chaining.
    ///
    /// The dropped `Arc<BlockCache>` is released to its remaining
    /// owners; other [`SqPack`] handles holding clones (e.g. siblings
    /// sharing the cache) are unaffected. To free the underlying
    /// cache entries, prefer [`BlockCache::clear`](crate::BlockCache::clear)
    /// on the inner cache before unsetting.
    #[cfg(feature = "block-cache")]
    #[cfg_attr(docsrs, doc(cfg(feature = "block-cache")))]
    pub fn unset_block_cache(&mut self) -> &mut Self {
        self.block_cache = None;
        self
    }

    /// Detach and return any [`BlockCache`](crate::BlockCache) currently
    /// attached to this handle, leaving the handle in the no-cache state.
    ///
    /// Useful when a consumer wants to move the cache to a new handle
    /// or inspect its final state without dropping it. Returns
    /// [`None`] when no cache was attached.
    #[cfg(feature = "block-cache")]
    #[cfg_attr(docsrs, doc(cfg(feature = "block-cache")))]
    pub fn take_block_cache(&mut self) -> Option<Arc<crate::block_cache::BlockCache>> {
        self.block_cache.take()
    }

    /// Pre-warm the block cache by reading a list of game paths and
    /// discarding the results.
    ///
    /// Each path is read through [`SqPack::read_file`], which
    /// decompresses every block in the payload. When a
    /// [`BlockCache`](crate::BlockCache) is attached via
    /// [`SqPack::with_block_cache`], those decompressed blocks are
    /// captured automatically; subsequent reads of the same blocks
    /// hit the cache and skip the inflate step.
    ///
    /// Returns the number of paths that were read successfully.
    /// Paths that fail (missing, I/O error, unsupported kind) are
    /// silently skipped — the intent is best-effort population, not
    /// validation.
    ///
    /// If no cache is attached the reads still succeed (they just
    /// don't populate anything), so the method is safe to call
    /// unconditionally. The return value still reflects how many
    /// paths resolved and decoded cleanly.
    ///
    /// ```no_run
    /// # #[cfg(feature = "block-cache")]
    /// # fn run() -> sqpack_rs::Result<()> {
    /// use std::sync::Arc;
    /// use sqpack_rs::{BlockCache, SqPack};
    ///
    /// let cache = Arc::new(BlockCache::new());
    /// let sq = SqPack::open("/path/to/ffxiv")?.with_block_cache(cache);
    /// let warmed = sq.warm_cache(&["exd/root.exl", "exd/item.exh"]);
    /// println!("pre-warmed {warmed} files");
    /// # Ok(())
    /// # }
    /// ```
    #[cfg(feature = "block-cache")]
    #[cfg_attr(docsrs, doc(cfg(feature = "block-cache")))]
    pub fn warm_cache(&self, paths: &[&str]) -> usize {
        let mut warmed = 0;
        for path in paths {
            if self.read_file(path).is_ok() {
                warmed += 1;
            }
        }
        warmed
    }

    /// Accessor for the optional [`BlockCache`](crate::BlockCache)
    /// attached to this handle. Returns [`None`] when no cache has
    /// been wired via [`SqPack::with_block_cache`] /
    /// [`SqPack::set_block_cache`].
    ///
    /// Useful primarily for tests and operator diagnostics
    /// (`sq.block_cache().map(BlockCache::len)` to read the live
    /// fill, or `sq.block_cache().map(BlockCache::stats)` for the
    /// hit/miss/fill snapshot).
    #[cfg(feature = "block-cache")]
    #[cfg_attr(docsrs, doc(cfg(feature = "block-cache")))]
    #[must_use]
    pub fn block_cache(&self) -> Option<&Arc<crate::block_cache::BlockCache>> {
        self.block_cache.as_ref()
    }

    /// Attach a shared [`PathList`](crate::PathList) to this
    /// [`SqPack`] handle. Enables [`SqPack::known_paths`] to
    /// enumerate every embedded path that exists in the live
    /// install, and [`SqPack::lookup_path_v1`] /
    /// [`SqPack::lookup_path_v2`] to recover the path string for a
    /// raw hash key.
    ///
    /// The list is shared by [`Arc`](std::sync::Arc): one
    /// `Arc<PathList>` can attach to multiple [`SqPack`] handles —
    /// hash-keyed reverse lookup is install-independent (the
    /// hashes are wire-format facts, not install-relative), so
    /// sharing one large dump across every install handle is the
    /// intended pattern.
    ///
    /// Returns `self` so the call chains with the `open_*`
    /// constructors. For consumers with a long-lived handle that
    /// need to attach a list mid-life without cloning, see
    /// [`SqPack::set_path_list`].
    ///
    /// ```no_run
    /// # #[cfg(feature = "path-list")]
    /// # fn run() -> sqpack_rs::Result<()> {
    /// use std::sync::Arc;
    /// use sqpack_rs::{PathList, SqPack};
    ///
    /// let list = Arc::new(PathList::with_seed_list());
    /// let sq = SqPack::open("/path/to/ffxiv")?.with_path_list(list);
    /// for path in sq.known_paths() {
    ///     println!("present: {path}");
    /// }
    /// # Ok(())
    /// # }
    /// ```
    ///
    /// See [`crate::path_list`] for the design rationale (sharing
    /// model, normalisation rules, first-wins on hash collision,
    /// what ships in the embedded seed list).
    #[cfg(feature = "path-list")]
    #[cfg_attr(docsrs, doc(cfg(feature = "path-list")))]
    #[must_use]
    pub fn with_path_list(mut self, list: Arc<crate::path_list::PathList>) -> Self {
        self.path_list = Some(list);
        self
    }

    /// In-place sibling of [`SqPack::with_path_list`].
    ///
    /// Attaches (or replaces) the [`PathList`](crate::PathList)
    /// without consuming `self`, so a consumer holding a long-lived
    /// [`SqPack`] handle can wire a list in after construction
    /// without cloning the whole handle. Returns `&mut Self` for
    /// further chaining. Drops any previously-attached list.
    #[cfg(feature = "path-list")]
    #[cfg_attr(docsrs, doc(cfg(feature = "path-list")))]
    pub fn set_path_list(&mut self, list: Arc<crate::path_list::PathList>) -> &mut Self {
        self.path_list = Some(list);
        self
    }

    /// Detach any [`PathList`](crate::PathList) currently attached
    /// to this handle. Subsequent
    /// [`SqPack::known_paths`] / [`SqPack::lookup_path_v1`] /
    /// [`SqPack::lookup_path_v2`] calls behave as if no list was
    /// ever attached (empty iterator / `None`). Returns `&mut Self`
    /// for chaining.
    #[cfg(feature = "path-list")]
    #[cfg_attr(docsrs, doc(cfg(feature = "path-list")))]
    pub fn unset_path_list(&mut self) -> &mut Self {
        self.path_list = None;
        self
    }

    /// Accessor for the optional [`PathList`](crate::PathList)
    /// attached to this handle. Returns [`None`] when no list has
    /// been wired via [`SqPack::with_path_list`] /
    /// [`SqPack::set_path_list`].
    ///
    /// Useful primarily for tests and operator diagnostics
    /// (`sq.path_list().map(PathList::len)` to read the live
    /// entry count).
    #[cfg(feature = "path-list")]
    #[cfg_attr(docsrs, doc(cfg(feature = "path-list")))]
    #[must_use]
    pub fn path_list(&self) -> Option<&Arc<crate::path_list::PathList>> {
        self.path_list.as_ref()
    }

    /// Reverse-lookup a v1 composite hash key
    /// (`folder_crc32 << 32 | file_crc32`) against the attached
    /// [`PathList`](crate::PathList).
    ///
    /// Returns [`None`] when no list is attached, or when the
    /// attached list has no entry for `hash`. The result is the
    /// original path string supplied to
    /// [`PathList::with_paths`](crate::PathList::with_paths) — not
    /// the normalised form — so consumers see exactly the text
    /// they fed in.
    ///
    /// Pair with [`crate::hash::hash_index1_key`] to take an
    /// index entry's `(folder_hash, file_hash)` pair (via
    /// [`IndexEntry::folder_hash`](crate::index::IndexEntry::folder_hash) /
    /// [`IndexEntry::file_hash`](crate::index::IndexEntry::file_hash)) and
    /// recover the original path:
    ///
    /// ```no_run
    /// # #[cfg(feature = "path-list")]
    /// # fn run(sq: &sqpack_rs::SqPack, entry: &sqpack_rs::index::IndexEntry) {
    /// let key = (u64::from(entry.folder_hash()) << 32)
    ///         | u64::from(entry.file_hash());
    /// if let Some(path) = sq.lookup_path_v1(key) {
    ///     println!("entry resolves to {path}");
    /// }
    /// # }
    /// ```
    #[cfg(feature = "path-list")]
    #[cfg_attr(docsrs, doc(cfg(feature = "path-list")))]
    #[must_use]
    pub fn lookup_path_v1(&self, hash: u64) -> Option<&str> {
        self.path_list.as_ref()?.lookup_v1(hash)
    }

    /// Reverse-lookup a v2 full-path CRC32 hash against the
    /// attached [`PathList`](crate::PathList).
    ///
    /// Returns [`None`] when no list is attached, or when the
    /// attached list has no entry for `hash`. Sister of
    /// [`SqPack::lookup_path_v1`]; pair with
    /// [`crate::hash::hash_index2`] /
    /// [`Index2Entry::hash`](crate::index2::Index2Entry::hash).
    #[cfg(feature = "path-list")]
    #[cfg_attr(docsrs, doc(cfg(feature = "path-list")))]
    #[must_use]
    pub fn lookup_path_v2(&self, hash: u32) -> Option<&str> {
        self.path_list.as_ref()?.lookup_v2(hash)
    }

    /// Iterate every path in the attached
    /// [`PathList`](crate::PathList) that has a matching entry in
    /// the live install.
    ///
    /// Cross-references the attached list against the install's
    /// `.index` / `.index2` archives: for each embedded path,
    /// route it to the matching archive, look up its hash, and
    /// yield it iff a matching entry is found.
    ///
    /// Returns an empty iterator when no list is attached. The
    /// iteration order mirrors
    /// [`PathList::paths`](crate::PathList::paths) (insertion
    /// order), which is deterministic.
    ///
    /// # Cost
    ///
    /// O(`embedded_paths`) — one [`crate::Index`] /
    /// [`crate::Index2`] open per distinct archive the embedded
    /// paths touch, then one in-memory hash-map lookup per path.
    /// Archive parses are not cached across `known_paths` calls;
    /// consumers walking the result multiple times should
    /// `.collect()` once.
    ///
    /// The method does **not** decode any payloads — only the
    /// `.index` / `.index2` files are touched. This keeps the
    /// "what's in this install" query fast (the index files are
    /// the small ones in an install) and orthogonal to the
    /// per-payload `read_file` path.
    #[cfg(feature = "path-list")]
    #[cfg_attr(docsrs, doc(cfg(feature = "path-list")))]
    #[must_use = "iterator is lazy and does nothing unless consumed"]
    pub fn known_paths(&self) -> KnownPathsIter<'_> {
        KnownPathsIter::new(self)
    }

    /// Filesystem root the install was opened at.
    #[must_use]
    pub fn install_root(&self) -> &Path {
        &self.install_root
    }

    /// Platform the install was opened for.
    #[must_use]
    pub fn platform(&self) -> Platform {
        self.platform
    }

    /// Crate-internal accessor for the configured [`Vfs`] handle. Used by
    /// the rayon-backed parallel sweepers in [`crate::parallel`] to probe
    /// archive existence and open `.index` / `.index2` files without
    /// going through [`Self::enumerate_archives`].
    #[cfg(feature = "parallel")]
    pub(crate) fn vfs_ref(&self) -> &dyn Vfs {
        &*self.vfs
    }

    /// Read the file at `game_path` end-to-end: hash, route, look up the
    /// entry, and decode the payload.
    ///
    /// `game_path` is a game-relative path like `"exd/root.exl"` —
    /// lowercased ASCII with `/` separators is canonical, but the implementation
    /// normalises uppercase + backslashes (see [`crate::hash`] for the rules).
    ///
    /// # Errors
    ///
    /// - [`Error::UnknownCategory`] — first path segment is not a known
    ///   category name.
    /// - [`Error::PathNotFound`] — path hashed cleanly but no entry was
    ///   found in any chunk's `.index` / `.index2` file. Also surfaces
    ///   when a v1 main-table entry is flagged synonym but the synonym
    ///   table holds no record for the supplied `game_path`.
    /// - [`Error::Io`] — underlying filesystem error reading any archive.
    /// - Any [`Error`] variant returned by [`crate::Index::open`],
    ///   [`crate::Index2::open`], or [`crate::DatFile::read_file`].
    ///
    /// # Panics
    ///
    /// Does not panic in any code path reachable through the public surface.
    /// Internally relies on `Option::expect` calls whose invariants are
    /// enforced by [`SqPack::with_platform`] rejecting
    /// [`Platform::Unknown`] — the only platform value the path-builders
    /// surface as `None`.
    pub fn read_file(&self, game_path: &str) -> Result<Vec<u8>> {
        let span = info_span!(span_names::SQPACK_READ_FILE, path = %game_path);
        let _enter = span.enter();
        let started_at = std::time::Instant::now();

        let (triple, location) = self.resolve(game_path)?;
        let bytes = self.read_payload(triple, location)?;
        #[allow(clippy::cast_possible_truncation)]
        let elapsed_ms = started_at.elapsed().as_millis() as u64;
        info!(
            path = %game_path,
            bytes = bytes.len(),
            elapsed_ms,
            "sqpack.read_file",
        );
        Ok(bytes)
    }

    /// Tagged-return variant of [`SqPack::read_file`].
    ///
    /// Routes the game path, looks it up, opens the `.datN`, and dispatches
    /// on [`FileKind`] via [`DatFile::read_file_typed`]. Standard payloads
    /// are decoded into bytes; Model and Texture payloads surface as typed
    /// variants carrying the parsed [`crate::dat::FileInfo`].
    ///
    /// See [`SqPackPayload`] for the variant shape and rationale.
    ///
    /// # Errors
    ///
    /// Same as [`SqPack::read_file`] modulo the [`Error::UnsupportedFileKind`]
    /// behaviour: this method only surfaces it for [`FileKind::Unknown`].
    /// Model and Texture payloads pass through as typed variants.
    ///
    /// # Panics
    ///
    /// Does not panic in any code path reachable through the public surface
    /// — see [`SqPack::read_file`] for the `expect` invariant rationale.
    pub fn read_file_typed(&self, game_path: &str) -> Result<SqPackPayload> {
        let span = info_span!(span_names::SQPACK_READ_FILE_TYPED, path = %game_path);
        let _enter = span.enter();
        let started_at = std::time::Instant::now();

        let (triple, location) = self.resolve(game_path)?;
        let mut dat = self.open_dat_for(triple, location.data_file_id)?;
        let payload = dat.read_file_typed(location.byte_offset)?;
        #[allow(clippy::cast_possible_truncation)]
        let elapsed_ms = started_at.elapsed().as_millis() as u64;
        info!(
            path = %game_path,
            kind = ?payload.kind(),
            elapsed_ms,
            "sqpack.read_file_typed",
        );
        Ok(payload)
    }

    /// Read a Texture-kind file at `game_path` and return the decoded
    /// bytes ready for [`crate::tex::Tex::parse`].
    ///
    /// Dispatches through [`SqPack::read_file_typed`] and unwraps the
    /// [`SqPackPayload::Texture`] variant, moving the owned `Vec<u8>`
    /// out of the payload. If the resolved path yields a Model or
    /// Standard payload, returns [`Error::UnexpectedFileKind`].
    ///
    /// This eliminates the payload-kind dispatch ceremony:
    ///
    /// ```text
    /// // Before:
    /// let payload = sq.read_file_typed("chara/human/c0201/obj/face/f0001/texture/--c0201f0001_fac_d.tex")?;
    /// let SqPackPayload::Texture(t) = payload else {
    ///     return Err(/* ... */);
    /// };
    /// let tex = Tex::parse(&t.bytes)?;
    ///
    /// // After:
    /// let bytes = sq.read_tex_bytes("chara/human/c0201/obj/face/f0001/texture/--c0201f0001_fac_d.tex")?;
    /// let tex = Tex::parse(&bytes)?;
    /// ```
    ///
    /// # Errors
    ///
    /// - [`Error::UnexpectedFileKind`] if the file is not a Texture payload.
    /// - Any error variant [`SqPack::read_file_typed`] can return.
    ///
    /// # Panics
    ///
    /// Does not panic in any code path reachable through the public surface
    /// — see [`SqPack::read_file`] for the `expect` invariant rationale.
    pub fn read_tex_bytes(&self, game_path: &str) -> Result<Vec<u8>> {
        let span = info_span!(span_names::SQPACK_READ_TEX_BYTES, path = %game_path);
        let _enter = span.enter();
        let started_at = std::time::Instant::now();

        let payload = self.read_file_typed(game_path)?;
        match payload {
            SqPackPayload::Texture(t) => {
                #[allow(clippy::cast_possible_truncation)]
                let elapsed_ms = started_at.elapsed().as_millis() as u64;
                info!(
                    path = %game_path,
                    bytes = t.bytes.len(),
                    elapsed_ms,
                    "sqpack.read_tex_bytes",
                );
                Ok(t.bytes)
            }
            other => Err(Error::UnexpectedFileKind {
                expected: FileKind::Texture,
                found: other.kind(),
            }),
        }
    }

    /// Read a Model-kind file at `game_path` and return Lumina-canonical
    /// `.mdl` bytes ready for [`crate::mdl::Mdl::parse`].
    ///
    /// Dispatches through [`SqPack::read_file_typed`], unwraps the
    /// [`SqPackPayload::Model`] variant, and calls
    /// [`crate::dat::ModelPayload::lumina_bytes`] to synthesise the
    /// 68-byte [`crate::mdl::ModelFileHeader`] prefix. If the resolved
    /// path yields a Texture or Standard payload, returns
    /// [`Error::UnexpectedFileKind`].
    ///
    /// This eliminates both the payload-kind dispatch ceremony and the
    /// `lumina_bytes` footgun (forgetting the prefix and handing raw
    /// section bytes to `Mdl::parse`):
    ///
    /// ```text
    /// // Before:
    /// let payload = sq.read_file_typed("chara/human/c0201/obj/body/b0001/model/c0201b0001_top.mdl")?;
    /// let SqPackPayload::Model(m) = payload else {
    ///     return Err(/* ... */);
    /// };
    /// let bytes = m.lumina_bytes();
    /// let mdl = Mdl::parse(&bytes)?;
    ///
    /// // After:
    /// let bytes = sq.read_mdl_bytes("chara/human/c0201/obj/body/b0001/model/c0201b0001_top.mdl")?;
    /// let mdl = Mdl::parse(&bytes)?;
    /// ```
    ///
    /// # Errors
    ///
    /// - [`Error::UnexpectedFileKind`] if the file is not a Model payload.
    /// - Any error variant [`SqPack::read_file_typed`] can return.
    ///
    /// # Panics
    ///
    /// Does not panic in any code path reachable through the public surface
    /// — see [`SqPack::read_file`] for the `expect` invariant rationale.
    pub fn read_mdl_bytes(&self, game_path: &str) -> Result<Vec<u8>> {
        let span = info_span!(span_names::SQPACK_READ_MDL_BYTES, path = %game_path);
        let _enter = span.enter();
        let started_at = std::time::Instant::now();

        let payload = self.read_file_typed(game_path)?;
        match payload {
            SqPackPayload::Model(m) => {
                let bytes = m.lumina_bytes();
                #[allow(clippy::cast_possible_truncation)]
                let elapsed_ms = started_at.elapsed().as_millis() as u64;
                info!(
                    path = %game_path,
                    bytes = bytes.len(),
                    elapsed_ms,
                    "sqpack.read_mdl_bytes",
                );
                Ok(bytes)
            }
            other => Err(Error::UnexpectedFileKind {
                expected: FileKind::Model,
                found: other.kind(),
            }),
        }
    }

    /// Open a streaming reader over the file at `game_path`.
    ///
    /// Routes the path through the same resolver as [`SqPack::read_file`],
    /// opens the resolved `.datN`, validates the per-payload
    /// [`crate::dat::FileInfo`] and block table, and returns a [`SqPackFileStream`]
    /// that yields one decoded block per [`Iterator::next`] call.
    ///
    /// Unlike [`SqPack::read_file`] / [`SqPack::read_file_typed`], the
    /// returned stream is **self-owning**: it carries the opened [`DatFile`]
    /// internally, so the caller does not have to keep the [`SqPack`] handle
    /// borrowed for the lifetime of the read.
    ///
    /// Only Standard-kind payloads are streamed. Model and Texture surface
    /// as [`Error::UnsupportedFileKind`] from this constructor.
    ///
    /// # Errors
    ///
    /// Same as [`SqPack::read_file`] except per-block decode failures bubble
    /// out of the returned iterator rather than this constructor.
    ///
    /// # Panics
    ///
    /// Does not panic in any code path reachable through the public surface
    /// — see [`SqPack::read_file`] for the `expect` invariant rationale.
    pub fn read_file_streaming(&self, game_path: &str) -> Result<SqPackFileStream> {
        let span = debug_span!(span_names::SQPACK_READ_FILE_STREAMING, path = %game_path);
        let _enter = span.enter();

        let (triple, location) = self.resolve(game_path)?;
        let mut dat = self.open_dat_for(triple, location.data_file_id)?;
        let header_size = dat.header().map_or(0, |h| u64::from(h.size));
        let byte_offset = location.byte_offset;
        if byte_offset < header_size {
            #[allow(clippy::cast_possible_truncation)]
            return Err(Error::InvalidHeaderSize {
                field: "byte_offset",
                value: byte_offset.min(u64::from(u32::MAX)) as u32,
                minimum: dat.header().map_or(0, |h| h.size),
            });
        }

        // Parse FileInfo + block table up front (same validation as the
        // borrowing DatFileStream), then transfer the parsed state into the
        // SqPack-owning stream type.
        dat.reader_mut().seek(SeekFrom::Start(byte_offset))?;
        let file_info =
            FileInfo::read(dat.reader_mut()).map_err(|e| Error::decode("file_info", &e))?;
        let kind = file_info.kind();
        match kind {
            FileKind::Standard | FileKind::Empty => {}
            other => return Err(Error::UnsupportedFileKind { kind: other }),
        }
        let blocks = crate::dat::read_and_validate_block_table(dat.reader_mut(), &file_info)?;
        let block_data_base = byte_offset + u64::from(file_info.size);
        let block_count = blocks.len();
        let raw_file_size = file_info.raw_file_size;
        info!(
            path = %game_path,
            kind = ?kind,
            block_count,
            raw_file_size,
            "sqpack.read_file_streaming opened"
        );
        Ok(SqPackFileStream {
            dat,
            file_info,
            blocks,
            block_data_base,
            cursor: 0,
        })
    }

    /// Peek the [`FileKind`] of `game_path` without decoding the body.
    ///
    /// Routes the path, opens the relevant `.index` / `.index2` / `.datN`,
    /// reads the per-payload [`crate::dat::FileInfo`] header, and returns the
    /// decoded kind. The `.dat` block table and per-block payloads are not
    /// touched.
    ///
    /// Useful for integrity scanners that want to skip Model/Texture entries
    /// (currently undecodable) without paying for the abandoned read, or to
    /// gather a kind-histogram of a category before deciding what to extract.
    ///
    /// # Note
    ///
    /// Returns [`FileKind::Empty`] for empty payloads. If you then open a
    /// [`SqPackFileStream`] for the same path via
    /// [`SqPack::read_file_streaming`], the stream's [`SqPackFileStream::kind`]
    /// will collapse `Empty` into [`FileKind::Standard`] because the streaming
    /// pipeline treats both as zero-or-more standard blocks. See
    /// [`SqPackFileStream::kind`] for the rationale.
    ///
    /// # Errors
    ///
    /// Same as [`SqPack::read_file`] up to (but excluding) block-decode
    /// failures — those don't fire here because no blocks are read.
    ///
    /// # Panics
    ///
    /// Does not panic in any code path reachable through the public surface
    /// — see [`SqPack::read_file`] for the `expect` invariant rationale.
    pub fn peek_kind(&self, game_path: &str) -> Result<FileKind> {
        let span = info_span!(span_names::SQPACK_PEEK_KIND, path = %game_path);
        let _enter = span.enter();
        let started_at = std::time::Instant::now();

        let (triple, location) = self.resolve(game_path)?;
        let mut dat = self.open_dat_for(triple, location.data_file_id)?;
        let kind = dat.peek_kind(location.byte_offset)?;
        debug!(path = %game_path, kind = ?kind, "sqpack.peek_kind");
        #[allow(clippy::cast_possible_truncation)]
        let elapsed_ms = started_at.elapsed().as_millis() as u64;
        info!(
            path = %game_path,
            kind = ?kind,
            elapsed_ms,
            "sqpack.peek_kind"
        );
        Ok(kind)
    }

    /// Read and parse `exd/root.exl` from this install.
    ///
    /// Convenience wrapper around `self.read_file("exd/root.exl")` +
    /// [`ExcelList::parse`]. The returned [`ExcelList`] is the master
    /// directory of every Excel sheet shipped with the client; see
    /// [`crate::excel`] for the wider data layer.
    ///
    /// # Errors
    ///
    /// - Any [`Error`] variant returned by [`SqPack::read_file`] when
    ///   resolving `exd/root.exl`.
    /// - Any [`Error`] variant returned by [`ExcelList::parse`] if the
    ///   file's bytes do not match the EXL wire format.
    pub fn excel_list(&self) -> Result<ExcelList> {
        let span = info_span!(span_names::SQPACK_EXCEL_LIST);
        let _enter = span.enter();
        let started_at = std::time::Instant::now();

        let bytes = self.read_file("exd/root.exl")?;
        let bytes_len = bytes.len();
        let list = ExcelList::parse(&bytes)?;
        #[allow(clippy::cast_possible_truncation)]
        let elapsed_ms = started_at.elapsed().as_millis() as u64;
        info!(
            sheets = list.len(),
            bytes = bytes_len,
            elapsed_ms,
            "sqpack.excel_list",
        );
        Ok(list)
    }

    /// Read and parse `exd/<sheet_name>.exh` from this install.
    ///
    /// Use [`SqPack::excel_list`] to enumerate valid sheet names.
    ///
    /// Convenience wrapper around
    /// `self.read_file(&format!("exd/{sheet_name}.exh"))` +
    /// [`ExcelHeader::parse`]. The returned [`ExcelHeader`] carries
    /// the sheet's column schema, page table, language variants, and
    /// fixed-row size — every piece needed to decode the matching
    /// `.exd` row files. `sheet_name` must match the entry in
    /// `exd/root.exl` exactly (case-sensitive).
    ///
    /// # Errors
    ///
    /// - Any [`Error`] variant returned by [`SqPack::read_file`] when
    ///   resolving the `.exh` path — notably [`Error::PathNotFound`]
    ///   if `sheet_name` is not listed in `exd/root.exl`.
    /// - Any [`Error`] variant returned by [`ExcelHeader::parse`] if
    ///   the file's bytes do not match the EXH wire format.
    pub fn excel_header(&self, sheet_name: &str) -> Result<ExcelHeader> {
        let span = info_span!(span_names::SQPACK_EXCEL_HEADER, sheet = %sheet_name);
        let _enter = span.enter();
        let started_at = std::time::Instant::now();

        let path = format!("exd/{sheet_name}.exh");
        let bytes = self.read_file(&path)?;
        let bytes_len = bytes.len();
        let header = ExcelHeader::parse(&bytes)?;
        #[allow(clippy::cast_possible_truncation)]
        let elapsed_ms = started_at.elapsed().as_millis() as u64;
        info!(
            sheet = %sheet_name,
            bytes = bytes_len,
            columns = header.columns().len(),
            pages = header.pages().len(),
            languages = header.languages().len(),
            elapsed_ms,
            "sqpack.excel_header",
        );
        Ok(header)
    }

    /// Read and parse one EXD page for a sheet.
    ///
    /// `header` is the [`ExcelHeader`] returned by
    /// [`SqPack::excel_header`] for the same `sheet_name`.
    /// `page_start_id` must match one of the entries in
    /// [`ExcelHeader::pages`]. `language` must be present in
    /// [`ExcelHeader::languages`] — otherwise this returns
    /// [`Error::LanguageNotAvailable`], with one natural-shaped
    /// exception: when `language` is [`Language::None`] and the
    /// sheet's `languages` list is `[Language::None]` (or empty), the
    /// un-suffixed file is loaded.
    ///
    /// Convenience wrapper around
    /// `self.read_file(&format!("exd/{sheet_name}_{page_start_id}[_<lang>].exd"))` +
    /// [`ExcelData::parse`]. Per the v0 caching contract this re-reads
    /// the file on every call; an LRU-backed variant is a follow-up.
    ///
    /// # Errors
    ///
    /// - [`Error::LanguageNotAvailable`] if `language` is not in
    ///   `header.languages()` (subject to the
    ///   `Language::None`-when-`[Language::None]`-only carve-out).
    /// - Any [`Error`] variant returned by [`SqPack::read_file`] when
    ///   resolving the `.exd` path — notably [`Error::PathNotFound`]
    ///   if the constructed path is not present in any archive.
    /// - Any [`Error`] variant returned by [`ExcelData::parse`] if the
    ///   file's bytes do not match the EXD wire format.
    pub fn excel_data(
        &self,
        sheet_name: &str,
        header: &ExcelHeader,
        page_start_id: u32,
        language: Language,
    ) -> Result<ExcelData> {
        let span = info_span!(
            span_names::SQPACK_EXCEL_DATA,
            sheet = %sheet_name,
            page_start_id,
            language = ?language,
        );
        let _enter = span.enter();
        let started_at = std::time::Instant::now();

        // The `[Language::None]` / empty-list case treats `None` as
        // "no language suffix"; every other mismatch is rejected. This
        // matches Lumina's behaviour of loading the un-suffixed file
        // for un-localised sheets.
        let languages = header.languages();
        let language_ok = match language {
            Language::None => languages.is_empty() || languages == [Language::None],
            other => languages.contains(&other),
        };
        if !language_ok {
            return Err(Error::LanguageNotAvailable {
                sheet: sheet_name.to_owned(),
                requested: language,
            });
        }

        let suffix = language_path_suffix(language);
        let path = if suffix.is_empty() {
            format!("exd/{sheet_name}_{page_start_id}.exd")
        } else {
            format!("exd/{sheet_name}_{page_start_id}_{suffix}.exd")
        };
        let bytes = self.read_file(&path)?;
        let bytes_len = bytes.len();
        let data = ExcelData::parse(&bytes, header, language)?;
        #[allow(clippy::cast_possible_truncation)]
        let elapsed_ms = started_at.elapsed().as_millis() as u64;
        info!(
            sheet = %sheet_name,
            page_start_id,
            language = ?language,
            bytes = bytes_len,
            row_count = data.row_count(),
            total_sub_row_count = data.total_sub_row_count(),
            elapsed_ms,
            "sqpack.excel_data",
        );
        Ok(data)
    }

    /// Open a sheet by name and return a [`Sheet`] handle.
    ///
    /// Reads the sheet's EXH eagerly (via [`SqPack::excel_header`]);
    /// the matching `.exd` row files are loaded lazily as the caller
    /// drives [`Sheet::rows`] / [`Sheet::row`] / [`Sheet::page`].
    ///
    /// `name` must match an entry in `exd/root.exl` exactly
    /// (case-sensitive). To enumerate valid names without opening
    /// every sheet, use [`SqPack::sheet_names`].
    ///
    /// # Errors
    ///
    /// Surfaces every error variant [`SqPack::excel_header`] can
    /// return, most notably [`Error::PathNotFound`] when `name` is not
    /// listed in `exd/root.exl`.
    pub fn sheet(&self, name: &str) -> Result<Sheet<'_>> {
        let span = info_span!(span_names::SQPACK_SHEET_OPEN, sheet = %name);
        let _enter = span.enter();
        let started_at = std::time::Instant::now();

        let header = self.excel_header(name)?;
        #[allow(clippy::cast_possible_truncation)]
        let elapsed_ms = started_at.elapsed().as_millis() as u64;
        info!(
            sheet = %name,
            columns = header.columns().len(),
            pages = header.pages().len(),
            languages = header.languages().len(),
            elapsed_ms,
            "sqpack.sheet",
        );
        Ok(Sheet::from_header(self, name.to_owned(), header))
    }

    /// Iterate every sheet listed in `exd/root.exl`.
    ///
    /// Reads the master list eagerly (a few KB on retail); EXH files
    /// are parsed on demand per item. A failure to read or parse a
    /// single sheet's EXH surfaces as `Err` for that item but the
    /// iteration continues — useful for whole-install sweeps where one
    /// damaged sheet should not stop the walk.
    ///
    /// # Errors
    ///
    /// Surfaces every error variant [`SqPack::excel_list`] can return
    /// during the up-front list read. Per-sheet failures are surfaced
    /// inline as iterator items.
    pub fn sheets(&self) -> Result<SheetIter<'_>> {
        let span = info_span!(span_names::SQPACK_SHEETS);
        let _enter = span.enter();
        let started_at = std::time::Instant::now();

        let list = self.excel_list()?;
        #[allow(clippy::cast_possible_truncation)]
        let elapsed_ms = started_at.elapsed().as_millis() as u64;
        info!(sheets = list.len(), elapsed_ms, "sqpack.sheets");
        Ok(SheetIter::new(self, list))
    }

    /// Cheap variant of [`SqPack::sheets`] — yields only the sheet
    /// names from `exd/root.exl`, without touching any `.exh` file.
    ///
    /// Useful when the caller already knows which sheets it cares
    /// about and only needs the catalog (e.g. to filter by name before
    /// opening).
    ///
    /// # Errors
    ///
    /// Surfaces every error variant [`SqPack::excel_list`] can return.
    pub fn sheet_names(&self) -> Result<SheetNameIter> {
        let span = info_span!(span_names::SQPACK_SHEET_NAMES);
        let _enter = span.enter();
        let started_at = std::time::Instant::now();

        let list = self.excel_list()?;
        #[allow(clippy::cast_possible_truncation)]
        let elapsed_ms = started_at.elapsed().as_millis() as u64;
        info!(sheets = list.len(), elapsed_ms, "sqpack.sheet_names");
        Ok(SheetNameIter::new(list))
    }

    /// Hash, route, and chunk-probe a game path to a `(triple, location)`
    /// pair without performing any `.dat` read.
    ///
    /// The shared core of [`SqPack::read_file`], [`SqPack::read_file_typed`],
    /// and [`SqPack::peek_kind`] — every entry point that takes a game path
    /// flows through here, so the chunk-probe semantics are defined exactly
    /// once.
    fn resolve(&self, game_path: &str) -> Result<(ArchiveTriple, DatLocation)> {
        // Normalise once and share the buffer between routing + both hash
        // shapes. The public path-taking versions allocate their own
        // normalised buffer; the pub(crate) `*_normalized` helpers skip that
        // step so the resolver does the work exactly once on its hot path.
        let normalised = normalize(game_path);

        let triple_base = ArchiveTriple::from_normalized(&normalised).ok_or_else(|| {
            let first = normalised.split(|&b| b == b'/').next().unwrap_or(&[]);
            let name = String::from_utf8_lossy(first).into_owned();
            Error::UnknownCategory { name }
        })?;

        let v1_key = hash_index1_key_normalized(&normalised);
        let v2_key = hash_index2_normalized(&normalised);

        // The normalised buffer is byte-equal to the input modulo ASCII
        // case-fold and `\` → `/`; both operations preserve UTF-8 validity,
        // so the bytes are still well-formed UTF-8 and `from_utf8` cannot
        // fail. The `&str` view is needed by `resolve_synonym` for its
        // byte-for-byte compare against the synonym record's stored path
        // (which is itself stored on-disk in already-normalised form).
        let normalised_str =
            std::str::from_utf8(&normalised).expect("normalize() preserves UTF-8 validity");

        for chunk in 0..=CHUNK_PROBE_CAP {
            let triple = ArchiveTriple {
                chunk,
                ..triple_base
            };
            let index_path = triple
                .index_path(&self.install_root, self.platform)
                .expect(PATH_BUILDER_INVARIANT);
            let index2_path = triple
                .index2_path(&self.install_root, self.platform)
                .expect(PATH_BUILDER_INVARIANT);

            if !self.vfs.exists(&index_path) && !self.vfs.exists(&index2_path) {
                debug!(
                    chunk,
                    "chunk has no .index or .index2 archive; ending probe",
                );
                break;
            }

            if let Some(location) = Self::lookup_in_chunk(
                &*self.vfs,
                triple,
                normalised_str,
                v1_key,
                v2_key,
                &index_path,
                &index2_path,
            )? {
                return Ok((triple, location));
            }
        }

        Err(Error::PathNotFound {
            game_path: game_path.to_owned(),
        })
    }

    /// Try to locate the entry inside the archives for a single chunk.
    ///
    /// `index_path` and `index2_path` are pre-built by the caller (which has
    /// also already confirmed at least one of them exists) so this helper
    /// doesn't redundantly resolve them.
    ///
    /// `normalised_path` is the `normalize`d form of the caller's game path
    /// (lowercase ASCII, `\` → `/`) — same canonical shape as the synonym
    /// record's on-disk path — and is threaded into
    /// [`crate::Index::resolve_synonym`] for its byte-for-byte compare. The
    /// resolver's raw caller-supplied path is used elsewhere for the
    /// [`Error::PathNotFound`] error construction; this helper itself only
    /// needs the normalised form.
    ///
    /// Returns:
    ///
    /// - `Ok(Some(loc))` — found a usable entry.
    /// - `Ok(None)` — chunk archives existed but didn't contain the path
    ///   (including the case where the v1 entry was flagged synonym and
    ///   the synonym table held no record matching the normalised path);
    ///   the caller advances to the next chunk.
    /// - `Err(...)` — I/O or parse failure that should bubble up.
    fn lookup_in_chunk(
        vfs: &dyn Vfs,
        triple: ArchiveTriple,
        normalised_path: &str,
        v1_key: u64,
        v2_key: u32,
        index_path: &Path,
        index2_path: &Path,
    ) -> Result<Option<DatLocation>> {
        // Track whether at least one of the two archive files actually existed
        // on disk — if neither did, we don't emit the "no archives matched"
        // debug event so a chunk-probe across an install that only uses
        // chunk 0 stays quiet rather than spamming.
        let mut any_existed = false;

        // v1 (.index) first.
        if vfs.exists(index_path) {
            any_existed = true;
            debug!(
                chunk = triple.chunk,
                path = %index_path.display(),
                "probing .index",
            );
            let idx = Index::open_in(index_path, vfs)?;
            if let Some(entry) = idx.get(v1_key) {
                if entry.is_synonym() {
                    // Synonym-flagged main-table entry — the real
                    // `(data_file_id, byte_offset)` lives in a record in
                    // the synonym table keyed on `(v1_key, normalised_path)`.
                    // A miss inside the synonym region is treated as
                    // "not in this chunk" (fall through to v2 / next
                    // chunk) rather than as an error: a hash collision
                    // that lands in another category's synonym bucket
                    // (vanishingly unlikely in practice but
                    // structurally possible) should not poison the
                    // lookup.
                    if let Some(syn) = idx.resolve_synonym(v1_key, normalised_path) {
                        debug!(
                            chunk = triple.chunk,
                            conflict_index = syn.conflict_index(),
                            "resolved v1 synonym entry",
                        );
                        return Ok(Some(syn.dat_location()));
                    }
                    debug!(
                        chunk = triple.chunk,
                        "v1 synonym bit set but no matching synonym record for path; falling through",
                    );
                } else if let Some(loc) = entry.dat_location() {
                    return Ok(Some(loc));
                }
            }
        }

        // v2 (.index2) fallback.
        if vfs.exists(index2_path) {
            any_existed = true;
            debug!(
                chunk = triple.chunk,
                path = %index2_path.display(),
                "probing .index2",
            );
            let idx2 = Index2::open_in(index2_path, vfs)?;
            if let Some(entry) = idx2.get(v2_key) {
                return Ok(Some(entry.dat_location()));
            }
        }

        if any_existed {
            debug!(chunk = triple.chunk, "no archives matched on this chunk");
        }
        Ok(None)
    }

    /// Open the `.dat{N}` file the location points into and extract the
    /// payload at the supplied offset.
    fn read_payload(&self, triple: ArchiveTriple, location: DatLocation) -> Result<Vec<u8>> {
        let mut dat = self.open_dat_for(triple, location.data_file_id)?;
        dat.read_file(location.byte_offset)
    }

    /// Open a [`DatFile`] for `(triple, dat_id)` against this install's
    /// configured [`Vfs`] and [`Platform`].
    ///
    /// Crate-internal helper shared between the path-driven read pipeline
    /// (`read_file` / `read_file_typed` / `read_file_streaming` / `peek_kind`)
    /// and the hash-keyed walker in
    /// [`crate::verify`]. The walker enumerates `(triple, dat_id, byte_offset)`
    /// tuples straight off `IndexEntry::dat_location` / `Index2Entry::dat_location`
    /// — it never goes through `read_file_streaming` (which routes by game
    /// path) and so needs a way to acquire a `DatFile` without re-walking
    /// the routing layer. Keeping the helper `pub(crate)` keeps the
    /// `(triple, dat_id) → DatFile` opening logic in exactly one place.
    ///
    /// Both `triple.dat_path(...)` returning `Some` and the install's
    /// `vfs.open_read(...)` succeeding are required; the former is
    /// guaranteed by `with_platform` rejecting `Platform::Unknown`
    /// at builder time, the latter is the only failure mode this
    /// helper can return.
    pub(crate) fn open_dat_for(&self, triple: ArchiveTriple, dat_id: u8) -> Result<DatFile> {
        // with_platform rejects Platform::Unknown and `repo_folder()`
        // covers the in-range expansion; the path-builder therefore always
        // returns `Some` here. The .expect() encodes that invariant.
        let dat_path = triple
            .dat_path(&self.install_root, self.platform, dat_id)
            .expect(PATH_BUILDER_INVARIANT);
        let dat = DatFile::open_at_in(&dat_path, &*self.vfs, dat_id)?;
        // Propagate the parent's block-cache identity context (if any)
        // to the fresh [`DatFile`] so this handle's reads short-circuit
        // the inflate step on repeat blocks. The cache itself is shared
        // via `Arc::clone` — both the parent `SqPack` and every spawned
        // `DatFile` point at the same physical cache. Written as an
        // `if let` rather than `map_or` because `with_cache_context`
        // consumes `dat`, and `map_or`'s default-value branch would
        // require an unconditional pre-move.
        #[cfg(feature = "block-cache")]
        let dat = if let Some(cache) = &self.block_cache {
            dat.with_cache_context(self.install_id, triple, dat_id, Arc::clone(cache))
        } else {
            dat
        };
        Ok(dat)
    }

    /// Lazily enumerate every `.index` / `.index2` archive present under this
    /// install.
    ///
    /// Returns an [`ArchiveWalker`] iterator that sweeps the cross-product of
    /// known categories, expansions `0..=15`, and chunks `0..=7`, opening only
    /// the archives that actually exist on disk. Each step yields a
    /// [`Result<EnumeratedArchive>`]: parse failures (corrupt header, bad
    /// magic, etc.) surface as `Err` so the caller can choose to log-and-skip
    /// or abort. Triples with no `.index` and no `.index2` present are
    /// skipped silently.
    ///
    /// The order is deterministic: outer loop is the
    /// [`KNOWN_CATEGORIES`](crate::route::Category) table order, then
    /// `expansion 0..=15`, then `chunk 0..=7`. v1 is preferred over v2 when
    /// both are present at the same triple; a v1-only triple yields
    /// [`EnumeratedArchive::V1`] and a v2-only triple yields
    /// [`EnumeratedArchive::V2`]. When both files exist the walker emits **two
    /// items** for the same triple — `V1` first, then `V2` — so a caller
    /// running an integrity scan sees the union of both hash tables without
    /// having to re-walk the install.
    ///
    /// The walker is lazy: archives are opened one at a time on each
    /// `next()`, and the previously-yielded archive is dropped before the
    /// next one is opened. An integrity scan over a multi-GiB install pays
    /// only for the currently-iterated archive's RAM footprint.
    #[must_use]
    pub fn enumerate_archives(&self) -> ArchiveWalker<'_> {
        ArchiveWalker::new(self)
    }

    /// Eagerly probe every archive slot in parallel via rayon, returning a
    /// `Vec<Result<EnumeratedArchive>>` in the same order
    /// [`Self::enumerate_archives`] would yield.
    ///
    /// Sibling of [`Self::enumerate_archives`] for callers that need the
    /// full archive list up front and want to use multiple cores to open
    /// the per-archive `.index` / `.index2` parses concurrently.
    /// Determinism is preserved: rayon's `.collect()` on an indexed
    /// `Vec` source preserves input order, so the returned vec is
    /// element-wise identical to draining the lazy sequential walker.
    ///
    /// # Behaviour
    ///
    /// - Same "chunk gap" semantics as the lazy walker: when the lowest
    ///   chunk for a `(category, expansion)` pair is missing, no higher
    ///   chunk for that pair is probed (mirrored from
    ///   [`ArchiveWalker`]'s `skip_to_next_expansion`).
    /// - Same v1/v2 emission shape: when both an `.index` and an
    ///   `.index2` exist at the same triple, two entries appear —
    ///   `V1` first, then `V2`.
    /// - Per-archive parse failures land in the returned vec as `Err`
    ///   slots, identical to the sequential walker.
    ///
    /// # Why a `Vec` rather than `impl ParallelIterator`
    ///
    /// Returning `impl ParallelIterator<Item = Result<EnumeratedArchive>>`
    /// would tie the iterator's lifetime to `&self`, which complicates
    /// `'static`-bounded contexts (rayon thread pool, tokio
    /// `spawn_blocking`). The eagerly-collected `Vec` is what consumers
    /// actually use today (a fan-out integrity scan typically wants
    /// every archive's metadata in memory before parallel-hashing
    /// entries).
    ///
    /// # Requires
    ///
    /// `parallel` Cargo feature.
    #[cfg(feature = "parallel")]
    #[cfg_attr(docsrs, doc(cfg(feature = "parallel")))]
    #[must_use]
    pub fn enumerate_archives_parallel(&self) -> Vec<Result<EnumeratedArchive>> {
        crate::parallel::enumerate_archives_parallel(self)
    }

    /// Create an [`SqPackAppender`](crate::write::SqPackAppender) that
    /// reuses this handle's `install_root`, `platform`, and read-side
    /// [`Vfs`] — eliminating the common bug where those values diverge
    /// between the reader and the writer.
    ///
    /// Writes go through a fresh [`StdFs`] backend. For an explicit
    /// write-side [`VfsWrite`](crate::vfs::VfsWrite) backend, use
    /// [`SqPackAppender::open_with_vfs`](crate::write::SqPackAppender::open_with_vfs)
    /// directly.
    ///
    /// # Errors
    ///
    /// Returns [`Error::Io`] if the install root is unreachable at
    /// open time — same contract as
    /// [`SqPackAppender::open_with_vfs`](crate::write::SqPackAppender::open_with_vfs).
    pub fn appender(&self) -> Result<crate::write::SqPackAppender> {
        crate::write::SqPackAppender::open_with_vfs(
            &self.install_root,
            self.platform,
            Arc::clone(&self.vfs),
            Arc::new(StdFs::new()),
        )
    }
}

/// Self-owning streaming reader returned by [`SqPack::read_file_streaming`].
///
/// Carries the opened [`DatFile`] internally so the [`SqPack`] handle does
/// not stay borrowed for the lifetime of the iteration. Each
/// [`Iterator::next`] call decodes one block; the returned [`BlockChunk`]
/// pairs the decompressed payload with its zero-based index in the block
/// table.
///
/// Block ordering and validation semantics mirror [`crate::dat::DatFileStream`].
/// Only Standard-kind payloads are streamed.
///
/// **Block-cache wiring asymmetry vs [`crate::dat::DatFileStream`]**: this
/// stream owns its [`DatFile`] outright, so the per-block cache
/// context is read straight off `self.dat.cache_ctx` via
/// [`DatFile::reader_and_cache`](crate::DatFile) on each block. The
/// sibling [`crate::dat::DatFileStream`] only borrows `&mut dyn ReadSeek`
/// (the originating [`DatFile`] is held by the caller), so it carries
/// its own cloned [`crate::dat::CacheContext`] alongside the reader
/// borrow. Both routes hit the same physical cache when one is
/// attached; the storage shape just differs because the lifetime
/// posture does.
pub struct SqPackFileStream {
    dat: DatFile,
    file_info: FileInfo,
    blocks: Vec<BlockInfo>,
    block_data_base: u64,
    cursor: usize,
}

impl SqPackFileStream {
    /// [`FileKind`] of the payload being streamed — always
    /// [`FileKind::Standard`] (Empty is treated as a zero-block Standard).
    #[must_use]
    pub fn kind(&self) -> FileKind {
        match self.file_info.kind() {
            FileKind::Empty => FileKind::Standard,
            other => other,
        }
    }

    /// Total uncompressed file size in bytes, as declared by the parsed
    /// [`FileInfo`].
    #[must_use]
    pub fn raw_file_size(&self) -> u32 {
        self.file_info.raw_file_size
    }

    /// Number of blocks in the file's block table — the total iteration
    /// count.
    #[must_use]
    pub fn block_count(&self) -> u32 {
        self.file_info.number_of_blocks
    }

    /// Parsed [`FileInfo`] for the file being streamed.
    #[must_use]
    pub fn file_info(&self) -> &FileInfo {
        &self.file_info
    }

    /// Read the next block's payload into a caller-supplied buffer.
    ///
    /// Equivalent to [`Iterator::next`] but writes into the supplied buffer
    /// (which is cleared on entry) instead of allocating a fresh `Vec`.
    /// Returns `Some(Ok(()))` after appending the block's bytes,
    /// `Some(Err(_))` on a decode failure, and `None` when the iteration
    /// exhausts.
    pub fn read_block_into(&mut self, buf: &mut Vec<u8>) -> Option<Result<()>> {
        let bi = *self.blocks.get(self.cursor)?;
        self.cursor += 1;
        buf.clear();
        // `block_data_base` is copied out before `reader_and_cache()`
        // because that accessor borrows `&mut self.dat` and would
        // otherwise conflict with `&self.block_data_base` inside the
        // same expression — see the matching read_one_block call site
        // in `Iterator::next` for the same pattern.
        let block_data_base = self.block_data_base;
        let (reader, cache_ctx) = self.dat.reader_and_cache();
        Some(crate::dat::read_one_block(
            reader,
            block_data_base,
            bi,
            buf,
            cache_ctx,
        ))
    }
}

impl Iterator for SqPackFileStream {
    type Item = Result<BlockChunk>;

    fn next(&mut self) -> Option<Self::Item> {
        // cursor is bounded by blocks.len() which came from a u32 on disk
        // (FileInfo::number_of_blocks), so the cast back to u32 cannot truncate.
        #[allow(clippy::cast_possible_truncation)]
        let idx = self.cursor as u32;
        let bi = *self.blocks.get(self.cursor)?;
        self.cursor += 1;
        let mut bytes = Vec::with_capacity(usize::from(bi.uncompressed_size));
        // See `read_block_into` for the rationale on the local copy —
        // `reader_and_cache()` borrows `&mut self.dat`, so any other
        // `&self.*` field access has to be hoisted above the call.
        let block_data_base = self.block_data_base;
        let (reader, cache_ctx) = self.dat.reader_and_cache();
        let result = crate::dat::read_one_block(reader, block_data_base, bi, &mut bytes, cache_ctx);
        Some(result.map(|()| BlockChunk { index: idx, bytes }))
    }

    fn size_hint(&self) -> (usize, Option<usize>) {
        let remaining = self.blocks.len().saturating_sub(self.cursor);
        (remaining, Some(remaining))
    }
}

impl ExactSizeIterator for SqPackFileStream {}

impl std::fmt::Debug for SqPackFileStream {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("SqPackFileStream")
            .field("file_info", &self.file_info)
            .field("block_count", &self.blocks.len())
            .field("block_data_base", &self.block_data_base)
            .field("cursor", &self.cursor)
            .finish_non_exhaustive()
    }
}

/// One archive yielded by [`ArchiveWalker`] — either a parsed v1 `.index` or
/// v2 `.index2`.
///
/// Carries the [`ArchiveTriple`] the archive resolved to alongside the parsed
/// type so an enumeration consumer can correlate hash-table entries back to
/// their physical archive without re-deriving the triple.
///
/// `#[non_exhaustive]`: a future addition (e.g. a third index format, or a
/// merged-view variant) lands as an additive variant.
#[non_exhaustive]
#[derive(Debug)]
pub enum EnumeratedArchive {
    /// A v1 `.index` archive.
    V1(ArchiveTriple, Index),
    /// A v2 `.index2` archive.
    V2(ArchiveTriple, Index2),
}

impl EnumeratedArchive {
    /// The [`ArchiveTriple`] this archive resolved to.
    #[must_use]
    pub fn triple(&self) -> ArchiveTriple {
        match self {
            EnumeratedArchive::V1(t, _) | EnumeratedArchive::V2(t, _) => *t,
        }
    }

    /// Number of entries in the archive's hash table.
    #[must_use]
    pub fn len(&self) -> usize {
        match self {
            EnumeratedArchive::V1(_, idx) => idx.len(),
            EnumeratedArchive::V2(_, idx) => idx.len(),
        }
    }

    /// `true` when the archive's hash table is empty.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.len() == 0
    }
}

/// Lazy iterator yielded by [`SqPack::enumerate_archives`].
///
/// Walks `(category, expansion, chunk)` triples in the order described on
/// [`SqPack::enumerate_archives`] and opens each existing archive on demand.
/// The walker borrows the [`SqPack`] handle for its lifetime; the handle's
/// `install_root` and `platform` are stable for the walk.
///
/// Hidden state: a cursor over `KNOWN_CATEGORIES × expansion × chunk × {v1,
/// v2}` slot. `next()` advances the cursor until a present archive is found
/// (or the cursor exhausts). The walker stops probing higher chunks for a
/// given `(category, expansion)` as soon as a chunk has neither `.index` nor
/// `.index2` on disk — same early-break invariant as
/// [`SqPack::read_file`]'s chunk probe.
pub struct ArchiveWalker<'a> {
    sq: &'a SqPack,
    cat_idx: usize,
    expansion: u8,
    chunk: u8,
    /// `false` = next slot is v1; `true` = next slot is v2 (within the same
    /// triple, we emit v1 first).
    emit_v2_next: bool,
}

impl<'a> ArchiveWalker<'a> {
    fn new(sq: &'a SqPack) -> Self {
        Self {
            sq,
            cat_idx: 0,
            expansion: 0,
            chunk: 0,
            emit_v2_next: false,
        }
    }

    /// Advance the (category, expansion, chunk, v1/v2) cursor by one slot.
    ///
    /// The walker emits two slots per triple — v1 then v2. After v2 the
    /// cursor steps to the next chunk; after the last chunk the cursor steps
    /// to the next expansion; after the last expansion the cursor steps to
    /// the next category. Once the category cursor exhausts, the walker
    /// returns `false` (no more slots).
    fn advance(&mut self) -> bool {
        if !self.emit_v2_next {
            self.emit_v2_next = true;
            return true;
        }
        self.emit_v2_next = false;
        if self.chunk < CHUNK_PROBE_CAP {
            self.chunk += 1;
            return true;
        }
        self.chunk = 0;
        if self.expansion < 15 {
            self.expansion += 1;
            return true;
        }
        self.expansion = 0;
        self.cat_idx += 1;
        self.cat_idx < KNOWN_CATEGORIES.len()
    }

    /// Skip the rest of the chunks for the current `(category, expansion)`.
    ///
    /// Called when the chunk probe finds a gap (neither `.index` nor
    /// `.index2` exists for the current chunk) — higher chunks for the same
    /// pair cannot exist when the lower chunk is missing, so we jump straight
    /// to the next `(category, expansion)` instead of advancing one slot at
    /// a time through every remaining chunk.
    ///
    /// Returns `false` once the category cursor exhausts (no more slots).
    fn skip_to_next_expansion(&mut self) -> bool {
        self.emit_v2_next = false;
        self.chunk = 0;
        if self.expansion < 15 {
            self.expansion += 1;
            return true;
        }
        self.expansion = 0;
        self.cat_idx += 1;
        self.cat_idx < KNOWN_CATEGORIES.len()
    }

    fn current_triple(&self) -> Option<ArchiveTriple> {
        let (_, raw_id) = *KNOWN_CATEGORIES.get(self.cat_idx)?;
        Some(ArchiveTriple {
            category: Category::from_known_id(raw_id),
            expansion: self.expansion,
            chunk: self.chunk,
        })
    }
}

impl Iterator for ArchiveWalker<'_> {
    type Item = Result<EnumeratedArchive>;

    fn next(&mut self) -> Option<Self::Item> {
        loop {
            if self.cat_idx >= KNOWN_CATEGORIES.len() {
                return None;
            }

            let triple = self.current_triple()?;

            // Build both paths once per (category, expansion, chunk) — used by
            // the existence-check that controls the early break, and by the
            // v1/v2 emission below.
            // Both path-builders return Some because with_platform
            // rejects Platform::Unknown and expansion is bounded to 0..=15.
            let index_path = triple
                .index_path(&self.sq.install_root, self.sq.platform)
                .expect(PATH_BUILDER_INVARIANT);
            let index2_path = triple
                .index2_path(&self.sq.install_root, self.sq.platform)
                .expect(PATH_BUILDER_INVARIANT);

            let v1_exists = self.sq.vfs.exists(&index_path);
            let v2_exists = self.sq.vfs.exists(&index2_path);
            if !v1_exists && !v2_exists {
                // No archive at this chunk for this (category, expansion) —
                // jump straight to the next `(category, expansion)` instead
                // of advancing one slot at a time through every remaining
                // chunk. Higher chunks for the same pair cannot exist when
                // the lower chunk is missing.
                if !self.skip_to_next_expansion() {
                    return None;
                }
                continue;
            }

            let emit_v2 = self.emit_v2_next;
            if !emit_v2 {
                if v1_exists {
                    let result = Index::open_in(&index_path, &*self.sq.vfs)
                        .map(|idx| EnumeratedArchive::V1(triple, idx));
                    if !self.advance() {
                        // No further slots — the next call returns None.
                    }
                    return Some(result);
                }
                // No v1 here — fall through to the v2 emission slot.
                if !self.advance() {
                    return None;
                }
                continue;
            }
            // emit_v2_next branch.
            if v2_exists {
                let result = Index2::open_in(&index2_path, &*self.sq.vfs)
                    .map(|idx| EnumeratedArchive::V2(triple, idx));
                if !self.advance() {
                    // No further slots — the next call returns None.
                }
                return Some(result);
            }
            if !self.advance() {
                return None;
            }
        }
    }
}

/// Lazy iterator yielded by
/// [`SqPack::known_paths`](crate::SqPack::known_paths).
///
/// Walks the attached [`PathList`](crate::PathList) in insertion
/// order, cross-referencing each embedded path against the install's
/// `.index` / `.index2` files. Yields the path on hit; skips on
/// miss. Returns an empty iteration when no list is attached.
///
/// Per-archive opens are cached for the iterator's lifetime — if two
/// embedded paths route to the same `(category, expansion, chunk)`
/// triple the underlying `.index` is parsed once, not twice. The
/// cache drops when the iterator drops, so a second `known_paths()`
/// call re-parses the touched archives.
///
/// The iteration order matches
/// [`PathList::paths`](crate::PathList::paths), which is the
/// insertion order from the original [`PathList`] construction.
#[cfg(feature = "path-list")]
#[cfg_attr(docsrs, doc(cfg(feature = "path-list")))]
pub struct KnownPathsIter<'a> {
    sq: &'a SqPack,
    /// Snapshot of the attached list. Holding the [`Arc`] keeps the
    /// list alive for the iterator's lifetime independent of the
    /// `sq` borrow's outer lifetime. `None` when no list is
    /// attached at construction time (the iterator is empty).
    list: Option<Arc<crate::path_list::PathList>>,
    /// Cursor into the snapshot's path list. Indexed against
    /// `list.as_ref().unwrap().paths().nth(cursor)`; advances on
    /// every [`Iterator::next`] call until the snapshot exhausts.
    cursor: usize,
    /// Per-`ArchiveTriple` cache of opened indexes. See [`ProbeSlot`]
    /// for the per-side state encoding; absence of a triple from
    /// this map means "not yet probed".
    cache: HashMap<ArchiveTriple, ArchivePair>,
}

/// Per-side probe state for one [`ArchivePair`].
///
/// `Missing` = probed and the file does not exist (or failed to
/// parse — `known_paths` is a best-effort accessor, not a
/// verifier). `Loaded(_)` = parsed successfully. The "not yet
/// probed" case is encoded as the absence of an [`ArchivePair`]
/// entry from the cache `HashMap` rather than as a third variant
/// here; the outer `HashMap::contains_key` check is the only
/// probe-state branch the hot path needs.
#[cfg(feature = "path-list")]
enum ProbeSlot<T> {
    Missing,
    Loaded(T),
}

/// Cached parse state for one `(category, expansion, chunk)` triple
/// inside [`KnownPathsIter`].
#[cfg(feature = "path-list")]
struct ArchivePair {
    v1: ProbeSlot<Index>,
    v2: ProbeSlot<Index2>,
}

#[cfg(feature = "path-list")]
impl<'a> KnownPathsIter<'a> {
    fn new(sq: &'a SqPack) -> Self {
        // Snapshot the Arc — when no PathList is attached the
        // iterator is empty and `next()` short-circuits on the
        // `None` arm. Holding the Arc separately from `sq` lets
        // `resolve` mutably borrow `self.cache` while the path
        // strings remain reachable through the snapshot.
        Self {
            sq,
            list: sq.path_list.as_ref().map(Arc::clone),
            cursor: 0,
            cache: HashMap::new(),
        }
    }

    /// Resolve one path. Returns `true` iff a matching archive
    /// entry is found in the live install. Cache lookups (parsed
    /// once per triple per iterator lifetime) are populated on
    /// demand.
    ///
    /// Routes the path to its `(category, expansion)` exactly once
    /// via [`ArchiveTriple::from_game_path`] and then probes only
    /// the chunks (`0..=CHUNK_PROBE_CAP`) of that single triple —
    /// the read pipeline's [`SqPack::resolve`] follows the same
    /// shape, so the per-path I/O footprint here matches it (no
    /// blind walk across expansions a path could never live in).
    fn resolve(&mut self, game_path: &str) -> bool {
        // Hash once (normalised) — same routine as the read pipeline.
        let normalised = normalize(game_path);
        // Need the routed triple to probe anywhere. If routing
        // fails the path is unknown (e.g. user fed a non-FFXIV
        // path) so skip.
        let Some(triple_base) = ArchiveTriple::from_game_path(game_path) else {
            return false;
        };
        let v1_key = hash_index1_key_normalized(&normalised);
        let v2_key = hash_index2_normalized(&normalised);
        // SAFETY-ish: `normalize` lowercase-folds ASCII and rewrites
        // `\` → `/`; both operations preserve UTF-8 validity, so
        // `from_utf8` cannot fail. The `&str` view is needed by
        // `Index::resolve_synonym`'s byte-for-byte compare against
        // the synonym record's stored path (which is itself stored
        // on-disk in already-normalised form).
        let normalised_str =
            std::str::from_utf8(&normalised).expect("normalize() preserves UTF-8 validity");

        // Probe chunks 0..=CHUNK_PROBE_CAP for the routed
        // `(category, expansion)`. Matches the read pipeline's
        // `SqPack::resolve` exactly except for the data-block
        // read at the end — we only need the boolean "is this
        // path present in any archive?" answer.
        for chunk in 0..=CHUNK_PROBE_CAP {
            let triple = ArchiveTriple {
                chunk,
                ..triple_base
            };
            // Parse-on-demand, cached. Pulling the open functions
            // out of the entry-or-insert path keeps the borrow
            // checker happy: the `&mut self.cache` borrow only
            // lives across the `insert` call, not across the
            // `open_index_*` calls (which borrow `self.sq`).
            if !self.cache.contains_key(&triple) {
                let v1 = match open_index_v1(self.sq, triple) {
                    Some(idx) => ProbeSlot::Loaded(idx),
                    None => ProbeSlot::Missing,
                };
                let v2 = match open_index_v2(self.sq, triple) {
                    Some(idx) => ProbeSlot::Loaded(idx),
                    None => ProbeSlot::Missing,
                };
                self.cache.insert(triple, ArchivePair { v1, v2 });
            }
            let pair = self.cache.get(&triple).expect("just inserted");
            let v1_present = matches!(pair.v1, ProbeSlot::Loaded(_));
            let v2_present = matches!(pair.v2, ProbeSlot::Loaded(_));

            // No archive at this chunk — higher chunks cannot
            // exist either (the chunk numbering is dense), so stop
            // probing this path.
            if !v1_present && !v2_present {
                break;
            }

            let v1_idx = if let ProbeSlot::Loaded(idx) = &pair.v1 {
                Some(idx)
            } else {
                None
            };
            let v2_idx = if let ProbeSlot::Loaded(idx) = &pair.v2 {
                Some(idx)
            } else {
                None
            };
            if path_present_in_loaded(v1_idx, v2_idx, normalised_str, v1_key, v2_key) {
                return true;
            }
        }
        false
    }
}

/// Boolean "does this archive pair contain `normalised_path`?" probe.
///
/// Shared between the read-pipeline resolver
/// ([`SqPack::lookup_in_chunk`], indirectly) and the path-list
/// iterator ([`KnownPathsIter::resolve`]). The two callers have
/// different ownership models for the loaded indexes — the resolver
/// opens fresh ones per chunk; the iterator caches them across
/// paths — so this helper takes pre-loaded `Option<&Index>` /
/// `Option<&Index2>` references rather than re-opening from disk.
///
/// Honours v1 synonym entries: an entry with [`IndexEntry::is_synonym`]
/// set is *not* treated as a hit on its raw hash — the synonym
/// table is consulted with the normalised path, and a synonym miss
/// (the entry refers to a different path that hashes to the same
/// v1 key) correctly falls through to the v2 probe. Matches the
/// read-pipeline semantics in [`SqPack::lookup_in_chunk`].
#[cfg(feature = "path-list")]
fn path_present_in_loaded(
    v1: Option<&Index>,
    v2: Option<&Index2>,
    normalised_path: &str,
    v1_key: u64,
    v2_key: u32,
) -> bool {
    if let Some(idx) = v1 {
        if let Some(entry) = idx.get(v1_key) {
            if entry.is_synonym() {
                // Synonym-flagged: confirm it really resolves to
                // this path (and not a hash neighbour) before
                // claiming a hit. A synonym miss falls through to
                // the v2 probe.
                if idx.resolve_synonym(v1_key, normalised_path).is_some() {
                    return true;
                }
            } else if entry.dat_location().is_some() {
                return true;
            }
        }
    }
    if let Some(idx2) = v2 {
        if idx2.get(v2_key).is_some() {
            return true;
        }
    }
    false
}

#[cfg(feature = "path-list")]
fn open_index_v1(sq: &SqPack, triple: ArchiveTriple) -> Option<Index> {
    let path = triple.index_path(&sq.install_root, sq.platform)?;
    if !sq.vfs.exists(&path) {
        return None;
    }
    Index::open_in(&path, &*sq.vfs).ok()
}

#[cfg(feature = "path-list")]
fn open_index_v2(sq: &SqPack, triple: ArchiveTriple) -> Option<Index2> {
    let path = triple.index2_path(&sq.install_root, sq.platform)?;
    if !sq.vfs.exists(&path) {
        return None;
    }
    Index2::open_in(&path, &*sq.vfs).ok()
}

#[cfg(feature = "path-list")]
impl Iterator for KnownPathsIter<'_> {
    /// `Arc<str>` rather than `&str` because this iterator owns
    /// its parse cache for the duration of iteration and must
    /// mutate it from inside `next`; yielding `Arc<str>` lets the
    /// caller carry the path past the cache mutation with one
    /// refcount bump per yield. An interior-mutable cache
    /// ([`std::cell::RefCell`]) would let us yield `&str` instead
    /// — we chose `Arc<str>` for the simpler shape, since refcount
    /// cost is negligible and consumers wanting `&str` can call
    /// [`AsRef::as_ref`] on the yielded value.
    type Item = Arc<str>;

    fn next(&mut self) -> Option<Self::Item> {
        // Walk forward until a path resolves; give up when the
        // snapshot exhausts. Cloning the `Arc<str>` out of the
        // snapshot before calling `resolve` (which mutably borrows
        // `self`) sidesteps the cache/snapshot borrow conflict
        // entirely.
        loop {
            let candidate: Arc<str> = {
                let list = self.list.as_ref()?;
                let arc = list.arc_at(self.cursor)?;
                self.cursor += 1;
                arc
            };
            if self.resolve(candidate.as_ref()) {
                return Some(candidate);
            }
        }
    }

    fn size_hint(&self) -> (usize, Option<usize>) {
        // Upper bound: every remaining snapshot entry could
        // resolve. Lower bound: zero — any candidate may miss
        // (`resolve` returns false) so the iterator can yield
        // anywhere between 0 and `remaining` items.
        let remaining = self
            .list
            .as_ref()
            .map_or(0, |l| l.len())
            .saturating_sub(self.cursor);
        (0, Some(remaining))
    }
}

#[cfg(feature = "path-list")]
impl std::fmt::Debug for KnownPathsIter<'_> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("KnownPathsIter")
            .field("cache_entries", &self.cache.len())
            .finish_non_exhaustive()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use std::sync::atomic::{AtomicU64, Ordering};

    /// Process-local counter for `TestInstall` directory names. Ensures two
    /// tests in the same run don't pick the same temp path.
    static COUNTER: AtomicU64 = AtomicU64::new(0);

    /// Build a unique temp-dir path inside `std::env::temp_dir()`. We
    /// deliberately don't pull in the `tempfile` crate (no new dependencies);
    /// the format failure-mode tests only need a directory we can put a
    /// `sqpack/` subdir inside.
    fn temp_path(tag: &str) -> PathBuf {
        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
        let mut p = std::env::temp_dir();
        p.push(format!("sqpack-rs-test-{tag}-{}-{n}", std::process::id()));
        let _ = fs::remove_dir_all(&p);
        p
    }

    /// Minimal RAII tempdir for the sqpack tests.
    struct TestInstall {
        path: PathBuf,
    }

    impl TestInstall {
        fn new() -> Self {
            let path = temp_path("install");
            fs::create_dir_all(path.join("sqpack/ffxiv")).expect("mkdir sqpack");
            TestInstall { path }
        }

        fn path(&self) -> &Path {
            &self.path
        }
    }

    impl Drop for TestInstall {
        fn drop(&mut self) {
            let _ = fs::remove_dir_all(&self.path);
        }
    }

    /// Trivial RAII cleanup helper for tests that don't go through
    /// `TestInstall`.
    fn scopeguard(path: PathBuf) -> impl Drop {
        struct G(PathBuf);
        impl Drop for G {
            fn drop(&mut self) {
                let _ = fs::remove_dir_all(&self.0);
            }
        }
        G(path)
    }

    // ----- open() validation -----

    #[test]
    fn open_validates_sqpack_directory_present() {
        let install = TestInstall::new();
        let sq = SqPack::open(install.path()).expect("open with valid sqpack dir");
        assert_eq!(sq.install_root(), install.path());
        assert_eq!(sq.platform(), Platform::Win32);
    }

    #[test]
    fn open_missing_sqpack_directory_is_io_error() {
        // Fresh path that doesn't have a `sqpack` child.
        let base = temp_path("missing");
        fs::create_dir_all(&base).expect("mkdir base");
        let _cleanup = scopeguard(base.clone());
        let err = SqPack::open(&base).unwrap_err();
        match err {
            Error::Io { path, .. } => {
                let p = path.expect("Io variant should carry the path");
                assert!(p.ends_with("sqpack"));
            }
            other => panic!("expected Io, got {other:?}"),
        }
    }

    #[test]
    fn open_sqpack_is_file_not_dir_is_io_error() {
        let base = temp_path("file");
        fs::create_dir_all(&base).expect("mkdir base");
        fs::write(base.join("sqpack"), b"oops").expect("write file");
        let _cleanup = scopeguard(base.clone());
        let err = SqPack::open(&base).unwrap_err();
        match err {
            Error::Io { .. } => {}
            other => panic!("expected Io, got {other:?}"),
        }
    }

    #[test]
    fn with_platform_stores_platform() {
        let install = TestInstall::new();
        let sq = SqPack::open(install.path())
            .expect("open")
            .with_platform(Platform::Ps4)
            .expect("with_platform");
        assert_eq!(sq.platform(), Platform::Ps4);
    }

    // ----- read_file: routing failure modes (no real archives needed) -----

    #[test]
    fn read_file_unknown_category_errors() {
        let install = TestInstall::new();
        let sq = SqPack::open(install.path()).expect("open");
        let err = sq.read_file("notacat/foo.bin").unwrap_err();
        match err {
            Error::UnknownCategory { name } => assert_eq!(name, "notacat"),
            other => panic!("expected UnknownCategory, got {other:?}"),
        }
    }

    #[test]
    fn read_file_known_category_no_archives_is_path_not_found() {
        // sqpack/ffxiv exists but contains no index files — the chunk probe
        // exhausts and surfaces PathNotFound.
        let install = TestInstall::new();
        let sq = SqPack::open(install.path()).expect("open");
        let err = sq.read_file("exd/root.exl").unwrap_err();
        match err {
            Error::PathNotFound { game_path } => assert_eq!(game_path, "exd/root.exl"),
            other => panic!("expected PathNotFound, got {other:?}"),
        }
    }

    #[test]
    fn read_file_empty_path_errors_as_unknown_category() {
        // The empty string normalises to "" — no category match.
        let install = TestInstall::new();
        let sq = SqPack::open(install.path()).expect("open");
        let err = sq.read_file("").unwrap_err();
        match err {
            Error::UnknownCategory { name } => assert_eq!(name, ""),
            other => panic!("expected UnknownCategory, got {other:?}"),
        }
    }

    // ----- open: unsupported-platform rejection -----

    #[test]
    fn with_unknown_platform_is_unsupported_platform_error() {
        // Platform::Unknown carries an unknown lowercase tag suffix, so the
        // path-routing layer can't build on-disk filenames for it. The
        // builder must surface that as UnsupportedPlatform rather than
        // producing a handle that fails every later lookup.
        let install = TestInstall::new();
        let err = SqPack::open(install.path())
            .expect("open")
            .with_platform(Platform::Unknown(99))
            .unwrap_err();
        match err {
            Error::UnsupportedPlatform { platform } => {
                assert_eq!(platform, Platform::Unknown(99));
            }
            other => panic!("expected UnsupportedPlatform, got {other:?}"),
        }
    }

    #[test]
    fn unsupported_platform_error_display_renders_through_platform_display() {
        // The `#[error]` format string for `Error::UnsupportedPlatform` uses
        // `{platform}` (not `{platform:?}`) specifically so the rendered text
        // routes through `Platform`'s `Display` impl. Pin the exact string so
        // a future regression — someone flipping the format spec back to
        // `{:?}`, or altering the `Display` output — can't slip past with all
        // other tests green.
        let err = Error::UnsupportedPlatform {
            platform: Platform::Unknown(99),
        };
        assert_eq!(err.to_string(), "unsupported SqPack platform: Unknown(99)");
    }

    #[test]
    fn unsupported_platform_error_display_for_named_variant_does_not_crash() {
        // `Error::UnsupportedPlatform` is technically constructible with any
        // `Platform` value, though `with_platform` only ever builds it
        // for `Unknown`. Pin the rendered string for a named variant so the
        // Display contract is documented for every variant, not just `Unknown`.
        let err = Error::UnsupportedPlatform {
            platform: Platform::Win32,
        };
        assert_eq!(err.to_string(), "unsupported SqPack platform: Win32");
    }

    // ----- read_file: chunk-probe behaviour with hand-built archives -----

    use crate::hash::{hash_index1_key, hash_index2};
    use crate::header::SQPACK_HEADER_SIZE;
    use crate::header::tests::make_sqpack_header;
    use crate::index::INDEX_ENTRY_SIZE;
    use crate::index2::INDEX2_ENTRY_SIZE;

    /// 1024-byte `SqPackIndexHeader` (shared layout for v1 + v2). The four
    /// regions besides `index_data_*` stay zero; we only need the hash table
    /// itself to be findable.
    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
    }

    /// Hand-build a minimal valid v1 `.index` file with the supplied entries.
    fn make_index_file(entries: &[(u64, u32)]) -> Vec<u8> {
        let mut buf = Vec::new();
        buf.extend_from_slice(&make_sqpack_header(2));
        let index_data_offset = SQPACK_HEADER_SIZE + 1024;
        let index_data_size = u32::try_from(entries.len()).unwrap() * INDEX_ENTRY_SIZE;
        buf.extend_from_slice(&make_index_header(index_data_offset, index_data_size, 1));
        assert_eq!(buf.len(), index_data_offset as usize);
        for (hash, data) in entries {
            buf.extend_from_slice(&hash.to_le_bytes());
            buf.extend_from_slice(&data.to_le_bytes());
            buf.extend_from_slice(&0u32.to_le_bytes()); // padding
        }
        buf
    }

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

    #[test]
    fn read_file_probes_higher_chunks() {
        // Chunk 0 has an empty .index — present, but the lookup misses. Chunk
        // 1 has a .index with a single entry whose v1 key matches
        // hash_index1_key("exd/root.exl"), pointing at dat 0 with a non-zero
        // offset. We don't write the .dat file: the test passes when read_file
        // returns Error::Io (the dat is missing) rather than PathNotFound
        // (which would mean the probe never reached chunk 1).
        let install = TestInstall::new();
        let sqpack_dir = install.path().join("sqpack").join("ffxiv");

        let chunk0_index = sqpack_dir.join("0a0000.win32.index");
        fs::write(&chunk0_index, make_index_file(&[])).expect("write chunk0 .index");

        // Encode an entry whose dat_location is (data_file_id=0, byte_offset>0).
        // data layout: bit 0 = synonym (clear), bits 1..=3 = data_file_id (0),
        // bits 4..=31 = (byte_offset / 8). Use byte_offset = 0x80, so
        // (data & !0xF) = 0x10, raw data word = 0x10.
        let v1_key = hash_index1_key("exd/root.exl");
        let chunk1_index = sqpack_dir.join("0a0001.win32.index");
        fs::write(&chunk1_index, make_index_file(&[(v1_key, 0x10)])).expect("write chunk1 .index");

        let sq = SqPack::open(install.path()).expect("open");
        let err = sq.read_file("exd/root.exl").unwrap_err();
        match err {
            // Hit on chunk 1, then opening the missing .dat0 fails.
            Error::Io { path, .. } => {
                let p = path.expect("Io error should carry the dat path");
                assert!(
                    p.ends_with("0a0001.win32.dat0"),
                    "expected probe to reach chunk 1's .dat0, got {p:?}",
                );
            }
            Error::PathNotFound { .. } => {
                panic!("probe stopped at chunk 0 instead of advancing to chunk 1");
            }
            other => panic!("expected Io error opening missing .dat0, got {other:?}"),
        }
    }

    #[test]
    fn read_file_falls_back_to_index2() {
        // No .index at chunk 0 — only .index2 exists. The v2 entry points at
        // dat 0. Same reasoning as above: the missing .dat0 produces Error::Io
        // and that confirms the v2 fallback was actually consulted.
        let install = TestInstall::new();
        let sqpack_dir = install.path().join("sqpack").join("ffxiv");

        let v2_key = hash_index2("exd/root.exl");
        let chunk0_index2 = sqpack_dir.join("0a0000.win32.index2");
        fs::write(&chunk0_index2, make_index2_file(&[(v2_key, 0x10)]))
            .expect("write chunk0 .index2");

        let sq = SqPack::open(install.path()).expect("open");
        let err = sq.read_file("exd/root.exl").unwrap_err();
        match err {
            Error::Io { path, .. } => {
                let p = path.expect("Io error should carry the dat path");
                assert!(
                    p.ends_with("0a0000.win32.dat0"),
                    "expected v2 hit to resolve to chunk 0's .dat0, got {p:?}",
                );
            }
            Error::PathNotFound { .. } => {
                panic!("v2-only chunk did not fire — fallback path is broken");
            }
            other => panic!("expected Io error opening missing .dat0, got {other:?}"),
        }
    }

    // ----- read_file: synonym-table resolution -----

    /// Build an index buffer with one synonym-flagged main-table entry and
    /// `synonyms` populated synonym records (sentinel auto-appended).
    fn make_index_file_with_synonyms_for_test(
        v1_key: u64,
        synonyms: &[(u64, u32, u32, &str)],
    ) -> Vec<u8> {
        // Reuse the same layout shape as the index module's test helper but
        // inlined here so this test file doesn't reach across into module
        // internals.
        const SYN_ENTRY_SIZE: u32 = 256;
        let index_data_offset = SQPACK_HEADER_SIZE + 1024;
        // One main-table entry, synonym-flagged.
        let index_data_size = INDEX_ENTRY_SIZE;
        let synonym_data_offset = index_data_offset + index_data_size;
        let synonym_records = u32::try_from(synonyms.len()).unwrap() + 1;
        let synonym_data_size = synonym_records * SYN_ENTRY_SIZE;

        let mut buf = Vec::new();
        buf.extend_from_slice(&make_sqpack_header(2));
        // Hand-build index header with synonym fields populated.
        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(&index_data_size.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(&synonym_data_size.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.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);

        // Synonym-flagged main-table entry: data low bit set.
        buf.extend_from_slice(&v1_key.to_le_bytes());
        buf.extend_from_slice(&0x0000_0001u32.to_le_bytes());
        buf.extend_from_slice(&0u32.to_le_bytes()); // padding

        // Synonym records.
        for (hash, data, conflict_index, path) in synonyms {
            let mut entry = [0u8; 256];
            entry[0..8].copy_from_slice(&hash.to_le_bytes());
            entry[8..12].copy_from_slice(&data.to_le_bytes());
            entry[12..16].copy_from_slice(&conflict_index.to_le_bytes());
            let path_bytes = path.as_bytes();
            let n = path_bytes.len().min(240);
            entry[16..16 + n].copy_from_slice(&path_bytes[..n]);
            buf.extend_from_slice(&entry);
        }
        // Sentinel.
        let mut sentinel = [0u8; 256];
        sentinel[0..8].copy_from_slice(&u64::MAX.to_le_bytes());
        sentinel[12..16].copy_from_slice(&u32::MAX.to_le_bytes());
        buf.extend_from_slice(&sentinel);

        buf
    }

    #[test]
    fn read_file_synonym_entry_resolves_via_synonym_table() {
        // Build a .index with one synonym-flagged main-table entry plus a
        // matching synonym record for "exd/root.exl". The chunk probe
        // resolves through the synonym table — the lookup yields a real
        // DatLocation, and the only failure mode we expect here is the
        // dat-file open (we don't write a .dat0). That confirms the
        // lookup made it past the synonym redirect rather than aborting
        // at the synonym bit.
        let install = TestInstall::new();
        let sqpack_dir = install.path().join("sqpack").join("ffxiv");

        let v1_key = hash_index1_key("exd/root.exl");
        let chunk0_index = sqpack_dir.join("0a0000.win32.index");
        // Synonym record carries data = 0x10 → byte_offset 0x80, dat0.
        fs::write(
            &chunk0_index,
            make_index_file_with_synonyms_for_test(v1_key, &[(v1_key, 0x10, 1, "exd/root.exl")]),
        )
        .expect("write chunk0 .index");

        let sq = SqPack::open(install.path()).expect("open");
        let err = sq.read_file("exd/root.exl").unwrap_err();
        match err {
            // Synonym resolution succeeded; the dat-file open is what fails.
            Error::Io { path, .. } => {
                let p = path.expect("Io error should carry the dat path");
                assert!(
                    p.ends_with("0a0000.win32.dat0"),
                    "synonym resolution should target chunk 0's .dat0, got {p:?}",
                );
            }
            Error::PathNotFound { .. } => {
                panic!("synonym record matched the path; lookup should not return PathNotFound");
            }
            other => panic!("expected Io error opening missing .dat0, got {other:?}"),
        }
    }

    #[test]
    fn read_file_synonym_resolution_uses_normalised_path_for_byte_compare() {
        // Regression: the synonym record's stored path is normalised
        // (lowercase ASCII, `/` separators), so the resolver's byte-for-byte
        // compare must run against the normalised form of the caller's
        // input — not the raw input. A caller passing the same logical path
        // in any of three shapes ("exd/root.exl", "EXD/Root.EXL",
        // "EXD\\Root.exl") must all resolve through the same synonym record.
        // Prior to this fix, the upper-case + backslash variants tripped a
        // silent miss inside resolve_synonym and fell through to PathNotFound.
        let install = TestInstall::new();
        let sqpack_dir = install.path().join("sqpack").join("ffxiv");

        // Build the index once and let three call shapes probe it.
        let v1_key = hash_index1_key("exd/root.exl");
        let chunk0_index = sqpack_dir.join("0a0000.win32.index");
        fs::write(
            &chunk0_index,
            make_index_file_with_synonyms_for_test(v1_key, &[(v1_key, 0x10, 1, "exd/root.exl")]),
        )
        .expect("write chunk0 .index");

        let sq = SqPack::open(install.path()).expect("open");

        // All three shapes must reach the synonym record and then fail
        // identically on the missing `.dat0` open — the dat-open error path
        // is the witness that resolution succeeded past resolve_synonym.
        for raw in ["exd/root.exl", "EXD/Root.EXL", "EXD\\Root.exl"] {
            let err = sq.read_file(raw).unwrap_err();
            match err {
                Error::Io { path, .. } => {
                    let p = path.expect("Io error should carry the dat path");
                    assert!(
                        p.ends_with("0a0000.win32.dat0"),
                        "synonym resolution for {raw:?} should target chunk 0's .dat0, got {p:?}",
                    );
                }
                Error::PathNotFound { .. } => {
                    panic!(
                        "synonym record matched the normalised path; \
                         lookup for {raw:?} should not return PathNotFound",
                    );
                }
                other => {
                    panic!("expected Io error opening missing .dat0 for {raw:?}, got {other:?}")
                }
            }
        }
    }

    #[test]
    fn read_file_synonym_bit_set_with_no_matching_record_falls_through_to_path_not_found() {
        // Build a .index where the synonym-flagged main-table entry hits
        // (hash matches "exd/root.exl") but the synonym table only holds
        // records for *different* paths. The resolver returns None from
        // resolve_synonym, the chunk probe falls through, and the call
        // ultimately surfaces PathNotFound rather than SynonymUnsupported.
        let install = TestInstall::new();
        let sqpack_dir = install.path().join("sqpack").join("ffxiv");

        let v1_key = hash_index1_key("exd/root.exl");
        let chunk0_index = sqpack_dir.join("0a0000.win32.index");
        fs::write(
            &chunk0_index,
            make_index_file_with_synonyms_for_test(
                v1_key,
                // Same hash, different stored path — resolve_synonym misses.
                &[(v1_key, 0x10, 1, "exd/different.exl")],
            ),
        )
        .expect("write chunk0 .index");

        let sq = SqPack::open(install.path()).expect("open");
        let err = sq.read_file("exd/root.exl").unwrap_err();
        match err {
            Error::PathNotFound { game_path } => {
                assert_eq!(game_path, "exd/root.exl");
            }
            other => panic!("expected PathNotFound, got {other:?}"),
        }
    }

    // ----- read_file: bad/truncated archive -----

    #[test]
    fn read_file_with_truncated_index_surfaces_parse_error() {
        // Place a file at the .index path that starts with valid SqPack magic
        // but is so short it can't possibly contain the full index header.
        // The probe must propagate the parse error rather than treating the
        // file as "no entry found".
        use crate::header::{SQPACK_HEADER_SIZE, SQPACK_MAGIC};
        let install = TestInstall::new();
        let sqpack_dir = install.path().join("sqpack").join("ffxiv");

        let mut garbage = Vec::new();
        garbage.extend_from_slice(&SQPACK_MAGIC); // valid magic
        garbage.push(0); // platform_id
        garbage.extend_from_slice(&[0u8; 3]); // unknown
        garbage.extend_from_slice(&SQPACK_HEADER_SIZE.to_le_bytes()); // size
        garbage.extend_from_slice(&1u32.to_le_bytes()); // version
        garbage.extend_from_slice(&2u32.to_le_bytes()); // file_type = Index
        // Stop here — only 24 bytes, no padding to the declared 1024-byte size.

        let chunk0_index = sqpack_dir.join("0a0000.win32.index");
        fs::write(&chunk0_index, &garbage).expect("write truncated index");

        let sq = SqPack::open(install.path()).expect("open");
        let err = sq.read_file("exd/root.exl").unwrap_err();
        // Must not be PathNotFound — that would imply the file was silently
        // ignored rather than rejected as a parse failure.
        assert!(
            !matches!(err, Error::PathNotFound { .. }),
            "truncated index must surface a parse error, not PathNotFound; got {err:?}"
        );
    }

    // ----- hash + get consistency across hash and index layers -----

    #[test]
    fn hash_and_index_get_are_consistent() {
        // Build an index with a known v1 key, then confirm that
        // hash_index1_key / hash_index1_path / get / get_by_parts all agree.
        // This is a cross-layer pin: the hash module and the index module must
        // compose correctly.
        let v1_key = hash_index1_key("exd/root.exl");
        let (folder_hash, file_hash) = crate::hash::hash_index1_path("exd/root.exl");
        assert_eq!(
            v1_key,
            (u64::from(folder_hash) << 32) | u64::from(file_hash),
            "key packing mismatch"
        );

        let buf = make_index_file(&[(v1_key, 0x0000_0020u32)]);
        let mut cur = std::io::Cursor::new(buf);
        let idx = crate::index::Index::read(&mut cur).unwrap();

        let by_key = idx.get(v1_key).expect("get(v1_key) must hit");
        let by_parts = idx
            .get_by_parts(folder_hash, file_hash)
            .expect("get_by_parts must hit");

        assert_eq!(
            by_key, by_parts,
            "get and get_by_parts must return the same entry"
        );
        // Confirm the entry is not a synonym.
        assert!(!by_key.is_synonym());
        assert!(by_key.dat_location().is_some());
    }

    // ----- Send + Sync contract -----

    #[test]
    fn sqpack_is_send_and_sync() {
        // `SqPack` is intended to be freely shared across threads (the v0
        // contract is lock-free because every read_file re-opens the
        // archives). This locks the contract at compile time so a future
        // change that introduces a non-Send/Sync interior field will fail
        // here rather than at a distant consumer's call site.
        fn assert_send_sync<T: Send + Sync>() {}
        assert_send_sync::<SqPack>();
    }

    #[test]
    fn format_types_are_send_and_sync() {
        // The format-layer types hold no non-Send/Sync state (no Rc, no raw
        // pointers, no RefCell). Pin that invariant here so a future change
        // that introduces interior-mutability in the format layer becomes
        // visible immediately.
        fn assert_send_sync<T: Send + Sync>() {}
        assert_send_sync::<crate::Index>();
        assert_send_sync::<crate::Index2>();
        // Error must also be Send + Sync so it propagates across thread
        // boundaries (e.g. rayon, tokio::spawn_blocking).
        assert_send_sync::<crate::Error>();
    }

    #[test]
    fn streaming_types_are_send() {
        // Pin Send/Sync bounds for the new streaming and enumeration types.
        //
        // SqPackFileStream owns a DatFile internally (BufReader<Box<dyn VfsRead>>
        // where VfsRead: Read + Seek + Send) — it is Send.
        //
        // DatFileStream<'_> borrows `&mut dyn ReadSeek` where ReadSeek: Read + Seek
        // (no Send bound). It is NOT Send; asserting Send here would not compile.
        // That is the correct and documented contract — it is a borrowed stream
        // tied to its parent DatFile.
        //
        // ArchiveWalker borrows &SqPack (which is Send + Sync) and holds only
        // Copy primitive fields — it is Send + Sync.
        //
        // EnumeratedArchive owns Index / Index2 which are Send + Sync.
        fn assert_send<T: Send>() {}
        fn assert_send_sync<T: Send + Sync>() {}

        // SqPackFileStream owns all its state — Send only (not Sync because of
        // the mutable reader position).
        assert_send::<SqPackFileStream>();
        // ArchiveWalker is an immutable view — Send + Sync.
        assert_send_sync::<ArchiveWalker<'_>>();
        // EnumeratedArchive owns Index / Index2 which are Send + Sync.
        assert_send_sync::<EnumeratedArchive>();
    }

    // ----- archive enumeration -----

    #[test]
    fn enumerate_archives_empty_install_yields_nothing() {
        // An install with no `.index*` archives must walk every known triple
        // without ever opening a file — the iterator drains to None.
        let install = TestInstall::new();
        let sq = SqPack::open(install.path()).expect("open");
        let collected: Vec<_> = sq.enumerate_archives().collect();
        assert!(
            collected.is_empty(),
            "expected no archives on an empty install, got {} items",
            collected.len(),
        );
    }

    #[test]
    fn enumerate_archives_yields_present_v1_archive() {
        // One .index in chunk 0 of category exd, expansion 0 — the walker
        // should yield exactly one EnumeratedArchive::V1.
        let install = TestInstall::new();
        let sqpack_dir = install.path().join("sqpack").join("ffxiv");
        let v1_key = hash_index1_key("exd/root.exl");
        let chunk0_index = sqpack_dir.join("0a0000.win32.index");
        fs::write(&chunk0_index, make_index_file(&[(v1_key, 0x10)])).expect("write .index");

        let sq = SqPack::open(install.path()).expect("open");
        let archives: Vec<_> = sq.enumerate_archives().collect();
        assert_eq!(archives.len(), 1, "expected exactly one yielded archive");
        match archives.into_iter().next().unwrap() {
            Ok(EnumeratedArchive::V1(triple, idx)) => {
                assert_eq!(triple.category.id(), 0x0a);
                assert_eq!(triple.expansion, 0);
                assert_eq!(triple.chunk, 0);
                assert_eq!(idx.len(), 1);
                assert!(idx.get(v1_key).is_some());
            }
            other => panic!("expected V1, got {other:?}"),
        }
    }

    #[test]
    fn enumerate_archives_yields_both_when_v1_and_v2_present() {
        // Both .index and .index2 for the same triple — the walker emits two
        // items at that triple, V1 first then V2.
        let install = TestInstall::new();
        let sqpack_dir = install.path().join("sqpack").join("ffxiv");

        let v1_key = hash_index1_key("exd/root.exl");
        let v2_key = hash_index2("exd/root.exl");
        fs::write(
            sqpack_dir.join("0a0000.win32.index"),
            make_index_file(&[(v1_key, 0x10)]),
        )
        .expect("write .index");
        fs::write(
            sqpack_dir.join("0a0000.win32.index2"),
            make_index2_file(&[(v2_key, 0x10)]),
        )
        .expect("write .index2");

        let sq = SqPack::open(install.path()).expect("open");
        let archives: Vec<_> = sq
            .enumerate_archives()
            .collect::<Result<Vec<_>>>()
            .expect("all archives parse cleanly");
        assert_eq!(archives.len(), 2, "expected V1 + V2 for the same triple");
        assert!(matches!(archives[0], EnumeratedArchive::V1(_, _)));
        assert!(matches!(archives[1], EnumeratedArchive::V2(_, _)));
        assert_eq!(archives[0].triple(), archives[1].triple());
    }

    #[test]
    fn enumerate_archives_stops_chunk_probe_on_gap() {
        // chunk 0 .index exists but chunk 1 is absent. The walker must NOT
        // continue probing chunks 2..=7 once chunk 1 is missing — same
        // early-break invariant as read_file's chunk probe. Verified by
        // hand-counting: only the chunk-0 archive should be yielded for this
        // (category, expansion) pair.
        let install = TestInstall::new();
        let sqpack_dir = install.path().join("sqpack").join("ffxiv");
        fs::write(sqpack_dir.join("0a0000.win32.index"), make_index_file(&[]))
            .expect("write chunk0 .index");

        let sq = SqPack::open(install.path()).expect("open");
        let count = sq.enumerate_archives().count();
        assert_eq!(count, 1, "expected single chunk-0 yield, got {count}");
    }

    #[test]
    fn enumerate_archives_includes_higher_chunks_until_first_gap() {
        // chunk 0 and chunk 1 both present — both yielded. Chunk 2 absent
        // breaks the probe; chunks 3..=7 are NOT opened or yielded.
        let install = TestInstall::new();
        let sqpack_dir = install.path().join("sqpack").join("ffxiv");
        fs::write(sqpack_dir.join("0a0000.win32.index"), make_index_file(&[]))
            .expect("write chunk0");
        fs::write(sqpack_dir.join("0a0001.win32.index"), make_index_file(&[]))
            .expect("write chunk1");

        let sq = SqPack::open(install.path()).expect("open");
        let archives: Vec<_> = sq
            .enumerate_archives()
            .collect::<Result<Vec<_>>>()
            .expect("all archives parse");
        assert_eq!(archives.len(), 2);
        assert_eq!(archives[0].triple().chunk, 0);
        assert_eq!(archives[1].triple().chunk, 1);
    }

    #[test]
    fn enumerate_archives_surfaces_parse_errors() {
        // A garbage .index file — the walker must surface the parse Err
        // rather than skip it silently. Closes the audit loophole where a
        // corrupt archive disappears from an integrity scan.
        use crate::header::SQPACK_MAGIC;
        let install = TestInstall::new();
        let sqpack_dir = install.path().join("sqpack").join("ffxiv");

        let mut garbage = Vec::new();
        garbage.extend_from_slice(&SQPACK_MAGIC);
        garbage.push(0);
        garbage.extend_from_slice(&[0u8; 3]);
        garbage.extend_from_slice(&SQPACK_HEADER_SIZE.to_le_bytes());
        garbage.extend_from_slice(&1u32.to_le_bytes());
        garbage.extend_from_slice(&2u32.to_le_bytes());
        fs::write(sqpack_dir.join("0a0000.win32.index"), &garbage).expect("write garbage");

        let sq = SqPack::open(install.path()).expect("open");
        let archives: Vec<_> = sq.enumerate_archives().collect();
        assert_eq!(archives.len(), 1);
        assert!(
            archives[0].is_err(),
            "expected parse Err, got {:?}",
            archives[0]
        );
    }

    // ----- Index::iter (Feature 1, slice-level) -----

    #[test]
    fn index_iter_yields_same_entries_as_slice() {
        // Index::iter() must yield identical entries to Index::entries().iter()
        // in the same order — the contract is "on-disk order, stable per file".
        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 = std::io::Cursor::new(buf);
        let idx = crate::index::Index::read(&mut cur).unwrap();
        let via_iter: Vec<_> = idx.iter().collect();
        let via_slice: Vec<_> = idx.entries().iter().collect();
        assert_eq!(via_iter, via_slice);
        assert_eq!(via_iter.len(), 3);
    }

    #[test]
    fn index_iter_is_exact_size() {
        // ExactSizeIterator carries the entry count up front for capacity
        // hints in downstream collectors.
        let buf = make_index_file(&[(0x1u64, 0x20u32), (0x2u64, 0x40u32)]);
        let mut cur = std::io::Cursor::new(buf);
        let idx = crate::index::Index::read(&mut cur).unwrap();
        let iter = idx.iter();
        assert_eq!(iter.len(), 2);
        assert_eq!(iter.size_hint(), (2, Some(2)));
    }

    #[test]
    fn index_into_iter_in_for_loop() {
        // The IntoIterator impl lets `for entry in &idx` work without an
        // explicit .iter() call — quality-of-life for the integrity scan use
        // case.
        let buf = make_index_file(&[(0x1u64, 0x20u32), (0x2u64, 0x40u32)]);
        let mut cur = std::io::Cursor::new(buf);
        let idx = crate::index::Index::read(&mut cur).unwrap();
        let mut seen = 0usize;
        for _entry in &idx {
            seen += 1;
        }
        assert_eq!(seen, 2);
    }

    #[test]
    fn index2_iter_yields_same_entries_as_slice() {
        let entries = [
            (0x1111_2222u32, 0x0000_0020u32),
            (0x3333_4444u32, 0x0000_0040u32),
        ];
        let buf = make_index2_file(&entries);
        let mut cur = std::io::Cursor::new(buf);
        let idx = crate::index2::Index2::read(&mut cur).unwrap();
        let via_iter: Vec<_> = idx.iter().collect();
        let via_slice: Vec<_> = idx.entries().iter().collect();
        assert_eq!(via_iter, via_slice);
    }

    #[test]
    fn index2_into_iter_in_for_loop() {
        let buf = make_index2_file(&[(0x1u32, 0x20u32), (0x2u32, 0x40u32)]);
        let mut cur = std::io::Cursor::new(buf);
        let idx = crate::index2::Index2::read(&mut cur).unwrap();
        let mut seen = 0usize;
        for _entry in &idx {
            seen += 1;
        }
        assert_eq!(seen, 2);
    }

    // ----- Feature 2: SqPack-level peek_kind / read_file_typed end-to-end -----

    /// Build a complete `.dat0` buffer with a Standard payload at offset
    /// `SQPACK_HEADER_SIZE`. Returns the bytes plus the byte offset of the
    /// `FileInfo` (which is also the value the `IndexEntry` should point at).
    fn make_standard_dat_for_sqpack(payload: &[u8]) -> Vec<u8> {
        // Mirrors dat.rs::tests::make_standard_dat for a single uncompressed
        // block, inlined here so the test module stays self-contained.
        let mut buf = make_sqpack_header(1); // file_type = 1 = Dat
        // FileInfo: size = 24 + 8 (one BlockInfo), kind = 2 (Standard),
        // raw_file_size = payload.len(), number_of_blocks = 1.
        let raw_file_size = u32::try_from(payload.len()).unwrap();
        buf.extend_from_slice(&32u32.to_le_bytes()); // size
        buf.extend_from_slice(&2u32.to_le_bytes()); // kind_raw = Standard
        buf.extend_from_slice(&raw_file_size.to_le_bytes());
        buf.extend_from_slice(&0u32.to_le_bytes()); // unknown[0]
        buf.extend_from_slice(&0u32.to_le_bytes()); // unknown[1]
        buf.extend_from_slice(&1u32.to_le_bytes()); // number_of_blocks
        // BlockInfo: offset=0, compressed_size=16+payload.len() (all-in),
        // uncompressed_size=payload.len().
        let compressed_size = u16::try_from(16 + payload.len()).unwrap();
        let uncompressed_size = u16::try_from(payload.len()).unwrap();
        buf.extend_from_slice(&0u32.to_le_bytes()); // offset
        buf.extend_from_slice(&compressed_size.to_le_bytes());
        buf.extend_from_slice(&uncompressed_size.to_le_bytes());
        // BlockHeader at offset 0 of block-data section: 16 bytes.
        buf.extend_from_slice(&16u32.to_le_bytes()); // size
        buf.extend_from_slice(&0u32.to_le_bytes()); // _unknown
        buf.extend_from_slice(&crate::dat::BLOCK_TYPE_UNCOMPRESSED.to_le_bytes());
        buf.extend_from_slice(&u32::from(uncompressed_size).to_le_bytes());
        buf.extend_from_slice(payload);
        buf
    }

    /// Build a `.dat0` with a Model-kind payload at offset
    /// `SQPACK_HEADER_SIZE`. The body is the minimal valid Model layout:
    /// a 208-byte `ModelHeader` declaring one populated stack-section
    /// block (one entry in the trailing u16 compressed-size table),
    /// followed by a single uncompressed-sentinel block at the start of
    /// the block-data region.
    fn build_model_payload() -> Vec<u8> {
        // 13 bytes of opaque "model block" payload.
        let payload: &[u8] = b"a model file";
        #[allow(clippy::cast_possible_truncation)]
        let payload_len = payload.len() as u32;
        let header_size: u32 = crate::dat::MODEL_HEADER_SIZE + 2; // 1 u16 entry

        let mut buf = make_sqpack_header(1);

        // ModelHeader fields (208 bytes total).
        buf.extend_from_slice(&header_size.to_le_bytes()); // size
        buf.extend_from_slice(&3u32.to_le_bytes()); // type = Model
        buf.extend_from_slice(&payload_len.to_le_bytes()); // raw_file_size
        buf.extend_from_slice(&1u32.to_le_bytes()); // number_of_blocks
        buf.extend_from_slice(&1u32.to_le_bytes()); // used_number_of_blocks
        buf.extend_from_slice(&0u32.to_le_bytes()); // version
        buf.extend_from_slice(&0u32.to_le_bytes()); // stack_size
        buf.extend_from_slice(&0u32.to_le_bytes()); // runtime_size
        for _ in 0..9 {
            buf.extend_from_slice(&0u32.to_le_bytes()); // vertex/edge/index sizes
        }
        buf.extend_from_slice(&0u32.to_le_bytes()); // compressed_stack_memory_size
        buf.extend_from_slice(&0u32.to_le_bytes()); // compressed_runtime_memory_size
        for _ in 0..9 {
            buf.extend_from_slice(&0u32.to_le_bytes()); // compressed vertex/edge/index sizes
        }
        buf.extend_from_slice(&0u32.to_le_bytes()); // stack_offset
        buf.extend_from_slice(&0u32.to_le_bytes()); // runtime_offset
        for _ in 0..9 {
            buf.extend_from_slice(&0u32.to_le_bytes()); // vertex/edge/index offsets
        }
        buf.extend_from_slice(&0u16.to_le_bytes()); // stack_block_index
        buf.extend_from_slice(&0u16.to_le_bytes()); // runtime_block_index
        for _ in 0..9 {
            buf.extend_from_slice(&0u16.to_le_bytes()); // vertex/edge/index block_index
        }
        buf.extend_from_slice(&1u16.to_le_bytes()); // stack_block_num = 1
        buf.extend_from_slice(&0u16.to_le_bytes()); // runtime_block_num
        for _ in 0..9 {
            buf.extend_from_slice(&0u16.to_le_bytes()); // vertex/edge/index block_num
        }
        buf.extend_from_slice(&0u16.to_le_bytes()); // vertex_declaration_num
        buf.extend_from_slice(&0u16.to_le_bytes()); // material_num
        buf.push(0); // num_lods
        buf.push(0); // index_buffer_streaming_enabled
        buf.push(0); // edge_geometry_enabled
        buf.push(0); // padding

        // Per-block compressed-size table: one u16 entry = header(16) + payload.
        let all_in = u16::try_from(crate::dat::BLOCK_HEADER_SIZE as usize + payload.len()).unwrap();
        buf.extend_from_slice(&all_in.to_le_bytes());

        // Block-data section: BlockHeader (uncompressed) + payload bytes.
        buf.extend_from_slice(&16u32.to_le_bytes()); // BlockHeader.size
        buf.extend_from_slice(&0u32.to_le_bytes()); // BlockHeader._unknown
        buf.extend_from_slice(&crate::dat::BLOCK_TYPE_UNCOMPRESSED.to_le_bytes());
        buf.extend_from_slice(&payload_len.to_le_bytes()); // BlockHeader.block_data_size
        buf.extend_from_slice(payload);

        buf
    }

    #[test]
    fn sqpack_read_file_typed_yields_standard() {
        // Full happy path: write a .index with a single entry pointing at a
        // Standard payload in a real .dat0 file. SqPack::read_file_typed
        // routes the path, opens the .dat, and returns SqPackPayload::Standard.
        let install = TestInstall::new();
        let sqpack_dir = install.path().join("sqpack").join("ffxiv");

        let payload = b"typed-read-payload".to_vec();
        let dat_buf = make_standard_dat_for_sqpack(&payload);
        let info_off = u64::from(crate::header::SQPACK_HEADER_SIZE);
        // Encode the IndexEntry: synonym=0, data_file_id=0,
        // byte_offset=info_off → data = info_off / 8.
        #[allow(clippy::cast_possible_truncation)]
        let data = (info_off / 8) as u32;

        let v1_key = hash_index1_key("exd/root.exl");
        fs::write(
            sqpack_dir.join("0a0000.win32.index"),
            make_index_file(&[(v1_key, data)]),
        )
        .expect("write .index");
        fs::write(sqpack_dir.join("0a0000.win32.dat0"), &dat_buf).expect("write .dat0");

        let sq = SqPack::open(install.path()).expect("open");
        let p = sq.read_file_typed("exd/root.exl").expect("read");
        match p {
            SqPackPayload::Standard(bytes) => assert_eq!(bytes, payload),
            other => panic!("expected Standard, got {other:?}"),
        }
    }

    #[test]
    fn sqpack_peek_kind_returns_standard() {
        // Same fixture as the read_file_typed test; peek_kind opens the .dat
        // and the .index but does not decode the body — the assertion is the
        // returned FileKind only.
        let install = TestInstall::new();
        let sqpack_dir = install.path().join("sqpack").join("ffxiv");

        let payload = b"k".repeat(8);
        let dat_buf = make_standard_dat_for_sqpack(&payload);
        let info_off = u64::from(crate::header::SQPACK_HEADER_SIZE);
        #[allow(clippy::cast_possible_truncation)]
        let data = (info_off / 8) as u32;

        let v1_key = hash_index1_key("exd/root.exl");
        fs::write(
            sqpack_dir.join("0a0000.win32.index"),
            make_index_file(&[(v1_key, data)]),
        )
        .expect("write .index");
        fs::write(sqpack_dir.join("0a0000.win32.dat0"), &dat_buf).expect("write .dat0");

        let sq = SqPack::open(install.path()).expect("open");
        let kind = sq.peek_kind("exd/root.exl").expect("peek");
        assert_eq!(kind, FileKind::Standard);
    }

    #[test]
    fn sqpack_read_file_typed_yields_model_variant() {
        // Model payload through the full SqPack pipeline — confirms the
        // Model variant survives the chunk-probe + DatFile dispatch and
        // is decoded into bytes.
        let install = TestInstall::new();
        let sqpack_dir = install.path().join("sqpack").join("ffxiv");

        let dat_buf = build_model_payload();
        let info_off = u64::from(crate::header::SQPACK_HEADER_SIZE);
        #[allow(clippy::cast_possible_truncation)]
        let data = (info_off / 8) as u32;

        let v1_key = hash_index1_key("exd/root.exl");
        fs::write(
            sqpack_dir.join("0a0000.win32.index"),
            make_index_file(&[(v1_key, data)]),
        )
        .expect("write .index");
        fs::write(sqpack_dir.join("0a0000.win32.dat0"), &dat_buf).expect("write .dat0");

        let sq = SqPack::open(install.path()).expect("open");
        let p = sq.read_file_typed("exd/root.exl").expect("read");
        match p {
            SqPackPayload::Model(m) => {
                assert_eq!(m.file_info.kind(), FileKind::Model);
                assert_eq!(m.bytes, b"a model file");
            }
            other => panic!("expected Model, got {other:?}"),
        }
        // read_file now decodes Model as well — same byte view as the
        // typed-payload variant exposes.
        let bytes = sq.read_file("exd/root.exl").expect("read");
        assert_eq!(bytes, b"a model file");
    }

    #[test]
    fn sqpack_peek_kind_returns_model_for_undecoded_payload() {
        let install = TestInstall::new();
        let sqpack_dir = install.path().join("sqpack").join("ffxiv");

        let dat_buf = build_model_payload();
        let info_off = u64::from(crate::header::SQPACK_HEADER_SIZE);
        #[allow(clippy::cast_possible_truncation)]
        let data = (info_off / 8) as u32;

        let v1_key = hash_index1_key("exd/root.exl");
        fs::write(
            sqpack_dir.join("0a0000.win32.index"),
            make_index_file(&[(v1_key, data)]),
        )
        .expect("write .index");
        fs::write(sqpack_dir.join("0a0000.win32.dat0"), &dat_buf).expect("write .dat0");

        let sq = SqPack::open(install.path()).expect("open");
        let kind = sq.peek_kind("exd/root.exl").expect("peek");
        assert_eq!(kind, FileKind::Model);
    }

    #[test]
    fn sqpack_peek_kind_unknown_path_is_path_not_found() {
        let install = TestInstall::new();
        let sq = SqPack::open(install.path()).expect("open");
        let err = sq.peek_kind("exd/root.exl").unwrap_err();
        assert!(matches!(err, Error::PathNotFound { .. }));
    }

    // ----- Feature 3: SqPack-level streaming -----

    #[test]
    fn sqpack_read_file_streaming_yields_standard_blocks() {
        // Full happy path: write a .index pointing at a Standard payload in
        // a .dat0 fixture, open a SqPackFileStream, drain it, concatenate
        // the chunks, and confirm the output matches the original payload.
        let install = TestInstall::new();
        let sqpack_dir = install.path().join("sqpack").join("ffxiv");

        let payload = b"stream-me-please".repeat(8); // 128 bytes
        let dat_buf = make_standard_dat_for_sqpack(&payload);
        let info_off = u64::from(crate::header::SQPACK_HEADER_SIZE);
        #[allow(clippy::cast_possible_truncation)]
        let data = (info_off / 8) as u32;

        let v1_key = hash_index1_key("exd/root.exl");
        fs::write(
            sqpack_dir.join("0a0000.win32.index"),
            make_index_file(&[(v1_key, data)]),
        )
        .expect("write .index");
        fs::write(sqpack_dir.join("0a0000.win32.dat0"), &dat_buf).expect("write .dat0");

        let sq = SqPack::open(install.path()).expect("open");
        let stream = sq.read_file_streaming("exd/root.exl").expect("open stream");
        assert_eq!(stream.kind(), FileKind::Standard);
        assert_eq!(stream.block_count(), 1);
        assert_eq!(
            stream.raw_file_size(),
            u32::try_from(payload.len()).unwrap()
        );

        let mut concatenated = Vec::new();
        for chunk in stream {
            let chunk = chunk.unwrap();
            concatenated.extend_from_slice(&chunk.bytes);
        }
        assert_eq!(concatenated, payload);
    }

    #[test]
    fn sqpack_read_file_streaming_rejects_model_payload() {
        let install = TestInstall::new();
        let sqpack_dir = install.path().join("sqpack").join("ffxiv");

        let dat_buf = build_model_payload();
        let info_off = u64::from(crate::header::SQPACK_HEADER_SIZE);
        #[allow(clippy::cast_possible_truncation)]
        let data = (info_off / 8) as u32;

        let v1_key = hash_index1_key("exd/root.exl");
        fs::write(
            sqpack_dir.join("0a0000.win32.index"),
            make_index_file(&[(v1_key, data)]),
        )
        .expect("write .index");
        fs::write(sqpack_dir.join("0a0000.win32.dat0"), &dat_buf).expect("write .dat0");

        let sq = SqPack::open(install.path()).expect("open");
        let err = sq.read_file_streaming("exd/root.exl").unwrap_err();
        assert!(matches!(
            err,
            Error::UnsupportedFileKind {
                kind: FileKind::Model
            }
        ));
    }

    #[test]
    fn sqpack_read_file_streaming_unknown_path_is_path_not_found() {
        let install = TestInstall::new();
        let sq = SqPack::open(install.path()).expect("open");
        let err = sq.read_file_streaming("exd/root.exl").unwrap_err();
        assert!(matches!(err, Error::PathNotFound { .. }));
    }

    // ----- path_present_in_loaded: synonym-aware probe helper -----

    #[cfg(feature = "path-list")]
    #[test]
    fn path_present_in_loaded_synonym_match_is_hit() {
        // The shared helper must consult the synonym table when the
        // main-table entry has the synonym bit set, and report a hit
        // only when the synonym record actually matches the supplied
        // normalised path.
        use std::io::Cursor;
        let v1_key = hash_index1_key("exd/root.exl");
        // Synonym data = 0x10 → byte_offset = 0x80, dat0. Path
        // stored normalised (lowercase, '/').
        let bytes =
            make_index_file_with_synonyms_for_test(v1_key, &[(v1_key, 0x10, 1, "exd/root.exl")]);
        let mut cur = Cursor::new(bytes);
        let idx = Index::read(&mut cur).expect("parse synonym index");

        let v2_key = hash_index2("exd/root.exl");
        assert!(
            path_present_in_loaded(Some(&idx), None, "exd/root.exl", v1_key, v2_key),
            "synonym record matches the supplied path; helper must report hit",
        );
    }

    #[cfg(feature = "path-list")]
    #[test]
    fn path_present_in_loaded_synonym_miss_is_not_hit() {
        // Bug #3 regression: the iterator used to short-circuit on
        // `idx.get(v1_key).is_some()` without consulting the synonym
        // table. A synonym-flagged entry whose only synonym record
        // points at a DIFFERENT path that hashes to the same v1 key
        // would have been falsely reported as a hit.
        use std::io::Cursor;
        // Caller probes for path A; the index's only synonym record
        // is for a different path B that happens to hash to the same
        // v1 key. The helper must NOT report a hit on A.
        let v1_key = 0x1234_5678_9abc_def0u64;
        let bytes = make_index_file_with_synonyms_for_test(
            v1_key,
            &[(v1_key, 0x10, 1, "exd/some_other_path.exh")],
        );
        let mut cur = Cursor::new(bytes);
        let idx = Index::read(&mut cur).expect("parse synonym index");

        let v2_key = 0xdead_beefu32;
        assert!(
            !path_present_in_loaded(
                Some(&idx),
                None,
                "exd/never_present_under_this_hash.exl",
                v1_key,
                v2_key,
            ),
            "synonym record's stored path does not match; helper must NOT report hit",
        );
    }

    #[cfg(feature = "path-list")]
    #[test]
    fn path_present_in_loaded_v2_only_hit() {
        // Sanity: with no v1 index and a v2 entry present, the
        // helper reports a hit via the v2 path.
        use std::io::Cursor;
        let v2_key = hash_index2("exd/root.exl");
        let data = 0x0000_0010u32; // byte_offset = 0x80, dat0
        let bytes = make_index2_file(&[(v2_key, data)]);
        let mut cur = Cursor::new(bytes);
        let idx2 = Index2::read(&mut cur).expect("parse v2 index");

        assert!(path_present_in_loaded(
            None,
            Some(&idx2),
            "exd/root.exl",
            hash_index1_key("exd/root.exl"),
            v2_key,
        ));
    }

    #[cfg(feature = "path-list")]
    #[test]
    fn path_present_in_loaded_both_none_is_not_hit() {
        // No archives loaded — helper must return false rather than
        // panicking. (`known_paths` calls this when a chunk has
        // neither .index nor .index2 — the calling site already
        // short-circuits, but the helper itself should be total.)
        assert!(!path_present_in_loaded(None, None, "exd/root.exl", 0, 0));
    }
}
