//! Category and physical-path routing for game-relative paths.
//!
//! Maps a game path like `exd/root.exl` to a physical filename like
//! `sqpack/ffxiv/0a0000.win32.index` under an install root.
//!
//! The mapping
//! -----------
//!
//! Every game-relative path resolves to an `(category, expansion, chunk)`
//! triple — see [`ArchiveTriple`]. That triple determines:
//!
//! - a repository folder (`ffxiv`, `ex1`, `ex2`, …) selected by `expansion`,
//! - a 6-hex-digit file stem `{cat:02x}{exp:02x}{chunk:02x}` (e.g.
//!   `0a0000` for `(exd, 0, 0)`),
//! - a platform-suffixed extension (`.win32.index`, `.win32.dat0`, …).
//!
//! Categories are derived from the first path segment after normalisation
//! (lowercase, `\` → `/`); see [`Category::try_from_name`] for the full
//! lowercase-name → wire-id table. Unknown categories surface as
//! `try_from_name` returning `None` (or [`ArchiveTriple::from_game_path`]
//! returning `None`).
//!
//! Expansion comes from the second path segment when it matches `ex<N>`
//! (e.g. `ex1`, `ex2`); otherwise `expansion = 0`. The literal token
//! `ffxiv` as a second segment does **not** bump the expansion — it
//! denotes "base game" inside `bg/`-rooted categories.
//!
//! Chunk
//! -----
//!
//! Some categories split their assets across multiple chunks (e.g.
//! `bg/ffxiv` is sliced into chunks 00, 01, 02 by region in Lumina). This
//! iteration always **routes to chunk 0** — `ArchiveTriple::from_game_path`
//! never returns a chunk ≠ 0. The composing layer in [`crate::sqpack`]
//! probes chunks `0..=7` in order if a lookup misses, with an early-break
//! when neither `.index` nor `.index2` exists for the current chunk; that's
//! the v0 contract. Resolving the real per-path chunk mapping is a
//! follow-up.
//!
//! Platform
//! --------
//!
//! Path-construction functions on [`ArchiveTriple`] take a [`Platform`]
//! and return [`Option<PathBuf>`]. The named variants (`Win32`, `Ps3`,
//! `Ps4`) each map to their lowercase platform-tag suffix and produce
//! `Some(PathBuf)`. [`Platform::Unknown`] produces `None` — we don't know
//! the on-disk extension suffix the game itself would have used for an
//! unrecognised platform, so refusing to construct a path is safer than
//! guessing and producing a misleading filename. Callers that want to
//! error on `None` can map it to their own error variant.

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

use crate::hash::normalize;
use crate::header::Platform;

/// Repository folders for `expansion` values `0..=15`.
///
/// Index 0 → `ffxiv` (base game); index N → `exN`. The expansion id field
/// in shipped files has never exceeded 15 (the format reserves 8 bits but
/// only the low nibble has ever been used). Values above 15 are rejected
/// by [`ArchiveTriple::repo_folder`] (which returns `None`); callers that
/// need a wider range can switch to a heap-allocated `String` builder, but
/// the static-string shape is intentional for the common case.
const FOLDERS: [&str; 16] = [
    "ffxiv", "ex1", "ex2", "ex3", "ex4", "ex5", "ex6", "ex7", "ex8", "ex9", "ex10", "ex11", "ex12",
    "ex13", "ex14", "ex15",
];

/// Table of every category recognised by [`Category::try_from_name`].
///
/// The (lowercase-name, wire-id) pairs are kept in lockstep with the match arm
/// in [`Category::try_from_name`]; the test suite asserts that the two are
/// equivalent. Exposed for archive-enumeration code paths (see
/// [`crate::SqPack::enumerate_archives`]) that need to sweep every known
/// category without re-encoding the table.
pub(crate) const KNOWN_CATEGORIES: &[(&str, u8)] = &[
    ("common", 0x00),
    ("bgcommon", 0x01),
    ("bg", 0x02),
    ("cut", 0x03),
    ("chara", 0x04),
    ("shader", 0x05),
    ("ui", 0x06),
    ("sound", 0x07),
    ("vfx", 0x08),
    ("ui_script", 0x09),
    ("exd", 0x0a),
    ("game_script", 0x0b),
    ("music", 0x0c),
    ("sqpack_test", 0x12),
    ("debug", 0x13),
];

