//! Error types for `sqpack-rs`.
//!
//! Failures from the parser surface as [`Error`]; [`Result`] is the
//! corresponding alias used by every fallible entry point. The error type
//! covers structural wire-format problems (bad magic, truncated headers,
//! malformed hash tables) and the I/O failures that bubble up from the
//! underlying file or byte stream.
//!
//! `binrw::Error` is not exposed in the public surface — a `binrw` parse
//! failure is wrapped as [`Error::Decode`], which carries an owned context
//! string and the formatted message.

use std::path::PathBuf;

/// Failures produced by `sqpack-rs`.
///
/// Covers every layer: I/O, wire-format parsing, path routing, verification,
/// and asset decoding. Callers typically match on the subset relevant to
/// their entry point and use `_` for the rest.
#[non_exhaustive]
#[derive(Debug, thiserror::Error)]
pub enum Error {
    /// I/O failure reading from the underlying byte source.
    #[error("I/O error{}: {source}", path.as_ref().map(|p| format!(" at {}", p.display())).unwrap_or_default())]
    Io {
        /// Path the failing operation was targeting, if known.
        path: Option<PathBuf>,
        /// Underlying [`std::io::Error`].
        #[source]
        source: std::io::Error,
    },

    /// The 8-byte `SqPack` magic header was missing or did not match.
    #[error("invalid SqPack magic")]
    InvalidMagic,

    /// The `SqPack` header's `type` field did not match the expected value.
    #[error("unexpected SqPack file type: expected {expected}, got {found}")]
    UnexpectedFileType {
        /// The type the caller asked for.
        expected: u32,
        /// The type the file actually declared.
        found: u32,
    },

    /// The index hash-table region carries a size that is not a multiple of
    #[error("index data size {size} is not a multiple of entry size {entry_size}")]
    MisalignedIndexData {
        /// The `index_data_size` field from the index header.
        size: u32,
        /// The size of a single [`crate::index::IndexEntry`] in bytes.
        entry_size: u32,
    },

    /// A header declared an on-disk size or offset that would cause the parser
    #[error("structurally invalid header field {field}: value {value} (minimum {minimum})")]
    InvalidHeaderSize {
        /// The header field whose value was rejected.
        field: &'static str,
        /// The value carried on disk.
        value: u32,
        /// The minimum legal value implied by surrounding context.
        minimum: u32,
    },

    /// Two distinct entries in the hash table shared the same hash key.
    #[error("duplicate hash key in index table: {hash:#018x}")]
    DuplicateHash {
        /// The hash key that appeared more than once. v2 keys are zero-extended
        hash: u64,
    },

    /// A `.dat` file payload's `FileInfo` declared a
    #[error("unsupported SqPack file kind: {kind:?}")]
    UnsupportedFileKind {
        /// The [`crate::dat::FileKind`] value the parser read from the
        kind: crate::dat::FileKind,
    },

    /// The file's [`crate::dat::FileKind`] didn't match what the caller
    #[error("expected {expected:?} payload, found {found:?}")]
    UnexpectedFileKind {
        /// The kind the caller expected.
        expected: crate::dat::FileKind,
        /// The kind the file actually has.
        found: crate::dat::FileKind,
    },

    /// A `.dat` block header's `size` field was not the fixed 16 bytes.
    #[error("block header size mismatch: expected {expected}, found {found}")]
    BlockHeaderSizeMismatch {
        /// The size the parser expected — currently always `16`.
        expected: u32,
        /// The size that was actually present in the block header's `size` field.
        found: u32,
    },

    /// A `.dat` payload's decompressed/uncompressed byte count did not match
    #[error("decompressed size mismatch ({context}): expected {expected}, found {found}")]
    DecompressedSizeMismatch {
        /// Which layer detected the mismatch — `"block"` or `"file"`.
        context: &'static str,
        /// The expected uncompressed length carried on disk.
        expected: u32,
        /// The actual byte count produced by the reader.
        found: u32,
    },