/// Decoded category identifier.
///
/// Wraps the wire-format `u8` so the type system can carry the
/// "validated category" guarantee. Construct via
/// [`Category::try_from_name`]; inspect via [`Category::id`].
///
/// # Ordering contract
///
/// The derived `Ord` / `PartialOrd` impls compare by raw wire id — i.e.
/// the same `u8` that [`Category::id`] returns. The wire ids themselves
/// are FFXIV on-disk format constants and are not going to be reassigned
/// for existing categories, so this crate locks the following guarantees
/// into the public stability contract:
///
/// - **Existing categories keep their relative order.** Reordering any
///   of the current 15 known categories (e.g. swapping `bg` and `exd`)
///   would be a SemVer-major change.
/// - **New categories may be added.** Future on-disk additions will
///   slot in at their natural wire-id position — typically at the high-id
///   end (the highest known id today is `0x13` and the format reserves
///   8 bits). Adding a new high-id category is SemVer-minor; adding one
///   with a low id that interleaves with existing categories would be
///   SemVer-major and would not happen without a deliberate breaking
///   release.
///
/// The `Hash` impl is also derived — equal categories hash equal, as
/// required by the `Eq` / `Hash` contract.
///
/// Consumers (notably [`crate::verify::PathVerifyReport::by_category`]) rely
/// on this ordering to render grouped reports in a deterministic
/// wire-id order without re-sorting at the call site.
///
/// # Persistence
///
/// `serde` derives are gated on the `serde` Cargo feature so the hash-
/// keyed manifest in [`crate::verify`] can serialise [`ArchiveTriple`]
/// (which embeds a `Category`). The serialised shape is the inner
/// wire-id `u8` — same value [`Self::id`] returns — making the
/// on-disk format compact and stable.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(docsrs, doc(cfg(feature = "serde")))]
pub struct Category(u8);

impl Category {
    /// Decode a category name (lowercase first path segment) into a typed
    /// [`Category`]. Unknown names return `None`.
    ///
    /// The full known table is:
    ///
    /// | Name           | Id     |
    /// |----------------|--------|
    /// | `common`       | `0x00` |
    /// | `bgcommon`     | `0x01` |
    /// | `bg`           | `0x02` |
    /// | `cut`          | `0x03` |
    /// | `chara`        | `0x04` |
    /// | `shader`       | `0x05` |
    /// | `ui`           | `0x06` |
    /// | `sound`        | `0x07` |
    /// | `vfx`          | `0x08` |
    /// | `ui_script`    | `0x09` |
    /// | `exd`          | `0x0a` |
    /// | `game_script`  | `0x0b` |
    /// | `music`        | `0x0c` |
    /// | `sqpack_test`  | `0x12` |
    /// | `debug`        | `0x13` |
    #[must_use]
    pub fn try_from_name(name: &str) -> Option<Self> {
        let id = match name {
            "common" => 0x00,
            "bgcommon" => 0x01,
            "bg" => 0x02,
            "cut" => 0x03,
            "chara" => 0x04,
            "shader" => 0x05,
            "ui" => 0x06,
            "sound" => 0x07,
            "vfx" => 0x08,
            "ui_script" => 0x09,
            "exd" => 0x0a,
            "game_script" => 0x0b,
            "music" => 0x0c,
            "sqpack_test" => 0x12,
            "debug" => 0x13,
            _ => return None,
        };
        Some(Category(id))
    }

    /// Raw wire-format id (e.g. `0x0a` for `exd`).
    #[must_use]
    pub fn id(self) -> u8 {
        self.0
    }

    /// `pub(crate)` constructor from a known raw id.
    ///
    /// Used by archive-enumeration code paths in [`crate::sqpack`] that walk
    /// [`KNOWN_CATEGORIES`] and need to project each row's `u8` into a typed
    /// [`Category`]. Not exposed publicly: the public construction path is
    /// [`Category::try_from_name`], which carries the validated-name
    /// invariant; this entry point assumes the caller has already validated
    /// the id against the known-categories table.
    #[must_use]
    pub(crate) fn from_known_id(id: u8) -> Self {
        Category(id)
    }

    /// `pub(crate)` sentinel for paths that don't route cleanly.
    ///
    /// Returns a [`Category`] whose [`Category::id`] is `0xFF`. The
    /// format reserves 8 bits for the wire category id; ids above `0x13`
    /// are unused today and `0xFF` is at the very top of the reserved
    /// range. Even if FFXIV were to assign new categories, they would
    /// land at the next free low id (we're at `0x13`) long before the
    /// allocator reaches `0xFF` — so this value is structurally safe as
    /// a sentinel.
    ///
    /// The only legitimate caller is the verifier's
    /// [`crate::verify::PathMismatch`] fallback path, used when
    /// [`ArchiveTriple::from_game_path`] fails to route a manifest entry's
    /// path (e.g. a manifest carrying paths the routing layer no longer
    /// recognises). Do not use this constructor for anything else — it
    /// breaks the "every constructed `Category` corresponds to a known
    /// wire id" invariant that [`Category::try_from_name`] /
    /// [`Category::from_known_id`] uphold.
    #[must_use]
    pub(crate) fn placeholder() -> Self {
        Category(0xFF)
    }
}

/// `(category, expansion, chunk)` triple identifying a single physical
/// `SqPack` archive group.
///
/// Build via [`ArchiveTriple::from_game_path`]; turn it into physical
/// filesystem paths via [`ArchiveTriple::index_path`] / `index2_path` /
/// `dat_path`.
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(docsrs, doc(cfg(feature = "serde")))]
pub struct ArchiveTriple {
    /// Decoded category (first path segment).
    pub category: Category,
    /// Expansion id (`0` = base game, `1` = ex1, …).
    pub expansion: u8,
    /// Chunk id. Always `0` from [`ArchiveTriple::from_game_path`];
    /// [`crate::sqpack`] probes chunks `0..=7` at lookup time, with an
    /// early-break when neither `.index` nor `.index2` exists for the
    /// current chunk.
    pub chunk: u8,
}

impl ArchiveTriple {
    /// Resolve `(category, expansion, chunk = 0)` from a game-relative
    /// path. Returns `None` if the first path segment is not a recognised
    /// category name.
    ///
    /// The path is normalised (lowercased, `\` → `/`) before splitting.
    /// Expansion is taken from the second segment when it parses as
    /// `ex<N>` (e.g. `ex1`, `ex12`); the literal token `ffxiv` is **not**
    /// treated as an expansion bump. The chunk is always `0` — see the
    /// module-level docs.
    #[must_use]
    pub fn from_game_path(path: &str) -> Option<Self> {
        let normalised = normalize(path);
        Self::from_normalized(&normalised)
    }

    /// `pub(crate)` companion to [`Self::from_game_path`] for callers that
    /// have already normalised the path. Used by [`crate::sqpack::SqPack::read_file`]
    /// to share the normalisation buffer between routing and both hash shapes.
    #[must_use]
    pub(crate) fn from_normalized(normalised: &[u8]) -> Option<Self> {
        // Bytes only — the input is ASCII-cased at this point and we never
        // need to interpret multi-byte sequences as separators.
        let mut segments = normalised.split(|&b| b == b'/');
        let cat_seg = segments.next()?;
        // Category names are ASCII so a lossy decode is safe.
        let cat_name = std::str::from_utf8(cat_seg).ok()?;
        let category = Category::try_from_name(cat_name)?;
        let expansion = segments
            .next()
            .and_then(parse_expansion_segment)
            .unwrap_or(0);
        Some(ArchiveTriple {
            category,
            expansion,
            chunk: 0,
        })
    }