    /// A `.dat` block-info row declared an `offset` or `compressed_size` that
    #[error("invalid block layout in {context}: {message}")]
    InvalidBlockLayout {
        /// Static description of which check failed (e.g. `"block_info.offset"`).
        context: &'static str,
        /// Formatted message describing the specific violation.
        message: String,
    },

    /// A binary decode (via the underlying `binrw` parser) failed.
    #[error("decode error in {context}: {message}")]
    Decode {
        /// Static description of which field or struct failed to decode.
        context: &'static str,
        /// Formatted message from the underlying decoder.
        message: String,
    },

    /// A game-relative path's first segment did not match a recognised
    #[error("unknown SqPack category: {name:?}")]
    UnknownCategory {
        /// The unrecognised category name — the lowercased, slash-normalised
        name: String,
    },

    /// A game-relative path was routed and hashed successfully but no
    #[error("game path not found in any archive: {game_path:?}")]
    PathNotFound {
        /// The original game-relative path the caller passed in.
        game_path: String,
    },

    /// [`crate::SqPack::with_platform`] was called with a platform
    #[error("unsupported SqPack platform: {platform}")]
    UnsupportedPlatform {
        /// The platform variant that was rejected.
        platform: crate::header::Platform,
    },

    /// The first line of `exd/root.exl` did not match the expected
    #[error("invalid EXL header: {found:?}")]
    InvalidExlHeader {
        /// The raw first line as read from the input, lossily UTF-8-decoded.
        found: String,
    },

    /// The EXL header parsed cleanly but declared a version this crate
    #[error("unsupported EXL version: {version} (expected 2)")]
    UnsupportedExlVersion {
        /// The version field carried in the `EXLT,<version>` header.
        version: u32,
    },

    /// An EXL data line did not match the expected `<name>,<id>` shape.
    #[error("malformed EXL entry on line {line}: {reason}")]
    MalformedExlEntry {
        /// 1-based line number of the offending row in the input.
        line: u32,
        /// Static description of which check failed.
        reason: &'static str,
    },

    /// An EXH header parsed cleanly but declared a version this crate
    #[error("unsupported EXH version: {version} (expected 3)")]
    UnsupportedExhVersion {
        /// The version field carried on disk in the EXH header.
        version: u16,
    },

    /// An EXH header's `variant` byte was not one of the known values
    #[error("malformed EXH sheet kind: {found}")]
    MalformedExhSheetKind {
        /// The raw byte read from the `variant` field.
        found: u8,
    },

    /// An EXH column-definition row carried a `type` discriminant that
    #[error("malformed EXH column type: {found:#06x}")]
    MalformedExhColumnType {
        /// The raw `u16` discriminant read from the column definition.
        found: u16,
    },

    /// An EXH language byte did not match any known
    #[error("malformed EXH language: {found}")]
    MalformedExhLanguage {
        /// The raw byte read from the language entry.
        found: u8,
    },

    /// An EXH file's declared `total_row_count` (the top-of-header
    #[error("inconsistent EXH row count: declared {declared}, sum across pages {sum}")]
    InconsistentExhRowCount {
        /// The `row_count` field carried at the top of the EXH header.
        declared: u32,
        /// The sum of [`crate::excel::PageDefinition::row_count`] across
        sum: u32,
    },

    /// An EXH byte slice was shorter than the minimum 32-byte fixed
    #[error("EXH input too short: got {found} bytes, need at least {minimum}")]
    InvalidExhLength {
        /// Length of the input slice.
        found: usize,
        /// Minimum byte count the parser needs to make forward progress.
        minimum: usize,
    },

    /// An EXD header parsed cleanly but declared a version this crate
    #[error("unsupported EXD version: {version} (expected 2)")]
    UnsupportedExdVersion {
        /// The version field carried on disk in the EXD header.
        version: u16,
    },

    /// An EXD byte slice was shorter than the index section declared
    #[error("EXD input too short: got {found} bytes, need at least {expected_at_least}")]
    InvalidExdLength {
        /// Minimum byte count implied by the header's `index_size`
        expected_at_least: usize,
        /// Length of the input slice.
        found: usize,
    },

    /// An EXD row could not be decoded against the EXH schema.
    #[error("malformed EXD row (id {row_id}): {reason}")]
    MalformedExdRow {
        /// Row id the failure was associated with. `0` for
        row_id: u32,
        /// Static description of which structural invariant failed.
        reason: &'static str,
    },

    /// The caller asked for a language variant that the sheet does
    #[error("language {requested:?} not available for sheet {sheet:?}")]
    LanguageNotAvailable {
        /// Sheet name the caller passed to
        sheet: String,
        /// Language the caller requested.
        requested: crate::excel::Language,
    },

    /// An EXD `String`-column cell decoded to bytes that are not valid
    #[error("invalid UTF-8 in EXD string column (row {row_id}, column {column_idx})")]
    InvalidUtf8InExdString {
        /// Row id the bad string was read from.
        row_id: u32,
        /// Zero-based index into the EXH column list.
        column_idx: usize,
    },

    /// The caller invoked a sub-row accessor on a sheet that is not a
    #[error("sub_row called on non-SubRows sheet {sheet:?}")]
    SubRowOnDefaultSheet {
        /// Sheet name the caller passed to
        sheet: String,
    },

    /// A TEX byte slice was shorter than the minimum 80-byte fixed
    #[error("TEX input too short: got {found} bytes, need at least {minimum}")]
    InvalidTexLength {
        /// Length of the input slice.
        found: usize,
        /// Minimum byte count the parser needs to make forward progress
        minimum: usize,
    },

    /// A TEX texture format discriminant could not be sized or
    #[error("unsupported TEX texture format: {format:#010x}")]
    UnsupportedTextureFormat {
        /// Raw `u32` discriminant the parser found in the header.
        format: u32,
    },

    /// The caller asked
    #[error("texture format {format:#010x} is not decompressible to RGBA8")]
    FormatNotDecompressibleToRgba {
        /// Raw `u32` discriminant the helper was asked to decode.
        format: u32,
    },

    /// The caller asked [`crate::tex::Tex::surface_bytes`] for a mip
    #[error("TEX mip {mip} out of range (mip count {mip_count})")]
    MipOutOfRange {
        /// Mip index the caller passed.
        mip: usize,
        /// Effective mip count the surface table contains.
        mip_count: usize,
    },

    /// The caller asked [`crate::tex::Tex::surface_bytes`] for a
    #[error("TEX slice {slice} out of range (slice count {slice_count})")]
    SliceOutOfRange {
        /// Slice index the caller passed.
        slice: u32,
        /// Effective slice count for the requested mip.
        slice_count: u32,
    },