    /// Repository folder name for this triple's `expansion` (e.g. `ffxiv`
    /// for `0`, `ex1` for `1`).
    ///
    /// Returns `None` for `expansion > 15`. The FFXIV format reserves 8 bits
    /// but no shipping file has ever used an expansion id outside `0..=15`;
    /// rather than clamp to a wrong-but-existing folder (which would silently
    /// route to the wrong archive), the lookup refuses to construct a path
    /// and propagates `None` through [`Self::index_path`] / [`Self::index2_path`]
    /// / [`Self::dat_path`].
    #[must_use]
    pub fn repo_folder(&self) -> Option<&'static str> {
        FOLDERS.get(self.expansion as usize).copied()
    }

    /// 6-hex-digit file stem: `{cat:02x}{exp:02x}{chunk:02x}`.
    ///
    /// E.g. `(exd = 0x0a, 0, 0)` → `"0a0000"`.
    #[must_use]
    pub fn file_stem(&self) -> String {
        format!(
            "{:02x}{:02x}{:02x}",
            self.category.id(),
            self.expansion,
            self.chunk
        )
    }

    /// Filesystem path of the `.index` file under `install_root`.
    ///
    /// Returns `None` if `platform` is [`Platform::Unknown`] or if
    /// `expansion > 15` (see [`Self::repo_folder`]).
    #[must_use]
    pub fn index_path(&self, install_root: &Path, platform: Platform) -> Option<PathBuf> {
        self.archive_path(install_root, platform, "index", None)
    }

    /// Filesystem path of the `.index2` file under `install_root`.
    ///
    /// Returns `None` if `platform` is [`Platform::Unknown`] or if
    /// `expansion > 15` (see [`Self::repo_folder`]).
    #[must_use]
    pub fn index2_path(&self, install_root: &Path, platform: Platform) -> Option<PathBuf> {
        self.archive_path(install_root, platform, "index2", None)
    }

    /// Filesystem path of the `.dat{dat_id}` companion file under
    /// `install_root`.
    ///
    /// Returns `None` if `platform` is [`Platform::Unknown`] or if
    /// `expansion > 15` (see [`Self::repo_folder`]).
    #[must_use]
    pub fn dat_path(&self, install_root: &Path, platform: Platform, dat_id: u8) -> Option<PathBuf> {
        self.archive_path(install_root, platform, "dat", Some(dat_id))
    }

    fn archive_path(
        self,
        install_root: &Path,
        platform: Platform,
        ext_root: &str,
        dat_id: Option<u8>,
    ) -> Option<PathBuf> {
        let platform_tag = platform_tag(platform)?;
        let repo_folder = self.repo_folder()?;
        let stem = self.file_stem();
        let filename = match dat_id {
            Some(id) => format!("{stem}.{platform_tag}.{ext_root}{id}"),
            None => format!("{stem}.{platform_tag}.{ext_root}"),
        };
        let mut path = install_root.to_path_buf();
        path.push("sqpack");
        path.push(repo_folder);
        path.push(filename);
        Some(path)
    }
}

/// Parse a path segment of the form `ex<N>` into the expansion id `N`.
///
/// Returns `None` if the segment doesn't start with `ex` or the trailing
/// digits don't parse as a `u8`. The literal token `ffxiv` returns `None`
/// — `bg/ffxiv/...` denotes "base game inside bg" and the caller defaults
/// to expansion 0.
fn parse_expansion_segment(seg: &[u8]) -> Option<u8> {
    let rest = seg.strip_prefix(b"ex")?;
    if rest.is_empty() {
        return None;
    }
    let digits = std::str::from_utf8(rest).ok()?;
    digits.parse::<u8>().ok()
}

/// Lowercase platform tag used in physical filenames (`win32`, `ps3`, …).
///
/// Returns `None` for [`Platform::Unknown`] — see the module-level docs.
fn platform_tag(platform: Platform) -> Option<&'static str> {
    match platform {
        Platform::Win32 => Some("win32"),
        Platform::Ps3 => Some("ps3"),
        Platform::Ps4 => Some("ps4"),
        Platform::Unknown(_) => None,
    }
}

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

    // ----- Category lookup -----

    #[test]
    fn category_known_names_map_to_ids() {
        assert_eq!(
            Category::try_from_name("common").map(Category::id),
            Some(0x00)
        );
        assert_eq!(
            Category::try_from_name("bgcommon").map(Category::id),
            Some(0x01)
        );
        assert_eq!(Category::try_from_name("bg").map(Category::id), Some(0x02));
        assert_eq!(Category::try_from_name("cut").map(Category::id), Some(0x03));
        assert_eq!(
            Category::try_from_name("chara").map(Category::id),
            Some(0x04)
        );
        assert_eq!(
            Category::try_from_name("shader").map(Category::id),
            Some(0x05)
        );
        assert_eq!(Category::try_from_name("ui").map(Category::id), Some(0x06));
        assert_eq!(
            Category::try_from_name("sound").map(Category::id),
            Some(0x07)
        );
        assert_eq!(Category::try_from_name("vfx").map(Category::id), Some(0x08));
        assert_eq!(
            Category::try_from_name("ui_script").map(Category::id),
            Some(0x09)
        );
        assert_eq!(Category::try_from_name("exd").map(Category::id), Some(0x0a));
        assert_eq!(
            Category::try_from_name("game_script").map(Category::id),
            Some(0x0b)
        );
        assert_eq!(
            Category::try_from_name("music").map(Category::id),
            Some(0x0c)
        );
        assert_eq!(
            Category::try_from_name("sqpack_test").map(Category::id),
            Some(0x12)
        );
        assert_eq!(
            Category::try_from_name("debug").map(Category::id),
            Some(0x13)
        );
    }

    #[test]
    fn category_unknown_name_is_none() {
        assert!(Category::try_from_name("nope").is_none());
        assert!(Category::try_from_name("").is_none());
        // The lookup is case-sensitive — the routing layer normalises before
        // calling here, but a direct caller with mixed-case input gets None.
        assert!(Category::try_from_name("EXD").is_none());
    }

    // ----- ArchiveTriple::from_game_path -----

    #[test]
    fn triple_exd_root_exl() {
        let t = ArchiveTriple::from_game_path("exd/root.exl").unwrap();
        assert_eq!(t.category.id(), 0x0a);
        assert_eq!(t.expansion, 0);
        assert_eq!(t.chunk, 0);
    }

    #[test]
    fn triple_bg_ex1_is_expansion_one() {
        let t = ArchiveTriple::from_game_path("bg/ex1/wil/wil_w1/level/bg.lgb").unwrap();
        assert_eq!(t.category.id(), 0x02);
        assert_eq!(t.expansion, 1);
    }

    #[test]
    fn triple_bg_ffxiv_does_not_bump_expansion() {
        // The literal token "ffxiv" as the second segment denotes "base game
        // under bg", NOT an expansion id of "0xfffxiv" or similar — expansion
        // stays at 0.
        let t = ArchiveTriple::from_game_path("bg/ffxiv/sea_s1/foo.lgb").unwrap();
        assert_eq!(t.category.id(), 0x02);
        assert_eq!(t.expansion, 0);
    }

    #[test]
    fn triple_music_ex2() {
        let t = ArchiveTriple::from_game_path("music/ex2/bgm_ex2_sca_03.scd").unwrap();
        assert_eq!(t.category.id(), 0x0c);
        assert_eq!(t.expansion, 2);
    }

    #[test]
    fn triple_unknown_category_is_none() {
        assert!(ArchiveTriple::from_game_path("notacat/foo.bin").is_none());
        assert!(ArchiveTriple::from_game_path("").is_none());
    }

    #[test]
    fn triple_normalises_case_and_slashes() {
        let a = ArchiveTriple::from_game_path("EXD/Root.EXL").unwrap();
        let b = ArchiveTriple::from_game_path("exd\\root.exl").unwrap();
        let c = ArchiveTriple::from_game_path("exd/root.exl").unwrap();
        assert_eq!(a, b);
        assert_eq!(b, c);
    }

    #[test]
    fn triple_high_expansion_number() {
        // ex12 is within the 0..=15 supported range.
        let t = ArchiveTriple::from_game_path("bg/ex12/foo.bin").unwrap();
        assert_eq!(t.expansion, 12);
    }

    #[test]
    fn triple_bad_expansion_token_falls_back_to_zero() {
        // "exfoo" doesn't parse as a u8 — expansion defaults to 0.
        let t = ArchiveTriple::from_game_path("bg/exfoo/whatever.bin").unwrap();
        assert_eq!(t.expansion, 0);
    }

    #[test]
    fn triple_single_segment_path_is_expansion_zero() {
        // No second segment → expansion stays at 0.
        let t = ArchiveTriple::from_game_path("exd").unwrap();
        assert_eq!(t.category.id(), 0x0a);
        assert_eq!(t.expansion, 0);
    }

    // ----- file_stem -----

    #[test]
    fn file_stem_format() {
        let t = ArchiveTriple {
            category: Category(0x0a),
            expansion: 0,
            chunk: 0,
        };
        assert_eq!(t.file_stem(), "0a0000");
    }

    #[test]
    fn file_stem_with_chunk_and_expansion() {
        let t = ArchiveTriple {
            category: Category(0x02),
            expansion: 1,
            chunk: 2,
        };
        assert_eq!(t.file_stem(), "020102");
    }

    // ----- repo_folder -----

    #[test]
    fn repo_folder_zero_is_ffxiv() {
        let t = ArchiveTriple {
            category: Category(0),
            expansion: 0,
            chunk: 0,
        };
        assert_eq!(t.repo_folder(), Some("ffxiv"));
    }

    #[test]
    fn repo_folder_one_is_ex1() {
        let t = ArchiveTriple {
            category: Category(0),
            expansion: 1,
            chunk: 0,
        };
        assert_eq!(t.repo_folder(), Some("ex1"));
    }

    #[test]
    fn repo_folder_full_range() {
        for (i, expected) in FOLDERS.iter().enumerate() {
            let t = ArchiveTriple {
                category: Category(0),
                #[allow(clippy::cast_possible_truncation)]
                expansion: i as u8,
                chunk: 0,
            };
            assert_eq!(
                t.repo_folder(),
                Some(*expected),
                "mismatch at expansion {i}",
            );
        }
    }

    #[test]
    fn repo_folder_out_of_range_is_none() {
        // expansion 16 is one past the last shipping value; no folder maps to
        // it, so the lookup returns None rather than clamping to ex15.
        for bad in [16u8, 17, 99, 255] {
            let t = ArchiveTriple {
                category: Category(0),
                expansion: bad,
                chunk: 0,
            };
            assert!(
                t.repo_folder().is_none(),
                "expansion {bad} should yield None",
            );
        }
    }

    #[test]
    fn path_composition_out_of_range_expansion_is_none() {
        // expansion 17 has no repo folder → all three path-builder methods
        // refuse to construct a path rather than silently routing to the
        // wrong archive.
        let t = ArchiveTriple {
            category: Category(0x0a),
            expansion: 17,
            chunk: 0,
        };
        let root = Path::new("/game");
        assert!(t.index_path(root, Platform::Win32).is_none());
        assert!(t.index2_path(root, Platform::Win32).is_none());
        assert!(t.dat_path(root, Platform::Win32, 0).is_none());
    }

    // ----- path composition -----

    #[test]
    fn index_path_composition() {
        let t = ArchiveTriple::from_game_path("exd/root.exl").unwrap();
        let p = t.index_path(Path::new("/game"), Platform::Win32).unwrap();
        assert_eq!(p, PathBuf::from("/game/sqpack/ffxiv/0a0000.win32.index"));
    }

    #[test]
    fn index2_path_composition() {
        let t = ArchiveTriple::from_game_path("exd/root.exl").unwrap();
        let p = t.index2_path(Path::new("/game"), Platform::Win32).unwrap();
        assert_eq!(p, PathBuf::from("/game/sqpack/ffxiv/0a0000.win32.index2"));
    }

    #[test]
    fn dat_path_composition() {
        let t = ArchiveTriple::from_game_path("exd/root.exl").unwrap();
        let p = t.dat_path(Path::new("/game"), Platform::Win32, 0).unwrap();
        assert_eq!(p, PathBuf::from("/game/sqpack/ffxiv/0a0000.win32.dat0"));
        let p3 = t.dat_path(Path::new("/game"), Platform::Win32, 3).unwrap();
        assert_eq!(p3, PathBuf::from("/game/sqpack/ffxiv/0a0000.win32.dat3"));
    }

    #[test]
    fn path_composition_for_expansion_uses_ex_folder() {
        let t = ArchiveTriple::from_game_path("bg/ex1/foo.lgb").unwrap();
        let p = t.index_path(Path::new("/game"), Platform::Win32).unwrap();
        assert_eq!(p, PathBuf::from("/game/sqpack/ex1/020100.win32.index"));
    }

    #[test]
    fn path_composition_for_all_named_platforms() {
        let t = ArchiveTriple::from_game_path("exd/root.exl").unwrap();
        let root = Path::new("/game");
        for (platform, tag) in &[
            (Platform::Win32, "win32"),
            (Platform::Ps3, "ps3"),
            (Platform::Ps4, "ps4"),
        ] {
            let p = t.index_path(root, *platform).unwrap();
            let expected = format!("/game/sqpack/ffxiv/0a0000.{tag}.index");
            assert_eq!(
                p,
                PathBuf::from(&expected),
                "platform tag mismatch for {tag}"
            );
        }
    }

    #[test]
    fn path_composition_unknown_platform_is_none() {
        let t = ArchiveTriple::from_game_path("exd/root.exl").unwrap();
        let root = Path::new("/game");
        assert!(t.index_path(root, Platform::Unknown(99)).is_none());
        assert!(t.index2_path(root, Platform::Unknown(99)).is_none());
        assert!(t.dat_path(root, Platform::Unknown(99), 0).is_none());
    }

    // ----- platform_tag and parse_expansion_segment unit coverage -----

    #[test]
    fn platform_tag_unknown_is_none() {
        assert!(platform_tag(Platform::Unknown(7)).is_none());
    }

    #[test]
    fn parse_expansion_segment_empty_n_is_none() {
        // "ex" with no trailing digits is rejected — only "ex<digits>" parses.
        assert!(parse_expansion_segment(b"ex").is_none());
    }

    #[test]
    fn parse_expansion_segment_zero_is_zero() {
        assert_eq!(parse_expansion_segment(b"ex0"), Some(0));
    }

    #[test]
    fn parse_expansion_segment_overflow_is_none() {
        // 256 doesn't fit in u8 — parse fails.
        assert!(parse_expansion_segment(b"ex256").is_none());
    }

    // ----- ArchiveTriple for every category -----

    #[test]
    fn every_category_routes_to_correct_id_via_from_game_path() {
        // Walk the full known-name table and confirm each name round-trips
        // through from_game_path with the expected wire id. Catches accidental
        // table edits that would silently misroute an entire category.
        let table: &[(&str, u8)] = &[
            ("common", 0x00),
            ("bgcommon", 0x01),
            ("bg", 0x02),
            ("cut", 0x03),
            ("chara", 0x04),
            ("shader", 0x05),
            ("ui", 0x06),
            ("sound", 0x07),
            ("vfx", 0x08),
            ("ui_script", 0x09),
            ("exd", 0x0a),
            ("game_script", 0x0b),
            ("music", 0x0c),
            ("sqpack_test", 0x12),
            ("debug", 0x13),
        ];
        for (name, expected_id) in table {
            let path = format!("{name}/foo.bin");
            let triple = ArchiveTriple::from_game_path(&path)
                .unwrap_or_else(|| panic!("from_game_path returned None for category {name:?}"));
            assert_eq!(
                triple.category.id(),
                *expected_id,
                "category id mismatch for {name:?}"
            );
        }
    }

    // ----- file_stem round-trip -----

    #[test]
    fn file_stem_always_six_lowercase_hex_chars() {
        // For a broad range of (category, expansion, chunk) combinations,
        // file_stem must always produce exactly 6 lowercase hex characters.
        let cases: &[(u8, u8, u8)] = &[
            (0x00, 0, 0),
            (0x0a, 0, 0),
            (0x13, 15, 0),
            (0xFF, 0, 0),
            (0x00, 0, 255),
            (0x04, 3, 7),
        ];
        for &(cat_id, exp, chunk) in cases {
            let t = ArchiveTriple {
                category: Category(cat_id),
                expansion: exp,
                chunk,
            };
            let stem = t.file_stem();
            assert_eq!(stem.len(), 6, "stem {stem:?} is not 6 chars");
            assert!(
                stem.chars()
                    .all(|c| c.is_ascii_hexdigit() && !c.is_uppercase()),
                "stem {stem:?} contains non-lowercase-hex chars"
            );
        }
    }

    #[test]
    fn file_stem_roundtrip_preserves_all_three_fields() {
        // Spot-check that the stem encodes all three fields: cat, exp, chunk.
        // (0x02, 1, 2) → "020102".
        let t = ArchiveTriple {
            category: Category(0x02),
            expansion: 1,
            chunk: 2,
        };
        let stem = t.file_stem();
        assert_eq!(&stem[0..2], "02", "category hex");
        assert_eq!(&stem[2..4], "01", "expansion hex");
        assert_eq!(&stem[4..6], "02", "chunk hex");
    }

    // ----- trailing-slash path to ArchiveTriple -----

    #[test]
    fn from_game_path_category_only_with_trailing_slash() {
        // "common/" normalises to b"common/" — the first segment before '/' is
        // "common", which is a valid category. The second segment is empty and
        // doesn't parse as ex<N>, so expansion stays at 0.
        let t = ArchiveTriple::from_game_path("common/").unwrap();
        assert_eq!(t.category.id(), 0x00);
        assert_eq!(t.expansion, 0);
        assert_eq!(t.chunk, 0);
    }

    // ----- Send + Sync for format types -----

    #[test]
    fn archive_triple_and_category_are_send_and_sync() {
        fn assert_send_sync<T: Send + Sync>() {}
        assert_send_sync::<ArchiveTriple>();
        assert_send_sync::<Category>();
    }
}