    /// A TEX surface byte window extends past the end of the input
    #[error(
        "TEX surface byte window out of bounds: offset {offset} + size {size} > file size {file_size}"
    )]
    TexSurfaceOutOfBounds {
        /// Computed absolute byte offset of the requested surface.
        offset: u64,
        /// Computed byte size of the requested surface.
        size: u64,
        /// Total length of the input slice in bytes.
        file_size: u64,
    },

    /// A TEX surface byte calculation overflowed before it could be
    #[error("TEX surface byte calculation overflowed: {reason}")]
    TexSurfaceOverflow {
        /// Static description of which arithmetic step overflowed.
        reason: &'static str,
    },

    /// An MDL byte slice was shorter than required for the section
    #[error("MDL input too short: got {found} bytes, need at least {minimum}")]
    InvalidMdlLength {
        /// Length of the input slice.
        found: usize,
        /// Minimum byte count the parser needs to make forward
        minimum: usize,
    },

    /// An MDL section failed a structural invariant — declared count
    #[error("MDL section {section:?} structurally invalid: {reason}")]
    InvalidMdlSection {
        /// Section identifier — one of the names listed in the
        section: &'static str,
        /// Static description of which invariant was violated.
        reason: &'static str,
    },

    /// A caller passed an MDL LOD index that is out of range.
    #[error("MDL LOD {lod} out of range (lod count {lod_count})")]
    MdlLodOutOfRange {
        /// LOD index the caller passed.
        lod: usize,
        /// Number of LOD slots present on disk
        lod_count: usize,
    },

    /// A caller asked [`crate::mdl::Mdl::material_name`] for an index
    #[error("MDL material index {index} out of range (material count {material_count})")]
    MdlMaterialOutOfRange {
        /// Material index the caller passed.
        index: usize,
        /// Number of material entries in the model's material-name
        material_count: usize,
    },

    /// A caller asked [`crate::mdl::Mdl::bone_name`] for an index
    #[error("MDL bone index {index} out of range (bone count {bone_count})")]
    MdlBoneOutOfRange {
        /// Bone index the caller passed.
        index: usize,
        /// Number of bone entries in the model's bone-name offset
        bone_count: usize,
    },

    /// A caller asked [`crate::mdl::Mdl::attribute_name`] for an
    #[error("MDL attribute index {index} out of range (attribute count {attribute_count})")]
    MdlAttributeOutOfRange {
        /// Attribute index the caller passed.
        index: usize,
        /// Number of attribute entries in the model's attribute-name
        attribute_count: usize,
    },

    /// A caller asked [`crate::mdl::Mdl::shape_name`] for an index
    #[error("MDL shape index {index} out of range (shape count {shape_count})")]
    MdlShapeOutOfRange {
        /// Shape index the caller passed.
        index: usize,
        /// Number of shape entries in the model's shape table.
        shape_count: usize,
    },

    /// A string-table byte offset on an MDL string lookup pointed
    #[error("MDL string offset {offset} out of range (table length {table_len})")]
    InvalidMdlStringOffset {
        /// Byte offset the caller looked up.
        offset: u32,
        /// Length of the model's string table in bytes.
        table_len: usize,
    },

    /// An MDL string-table byte range decoded to bytes that are not
    #[error("invalid UTF-8 in MDL string at offset {offset}")]
    InvalidUtf8InMdlString {
        /// Byte offset of the offending string in the model's
        offset: u32,
    },

    /// An SCD byte slice was shorter than the section being parsed
    #[error("SCD input too short: got {found} bytes, need at least {minimum}")]
    InvalidScdLength {
        /// Length of the input slice.
        found: usize,
        /// Minimum byte count the parser needs to make forward
        minimum: usize,
    },

    /// The 8-byte SCD magic (`b"SEDB"` followed by `b"SSCF"`) was
    #[error(
        "invalid SCD magic: expected [b\"SEDB\", b\"SSCF\"], got [{found_type:?}, {found_sub_type:?}]"
    )]
    InvalidScdMagic {
        /// The first four bytes of the input — should be `b"SEDB"`.
        found_type: [u8; 4],
        /// The next four bytes of the input — should be `b"SSCF"`.
        found_sub_type: [u8; 4],
    },

    /// An SCD file declared a non-little-endian byte order via its
    #[error("unsupported SCD endianness: endian_type={endian_type} (expected 0)")]
    UnsupportedScdEndianness {
        /// The raw `endian_type` byte the parser read from the
        endian_type: u8,
    },

    /// An SCD track-table entry failed a structural invariant —
    #[error("SCD track structurally invalid: {reason}")]
    InvalidScdTrack {
        /// Static description of which invariant was violated. The
        reason: &'static str,
    },

    /// An MS-ADPCM block in an SCD payload failed a structural
    #[error("SCD MS-ADPCM block structurally invalid: {reason}")]
    InvalidMsAdpcmBlock {
        /// Static description of which invariant was violated.
        reason: &'static str,
    },

    /// An OGG Vorbis SCD payload's container header could not be
    #[error("SCD OGG Vorbis header invalid: {reason}")]
    InvalidOggHeader {
        /// Static description of which invariant was violated.
        reason: &'static str,
    },

    /// An SCD track declared the [`crate::scd::ScdFormat::Mp3`]
    #[error("SCD MP3 decoding is not supported (PS3-only codec; no PC SCD ships MP3)")]
    Mp3DecodingUnsupported,

    /// An SCD track declared the [`crate::scd::ScdFormat::Atrac9`]
    #[error("SCD ATRAC9 decoding is not supported (Sony proprietary; no Rust port available)")]
    Atrac9DecodingUnsupported,

    /// An SCD track declared an unknown codec discriminant — i.e.
    #[error("SCD unknown codec discriminant {raw}: no decoder available")]
    UnknownCodecDecodingUnsupported {
        /// The raw `i32` codec discriminant the track carried.
        raw: i32,
    },

    /// An SCD track declared the [`crate::scd::ScdFormat::OggVorbis`]
    #[error(
        "SCD OGG Vorbis PCM decoding is not provided by this crate; \
         call Track::to_ogg_bytes() and feed the result to an external Vorbis decoder \
         (e.g. lewton::inside_ogg::OggStreamReader)"
    )]
    OggVorbisDecodingUnsupported,

    /// [`crate::scd::Track::to_ogg_bytes`] was called on a track whose
    #[error(
        "SCD Track::to_ogg_bytes called on a non-OggVorbis track \
         (dispatch on Track::format first)"
    )]
    OggBytesNotApplicable,

    /// An SKLB byte slice was shorter than the minimum fixed-header
    #[error("SKLB input too short: got {found} bytes, need at least {minimum}")]
    InvalidSklbLength {
        /// Length of the input slice.
        found: usize,
        /// Minimum byte count the parser needs to make forward
        minimum: usize,
    },

    /// The 4-byte SKLB magic (`b"blks"`, i.e. the little-endian `u32`
    #[error("invalid SKLB magic: expected b\"blks\", got {found:?}")]
    InvalidSklbMagic {
        /// The first four bytes of the input — should spell
        found: [u8; 4],
    },

    /// An SKLB header declared a Havok-payload offset that lies
    #[error("SKLB havok_offset out of bounds: offset {havok_offset} > file size {file_size}")]
    InvalidSklbHavokOffset {
        /// The bogus offset value carried in the SKLB header.
        havok_offset: u64,
        /// Total length of the input slice in bytes.
        file_size: u64,
    },

    /// A PAP byte slice was shorter than the minimum fixed-header
    #[error("PAP input too short: got {found} bytes, need at least {minimum}")]
    InvalidPapLength {
        /// Length of the input slice.
        found: usize,
        /// Minimum byte count the parser needs to make forward
        minimum: usize,
    },

    /// The 4-byte PAP magic (`b"pap "`, i.e. the little-endian `u32`
    #[error("invalid PAP magic: expected b\"pap \", got {found:?}")]
    InvalidPapMagic {
        /// The first four bytes of the input — should spell
        found: [u8; 4],
    },

    /// The PAP wrapper version word was not the documented
    #[error(
        "unsupported PAP version: {version:#010x} (expected {:#010x})",
        crate::pap::PAP_VERSION_V1
    )]
    UnsupportedPapVersion {
        /// The version word carried at offset `0x04` of the PAP
        version: u32,
    },

    /// A PAP header declared an offset (`info_offset`,
    #[error("PAP {field} out of bounds: offset {offset} > file size {file_size}")]
    InvalidPapOffset {
        /// Which header offset (or derived range end) was rejected.
        field: &'static str,
        /// The offending offset (or computed range end) in bytes.
        offset: u64,
        /// Total length of the input slice in bytes.
        file_size: u64,
    },

    /// A PAP header declared two section offsets whose relative order
    #[error(
        "PAP offsets out of order: {first_field} ({first_offset}) > {second_field} ({second_offset})"
    )]
    InvalidPapOffsetOrdering {
        /// The earlier-named header field in the offending pair (the
        first_field: &'static str,
        /// The on-disk value of `first_field`.
        first_offset: u64,
        /// The later-named header field in the offending pair (the
        second_field: &'static str,
        /// The on-disk value of `second_field`.
        second_offset: u64,
    },

    /// A PAP header declared an `animation_count` that exceeds the
    #[error("PAP animation_count {count} exceeds defensive cap {max}")]
    InvalidPapAnimationCount {
        /// The animation count carried in the PAP header.
        count: u32,
        /// The defensive cap rejected against. Equal to
        max: u32,
    },

    /// A PHYB byte slice was shorter than the minimum the parser
    #[error("PHYB input too short: got {found} bytes, need at least {minimum}")]
    InvalidPhybLength {
        /// Length of the input slice.
        found: usize,
        /// Minimum byte count the parser needs to make forward
        minimum: usize,
    },

    /// The PHYB `version` word at offset `0x00` was an unrecognised
    #[error(
        "unsupported PHYB version: {version:#010x} (expected 0 or {:#010x})",
        crate::phyb::PHYB_VERSION_FULL
    )]
    UnsupportedPhybVersion {
        /// The version word carried at offset `0x00` of the PHYB
        version: u32,
    },

    /// A PHYB header declared an offset (`collision_offset` or
    #[error("PHYB {field} out of bounds: offset {offset} > file size {file_size}")]
    InvalidPhybOffset {
        /// Which header offset (or derived range end) was rejected.
        field: &'static str,
        /// The offending offset (or computed range end) in bytes.
        offset: u64,
        /// Total length of the input slice in bytes.
        file_size: u64,
    },

    /// A PHYB V0 stub layout (12-byte file, `version == 0`) carried a
    #[error("PHYB V0 stub carries non-zero {field} = {value:#010x} (expected 0)")]
    InvalidPhybStub {
        /// Which reserved stub slot was non-zero — one of
        field: &'static str,
        /// The non-zero `u32` value carried in the offending slot.
        value: u32,
    },

    /// A PHYB simulator declared two sub-array windows that overlap on
    #[error(
        "PHYB simulator {simulator_index} sub-array overlap: \
         {prev_field} ends at {prev_end}, {next_field} starts at {next_start}"
    )]
    InvalidPhybSubArrayOverlap {
        /// Zero-based simulator index in the simulator section.
        simulator_index: u32,
        /// Name of the earlier-positioned sub-array (e.g. `"chain"`,
        prev_field: &'static str,
        /// Exclusive byte offset where `prev_field`'s window ends.
        prev_end: u64,
        /// Name of the later-positioned sub-array whose window
        next_field: &'static str,
        /// Inclusive byte offset where `next_field`'s window starts.
        next_start: u64,
    },

    /// A PHYB string field (a 32-byte null-padded bone or shape name)
    #[error("invalid UTF-8 in PHYB string field {field} at offset {offset}")]
    InvalidUtf8InPhybString {
        /// Static label naming which record field the bad bytes came
        field: &'static str,
        /// Byte offset of the offending string in the input slice.
        offset: u64,
    },

    /// A PHYB section declared a record count that exceeds the
    #[error("PHYB {kind} count {count} exceeds defensive cap {max}")]
    InvalidPhybCount {
        /// Static label for which count field was rejected — e.g.
        kind: &'static str,
        /// The count value carried on disk.
        count: u32,
        /// The defensive cap rejected against. Equal to
        max: u32,
    },

    /// A TMB byte slice was shorter than the minimum the parser
    #[error("TMB input too short: got {found} bytes, need at least {minimum}")]
    InvalidTmbLength {
        /// Length of the input slice.
        found: usize,
        /// Minimum byte count the parser needs to make forward
        minimum: usize,
    },

    /// The 4-byte TMB container magic (`b"TMLB"`) was missing or did
    #[error("invalid TMB magic: expected b\"TMLB\", got {found:?}")]
    InvalidTmbMagic {
        /// The first four bytes of the input — should spell
        found: [u8; 4],
    },

    /// A TMB chunk-header `size` field was structurally invalid:
    #[error("TMB chunk {tag:?} size {chunk_size} structurally invalid (file size {file_size})")]
    InvalidTmbChunkLength {
        /// The 4-byte tag of the chunk whose size field was
        tag: [u8; 4],
        /// The `chunk_size` value carried on disk.
        chunk_size: u32,
        /// Total length of the input slice in bytes.
        file_size: u64,
    },

    /// The TMLB container header's declared `file_size` field
    #[error("TMB declared file_size {declared} exceeds input slice ({found} bytes)")]
    InvalidTmbFileSize {
        /// The `file_size` value carried at offset `0x04` of the
        declared: u32,
        /// Total length of the input slice in bytes.
        found: u64,
    },

    /// A typed TMB entry's declared `entry_size` did not match the
    #[error(
        "TMB entry {tag:?} size {entry_size} mismatched chunk-table walk (expected_or_remaining {expected_or_remaining})"
    )]
    InvalidTmbEntryLength {
        /// The 4-byte tag of the entry whose size was rejected.
        tag: [u8; 4],
        /// The `entry_size` value carried on disk.
        entry_size: u32,
        /// Dual-purpose budget field — see the variant's rustdoc for
        expected_or_remaining: u64,
    },

    /// A TMB container's declared `chunk_count` exceeded the
    #[error("TMB chunk_count {count} exceeds defensive cap {max}")]
    InvalidTmbChunkCount {
        /// The `chunk_count` value carried in the TMLB header.
        count: u32,
        /// The defensive cap rejected against. Equal to
        max: u32,
    },

    /// A TMB per-chunk entry-table count exceeded a defensive cap.
    #[error("TMB entry-table count {count} exceeds defensive cap {max} (chunk {tag:?})")]
    InvalidTmbEntryCount {
        /// The 4-byte tag of the chunk whose entry-count was
        tag: [u8; 4],
        /// The on-disk count value.
        count: u32,
        /// The defensive cap rejected against.
        max: u32,
    },

    /// A TMB string field decoded to bytes that are not valid
    #[error("invalid UTF-8 in TMB string {context} at offset {offset}")]
    InvalidUtf8InTmbString {
        /// Static label naming which string field the bad bytes
        context: &'static str,
        /// Byte offset of the offending string in the input slice.
        offset: u64,
    },

    /// An EID byte slice was shorter than the minimum the parser
    #[error("EID input too short: got {found} bytes, need at least {minimum}")]
    InvalidEidLength {
        /// Length of the input slice.
        found: usize,
        /// Minimum byte count the parser needs to make forward
        minimum: usize,
    },

    /// The 4-byte EID magic (`b"eid\0"`, i.e. the little-endian
    #[error("invalid EID magic: expected b\"eid\\0\", got {found:?}")]
    InvalidEidMagic {
        /// The first four bytes of the input — should spell
        found: [u8; 4],
    },

    /// The EID `version1` `i16` was an unrecognised value. Retained
    #[error("unsupported EID version: {version:#06x}")]
    UnsupportedEidVersion {
        /// The `version1` field value carried at offset `0x04` of
        version: u32,
    },

    /// An EID file declared a per-entry count that was negative or
    #[error("EID entry count {count} exceeds defensive cap {max}")]
    InvalidEidEntryCount {
        /// The count value carried on disk, reinterpreted as
        count: u32,
        /// The defensive cap rejected against. Equal to
        max: u32,
    },

    /// An EID bone-name buffer decoded to bytes that are not valid
    #[error("invalid UTF-8 in EID bone-name buffer at offset {offset}")]
    InvalidUtf8InEidString {
        /// Byte offset of the offending name buffer in the input
        offset: u64,
    },

    /// The append-only writer rejected a path because its computed hash
    #[error("file already exists in archive: {game_path:?}")]
    FileAlreadyExists {
        /// The game-relative path the caller asked to append.
        game_path: String,
    },

    /// Appending the supplied bytes would push the target `.dat` file
    #[error(
        "append would exceed dat size cap: current_size={current_size}, additional_bytes={additional_bytes}, max={max}"
    )]
    DatSizeOverflow {
        /// The current size of the target `.dat` file in bytes.
        current_size: u64,
        /// The number of bytes the append would add.
        additional_bytes: u64,
        /// The configured cap ([`crate::write::DAT_MAX_SIZE`]).
        max: u64,
    },

    /// The append-only writer rejected a payload whose decoded
    #[error("append-only writer only supports Standard file kind: got {kind:?}")]
    UnsupportedAppendKind {
        /// The [`crate::dat::FileKind`] the caller tried to append.
        kind: crate::dat::FileKind,
    },

    /// The append-only writer rejected a payload that exceeds the
    #[error(
        "appended file size {size} exceeds maximum {max} (single-call cap; larger writes need a streaming encoder)"
    )]
    AppendFileTooLarge {
        /// The size of the payload the caller supplied, in bytes.
        size: u64,
        /// The configured cap ([`crate::write::APPEND_MAX_FILE_SIZE`]).
        max: u64,
    },

    /// An atomic step of an append transaction failed mid-flight.
    #[error("atomic append step `{context}` failed: {source}")]
    WriteAtomicityFailure {
        /// Static description of which atomic step failed (e.g.
        context: &'static str,
        /// The underlying I/O error.
        #[source]
        source: std::io::Error,
    },

    /// The full-repack writer was handed two inputs whose game paths route
    #[error("duplicate path in repack input ({kind}): {game_path:?}")]
    DuplicatePath {
        /// The colliding game-relative path.
        game_path: String,
        /// Hash family the collision was detected in — `"v1"` or
        kind: &'static str,
    },

    /// [`crate::write::SqPackRepacker::build`] /
    #[error("repack called with no input files")]
    RepackEmptyArchive,

    /// A [`crate::write::SqPackRepacker`] was handed more than
    #[error("repack input set has {count} files; max is {max}")]
    RepackTooManyFiles {
        /// Number of files in the caller's input set.
        count: usize,
        /// The configured cap ([`crate::write::REPACK_MAX_FILES`]).
        max: usize,
    },

    /// A [`crate::write::SqPackRepacker`] was built with neither
    #[error("repack requires at least one of index_v1 or index_v2 to be enabled")]
    RepackNoIndexSelected,

    /// [`crate::write::SqPackRepacker::add_file`] was called with a
    #[error("path {game_path:?} routes to {found:?} but repacker was built for {expected:?}")]
    PathRoutesToWrongArchive {
        /// The game-relative path the caller tried to add.
        game_path: String,
        /// The triple the repacker was constructed for.
        expected: crate::route::ArchiveTriple,
        /// The triple `game_path` would route to.
        found: crate::route::ArchiveTriple,
    },
}

impl From<std::io::Error> for Error {
    fn from(source: std::io::Error) -> Self {
        Error::Io { path: None, source }
    }
}

impl Error {
    /// Construct an [`Error::Decode`] from a `binrw` parse failure with the
    /// supplied static context string.
    #[must_use]
    pub fn decode(context: &'static str, err: &binrw::Error) -> Self {
        Error::Decode {
            context,
            message: err.to_string(),
        }
    }

    /// Construct an [`Error::Io`] tagged with the path the operation was
    /// targeting.
    #[must_use]
    pub fn io_at(path: impl Into<PathBuf>, source: std::io::Error) -> Self {
        Error::Io {
            path: Some(path.into()),
            source,
        }
    }

    /// Extract the [`std::io::ErrorKind`] when `self` is an [`Error::Io`]
    /// variant; returns `None` for every other variant. Used by the
    /// verify module to populate
    /// [`PathMismatchKind::ReadError::error_kind`](crate::verify::PathMismatchKind::ReadError).
    #[must_use]
    pub fn io_error_kind(&self) -> Option<std::io::ErrorKind> {
        match self {
            Error::Io { source, .. } => Some(source.kind()),
            _ => None,
        }
    }
}

/// `Result` alias for `sqpack-rs` entry points.
pub type Result<T> = std::result::Result<T, Error>;
