//! Error types for libfreemkv.
//!
//! Every error is a code with structured data. No English text.
//! Applications map codes to localized messages.
//!
//! # Error Code Ranges
//!
//! | Range | Category |
//! |-------|----------|
//! | E1xxx | Device errors |
//! | E2xxx | Profile errors |
//! | E3xxx | Unlock errors |
//! | E4xxx | SCSI errors |
//! | E5xxx | I/O errors |
//! | E6xxx | Disc format errors |
//! | E7xxx | AACS errors |
//! | E8xxx | Keydb errors |
//! | E9xxx | Stream/mux errors |

// ── Error codes ─────────────────────────────────────────────────────────────

// Device (1xxx)
pub const E_DEVICE_NOT_FOUND: u16 = 1000;
pub const E_DEVICE_PERMISSION: u16 = 1001;
pub const E_DEVICE_NOT_READY: u16 = 1002;
pub const E_DEVICE_RESET_FAILED: u16 = 1003;
pub const E_SCSI_INTERFACE_UNAVAILABLE: u16 = 1004;
pub const E_DEVICE_LOCKED: u16 = 1005;
pub const E_IOKIT_PLUGIN_FAILED: u16 = 1006;

// Profile (2xxx)
pub const E_UNSUPPORTED_DRIVE: u16 = 2000;
// 2001: burned/retired — do not reuse.
pub const E_PROFILE_PARSE: u16 = 2002;
pub const E_UNSUPPORTED_PLATFORM: u16 = 2003;
pub const E_PLATFORM_NOT_IMPLEMENTED: u16 = 2004;

// Unlock (3xxx)
pub const E_UNLOCK_FAILED: u16 = 3000;
pub const E_SIGNATURE_MISMATCH: u16 = 3001;

// SCSI (4xxx)
pub const E_SCSI_ERROR: u16 = 4000;
pub const E_INVALID_CDB_LENGTH: u16 = 4001;

// I/O (5xxx)
pub const E_IO_ERROR: u16 = 5000;

// Disc format (6xxx)
pub const E_DISC_READ: u16 = 6000;
pub const E_MPLS_PARSE: u16 = 6001;
pub const E_CLPI_PARSE: u16 = 6002;
pub const E_UDF_NOT_FOUND: u16 = 6003;
// 6004: burned/retired — do not reuse.
pub const E_DISC_TITLE_RANGE: u16 = 6005;
// 6006: burned/retired — do not reuse.
pub const E_IFO_PARSE: u16 = 6007;
pub const E_MKV_INVALID: u16 = 6008;
pub const E_NO_STREAMS: u16 = 6009;
pub const E_HALTED: u16 = 6010;
pub const E_MAPFILE_INVALID: u16 = 6011;
pub const E_SELECTION_PID_UNKNOWN: u16 = 6014;
pub const E_UDF_BUFFER_TOO_SMALL: u16 = 6012;
pub const E_UDF_NOT_FILESYSTEM: u16 = 6013;

// AACS (7xxx)
pub const E_AACS_NO_KEYS: u16 = 7000;
pub const E_AACS_CERT_SHORT: u16 = 7001;
pub const E_AACS_AGID_ALLOC: u16 = 7002;
pub const E_AACS_CERT_REJECTED: u16 = 7003;
pub const E_AACS_CERT_READ: u16 = 7004;
pub const E_AACS_CERT_VERIFY: u16 = 7005;
pub const E_AACS_KEY_READ: u16 = 7006;
pub const E_AACS_KEY_REJECTED: u16 = 7007;
pub const E_AACS_KEY_VERIFY: u16 = 7008;
pub const E_AACS_VID_READ: u16 = 7009;
pub const E_AACS_VID_MAC: u16 = 7010;
pub const E_AACS_DATA_KEY: u16 = 7011;
// 7012: burned/retired — do not reuse.
pub const E_DECRYPT_FAILED: u16 = 7013;
pub const E_CSS_AUTH_FAILED: u16 = 7014;
pub const E_AACS_HOST_CERT_REJECTED: u16 = 7015;
pub const E_AACS_RAW_READ_UNSUPPORTED: u16 = 7016;
pub const E_AACS_VID_UNAVAILABLE: u16 = 7017;
pub const E_AACS_MK_UNAVAILABLE: u16 = 7018;
pub const E_AACS_VUK_NOT_IN_KEYDB: u16 = 7019;
pub const E_DRIVE_PROFILE_MISSING: u16 = 7020;
pub const E_VID_CDB_UNAVAILABLE: u16 = 7021;
pub const E_NO_DISC_KEY: u16 = 7022;
pub const E_CSS_KEY_MISSING: u16 = 7023;
pub const E_AACS_NO_HOST_CERT: u16 = 7024;
pub const E_AACS_BUS_KEY_UNAVAILABLE: u16 = 7025;
pub const E_FMTS_KEY_MISSING: u16 = 7026;

// Keydb (8xxx)
pub const E_KEYDB_CONNECT: u16 = 8000;
pub const E_KEYDB_HTTP: u16 = 8001;
pub const E_KEYDB_INVALID: u16 = 8002;
pub const E_KEYDB_WRITE: u16 = 8003;
pub const E_KEYDB_PARSE: u16 = 8004;
pub const E_KEYDB_LOAD: u16 = 8005;
pub const E_KEYDB_UNSUPPORTED_SCHEME: u16 = 8006;
pub const E_KEYDB_TOO_MANY_REDIRECTS: u16 = 8007;

// Stream/mux (9xxx)
pub const E_STREAM_READ_ONLY: u16 = 9000;
pub const E_STREAM_WRITE_ONLY: u16 = 9001;
pub const E_STREAM_URL_INVALID: u16 = 9002;
pub const E_STREAM_URL_MISSING_PATH: u16 = 9003;
pub const E_STREAM_URL_MISSING_PORT: u16 = 9004;
pub const E_PES_FRAME_TOO_LARGE: u16 = 9005;
pub const E_PES_INVALID_MAGIC: u16 = 9006;
pub const E_ISO_TOO_LARGE: u16 = 9007;
pub const E_NO_METADATA: u16 = 9008;
pub const E_DISC_URL_NOT_DIRECT: u16 = 9009;
/// `--raw` given with a `dir://` destination (raw + decrypted-tree is
/// a contradiction; raw bytes go to `iso://`).
pub const E_DIR_RAW_REJECTED: u16 = 9019;
pub const E_HEVC_PARAM_PARSE: u16 = 9010;
pub const E_MUX_TRACK_RANGE: u16 = 9011;
pub const E_FMP4_UNIMPLEMENTED: u16 = 9012;
pub const E_DEMUX_THREAD_PANICKED: u16 = 9013;
pub const E_PIPELINE_JOIN_TIMEOUT: u16 = 9014;
pub const E_PIPELINE_CONSUMER_PANICKED: u16 = 9015;
pub const E_SWEEP_CONSUMER_GONE: u16 = 9016;
pub const E_PES_TRACK_TOO_LARGE: u16 = 9017;
pub const E_PIPELINE_CONSUMER_GONE: u16 = 9018;
pub const E_DISC_CAPACITY_OVERFLOW: u16 = 9020;
/// `--multipass` given with a `dir://` destination (`dir://` is 1-shot;
/// recovery is the `iso://` path's job).
pub const E_DIR_MULTIPASS_REJECTED: u16 = 9024;
/// A non-disc (byte-stream) source was routed into `dir://`, which needs a
/// filesystem (only `disc://` / `iso://` qualify).
pub const E_DIR_SOURCE_UNSUPPORTED: u16 = 9025;
/// `dir://` target directory is non-empty and `--force` was not given.
pub const E_DIR_NOT_EMPTY: u16 = 9026;
/// `dir://` target filesystem free space is below the sum of file extents.
pub const E_DIR_INSUFFICIENT_SPACE: u16 = 9027;
/// Two distinct disc paths sanitize to the same host path (would silently
/// overwrite — surfaced as a hard error instead).
pub const E_DIR_NAME_COLLISION: u16 = 9028;
/// A `dir://` create_dir_all / file write / rename failed.
pub const E_DIR_WRITE_FAILED: u16 = 9029;
pub const E_M2TS_PACKET_MALFORMED: u16 = 9021;
/// A `network://` output target resolved to no address that is safe to
/// connect to (every resolved IP was loopback / private / link-local /
/// multicast / unspecified). Closes the DNS-rebinding SSRF window.
pub const E_NETWORK_ADDR_BLOCKED: u16 = 9022;
/// A muxer's `finish()` was called after zero frames were emitted — the
/// output would be a header-only container with no media. Surfaced so a
/// zero-frame mux (undecryptable input, fully-unreadable title, every
/// frame dropped before the first keyframe) cannot report success.
pub const E_MUX_EMPTY: u16 = 9023;
pub const E_EXTENT_NOT_UNIT_ALIGNED: u16 = 9030;
/// `mp4://` output but the title has no (primary) video track to carry.
pub const E_MP4_NO_VIDEO_TRACK: u16 = 9048;
/// `mp4://` SOURCE file is malformed/truncated (bad box structure, sample table,
/// or offsets) — the MP4 demuxer could not parse it.
pub const E_MP4_INVALID: u16 = 9049;
/// `mp4://` video track is missing its codec-configuration record
/// (`hvcC`/`avcC`), without which the sample entry can't be written.
pub const E_MP4_MISSING_CODEC_PRIVATE: u16 = 9050;
/// READ CAPACITY returned a short or overflowing transfer.
pub const E_DISC_CAPACITY_MALFORMED: u16 = 9047;

// ── Error enum ──────────────────────────────────────────────────────────────

/// Structured error with numeric code and context data. No English text.
///
/// Marked `#[non_exhaustive]`: downstream crates must not match it
/// exhaustively, so new variants can be added without a semver break.
#[derive(Debug)]
#[non_exhaustive]
pub enum Error {
    // Device (1xxx)
    DeviceNotFound {
        path: String,
    },
    DevicePermission {
        path: String,
    },
    DeviceNotReady {
        path: String,
    },
    DeviceResetFailed {
        path: String,
    },
    /// Platform-specific SCSI interface couldn't be obtained from the OS
    /// (macOS: `SCSITaskDeviceInterface` unavailable). The `path` field
    /// carries the device path; no English commentary on the failure mode.
    ScsiInterfaceUnavailable {
        path: String,
    },
    /// Device is held by another process / kernel state. `kr` is the
    /// platform return code (macOS IOReturn, Linux errno-equivalent).
    DeviceLocked {
        path: String,
        kr: u32,
    },
    /// macOS IOKit plugin couldn't be created for this device. `kr` is
    /// the IOReturn code from `IOCreatePlugInInterfaceForService`.
    IoKitPluginFailed {
        path: String,
        kr: u32,
    },

    // Profile (2xxx)
    UnsupportedDrive {
        vendor_id: String,
        product_id: String,
        product_revision: String,
    },
    ProfileParse,
    /// SCSI transport was requested on an OS without a backend
    /// implementation. `target` is the `std::env::consts::OS` value.
    UnsupportedPlatform {
        target: String,
    },
    /// Drive matched a known platform that we haven't implemented yet
    /// (e.g. Renesas firmware). `platform` is a stable identifier.
    PlatformNotImplemented {
        platform: String,
    },

    // Unlock (3xxx)
    UnlockFailed,
    SignatureMismatch {
        expected: [u8; 4],
        got: [u8; 4],
    },

    // SCSI (4xxx)
    /// SCSI command failed.
    ///
    /// `opcode` is the failing CDB byte 0. `status` is the raw SCSI
    /// status byte: `0x02` = CHECK CONDITION (drive replied with sense
    /// data), `0xFF` = libfreemkv-synthesised sentinel meaning "no SCSI
    /// status delivered" (kernel timeout, USB bridge wedge, IOKit
    /// service failure). `sense` carries the drive's SPC-4 sense triple
    /// when the drive replied; `None` for transport-layer failures.
    ///
    /// Recommended dispatch (callers shouldn't pattern-match raw
    /// fields):
    ///   - [`Error::is_scsi_transport_failure`] — bail; bridge/transport wedge
    ///   - [`Error::is_marginal_read`] — drive said this read was marginal; smaller block may recover
    ///   - [`Error::scsi_sense`] — borrow the sense triple for finer routing ([`ScsiSense::is_medium_error`] etc.)
    ScsiError {
        opcode: u8,
        status: u8,
        sense: Option<crate::scsi::ScsiSense>,
    },
    /// CDB supplied to the transport exceeded the maximum supported length.
    /// `len` is the supplied CDB length; `max` is the transport's limit.
    InvalidCdbLength {
        len: usize,
        max: usize,
    },

    // I/O (5xxx)
    IoError {
        source: std::io::Error,
    },

    // Disc format (6xxx)
    DiscRead {
        sector: u64,
        status: Option<u8>,
        sense: Option<crate::scsi::ScsiSense>,
    },
    /// Drive was halted by caller.
    Halted,
    MplsParse,
    ClpiParse,
    UdfNotFound {
        path: String,
    },
    /// The reader was addressable but the bytes are structurally NOT a UDF
    /// filesystem — a deterministic tag/format mismatch (e.g. no Anchor Volume
    /// Descriptor Pointer at sector 256, no partition descriptor, no File Set
    /// Descriptor). Distinct from [`Error::DiscRead`] (a transient I/O fault):
    /// this is a stable property of the media, not something a retry fixes. Lets
    /// callers (notably FMTS key resolution) treat "not a UDF/FMTS disc" as a
    /// clean negative while still failing loud on a real read fault.
    UdfNotFilesystem,
    /// A `SectorSource` caller passed a destination buffer smaller than one
    /// 2048-byte sector. A contract violation on the public reader API —
    /// returned instead of panicking on the slice.
    UdfBufferTooSmall,
    DiscTitleRange {
        index: usize,
        count: usize,
    },
    IfoParse,
    MkvInvalid,
    NoStreams,
    /// A [`crate::StreamSelection`] listed a PID that does not exist in the
    /// title's declared streams — a caller bug (e.g. a stale scan), reported
    /// loudly rather than silently producing an MKV missing a requested track.
    SelectionPidUnknown {
        pid: u16,
    },
    /// ddrescue mapfile parse failed. `kind` is a stable, language-neutral
    /// identifier (e.g. `"status_char"`, `"hex"`); not a translatable
    /// English message.
    MapfileInvalid {
        kind: &'static str,
    },

    // AACS (7xxx)
    AacsNoKeys,
    AacsCertShort,
    AacsAgidAlloc,
    AacsCertRejected,
    AacsCertRead,
    AacsCertVerify,
    AacsKeyRead,
    AacsKeyRejected,
    AacsKeyVerify,
    AacsVidRead,
    AacsVidMac,
    AacsDataKey,
    DecryptFailed,
    CssAuthFailed,
    /// Host certificate rejected by the drive's revocation list (HRL hit).
    /// All available host certs failed mutual auth on this drive.
    AacsHostCertRejected,
    /// Drive cannot be put into raw-read mode and standard AACS cert
    /// auth failed. No path to decryption remains.
    AacsRawReadUnsupported,
    /// Volume ID could not be retrieved from the drive (neither via cert
    /// auth nor via the alternate VID read path). Downstream of step 1
    /// of the AACS chain.
    AacsVidUnavailable,
    /// No available path produced a Media Key (no MK+VID in keydb, no
    /// PK match, no DK derivation).
    AacsMkUnavailable,
    /// Disc-hash lookup in the keydb missed and no other path is
    /// available (typically because VID is missing).
    AacsVukNotInKeydb,
    /// Drive identity did not match any bundled profile; per-drive CDB
    /// templates aren't available so the OEM VID retrieval path can't
    /// run.
    DriveProfileMissing,
    /// Drive's profile is present but doesn't carry a VID-retrieval CDB
    /// template (older profile blob, or a drive class without an OEM
    /// VID path).
    VidCdbUnavailable,
    /// The disc is AACS-encrypted and decryption was requested, but key
    /// resolution produced no usable key for it — so muxing would emit
    /// undecryptable garbage. Distinct from [`Error::KeydbLoad`] (no keydb
    /// file at all): a keydb may be present but lack an entry for this disc.
    /// `disc_hash` is the 40-hex SHA1 of `Unit_Key_RO.inf` (no `0x` prefix)
    /// so the application can name the disc; empty if the hash wasn't
    /// captured at scan.
    NoDiscKey {
        disc_hash: String,
    },
    /// The disc is CSS-encrypted and decryption was requested, but the
    /// known-plaintext crack resolved no usable title key for the chosen
    /// title (e.g. a multi-VTS DVD where the title's VTS could not be
    /// re-cracked). Muxing would emit scrambled ciphertext, so the caller
    /// fails fast instead. CSS analogue of [`Error::NoDiscKey`].
    CssKeyMissing,
    /// The live-drive AACS cert-auth handshake (the OEM/AACS baseline route)
    /// could not run because NO host certificate was available from any key
    /// source. Host certs are keysource-served, never compiled in, so without
    /// a keysource that supplies one the OEM route fails gracefully here — this
    /// is the intended outcome, not a panic. Resolution still proceeds with a
    /// zero Volume ID and relies on the path-1 disc-hash → VUK lookup, so the
    /// error is dropped when that lookup hits. `path` carries the sentinel
    /// `<no host cert>` (mirroring [`Error::KeydbLoad`]'s sentinel) so a CLI can
    /// render "No Host Certs Found."
    AacsNoHostCert {
        path: String,
    },
    /// A bus-encrypted disc (AACS 2.0 / UHD, Content Certificate bus-encryption
    /// bit set) was scanned on a live drive, the Volume ID was obtained, but no
    /// `read_data_key` (bus key) was produced — so the on-disc bytes are still
    /// bus-encrypted and would decrypt to garbage. The bus key is derivable ONLY
    /// from the AACS host-certificate cert-auth handshake; a VID-only OEM unlock
    /// path (which returns no bus key) is insufficient for such a disc. Surfaced
    /// instead of silently producing a corrupt rip. NOT raised for AACS 1.0 BD
    /// (no bus encryption, `read_data_key` legitimately absent) nor for
    /// file-backed (ISO) scans, where bus encryption was already removed at read
    /// time and no handshake runs.
    AacsBusKeyUnavailable,

    /// AACS 2.1 (FMTS) disc carries forensic variant segments, but no segment
    /// (variant) key is available to open them. Raised UPFRONT — before the mux —
    /// exactly like a missing unit key, so a 2.1 disc that would rip with holes is
    /// refused rather than silently producing a forensic-holed output. (The mux
    /// resolves the full forensic key set up front; a resolution gap fails here.)
    FmtsKeyMissing,

    // Keydb (8xxx)
    KeydbConnect {
        host: String,
    },
    KeydbHttp {
        status: u16,
    },
    KeydbInvalid,
    KeydbWrite {
        path: String,
    },
    KeydbParse,
    KeydbLoad {
        path: String,
    },
    /// A redirect (or the configured URL) targets a scheme this
    /// dependency-light HTTP client cannot fetch (e.g. `https://`).
    /// Carries the offending scheme for diagnostics.
    KeydbUnsupportedScheme {
        scheme: String,
    },
    /// The redirect chain exceeded the follow limit.
    KeydbTooManyRedirects,

    // Stream/mux (9xxx)
    StreamReadOnly,
    StreamWriteOnly,
    StreamUrlInvalid {
        url: String,
    },
    StreamUrlMissingPath {
        scheme: String,
    },
    StreamUrlMissingPort {
        addr: String,
    },
    /// A `network://` output host resolved to no connectable address —
    /// every resolved IP was loopback / private / link-local / multicast /
    /// unspecified. Carries the offending `host:port`. Re-checked at
    /// connect time to close the DNS-rebinding TOCTOU.
    NetworkAddrBlocked {
        addr: String,
    },
    /// A muxer's `finish()` was reached after zero frames were written, so
    /// the output would be a header-only container with no media. Surfaced
    /// (instead of writing a valid-but-empty file and reporting success) so
    /// a zero-frame mux — undecryptable input, a fully-unreadable title, or
    /// every frame dropped before the first keyframe — fails loudly. The
    /// `m2ts://` analogue of [`Error::MkvInvalid`]'s zero-frame guard.
    MuxEmpty,
    /// `mp4://` target title has no primary video track to mux.
    Mp4NoVideoTrack,
    /// `mp4://` source file is malformed/truncated — the MP4 demuxer failed.
    Mp4Invalid,
    /// `mp4://` video track is missing its `hvcC`/`avcC` configuration record.
    Mp4MissingCodecPrivate,
    PesFrameTooLarge {
        size: usize,
    },
    PesInvalidMagic,
    /// PES frame track index exceeds the 1-byte on-wire field (> 255).
    /// Carries the offending index. Distinct from [`Error::PesInvalidMagic`],
    /// which signals corrupt input on the read side.
    PesTrackTooLarge {
        track: usize,
    },
    IsoTooLarge {
        path: String,
    },
    NoMetadata,
    /// `disc://` URLs aren't openable through `input()` — callers must use
    /// `Drive::open() + Disc::scan() + DiscStream::new()` directly. This
    /// is a structural API constraint, not a parse failure.
    DiscUrlNotDirect,
    /// A non-empty `HEVCDecoderConfigurationRecord` (hvcC) was supplied to
    /// a muxer but failed to parse into any VPS/SPS/PPS NAL — emitting the
    /// stream without parameter sets would yield an undecodable result.
    HevcParamParse,
    /// A muxer `write_frame` / `set_codec_private` was given a track index
    /// beyond the configured PID/track count.
    MuxTrackRange {
        track: usize,
        tracks: usize,
    },
    /// The fragmented-MP4 sink cannot emit media — `moof`/`mdat` framing is
    /// not implemented. Surfaced instead of silently discarding samples.
    Fmp4Unimplemented,
    /// A worker thread in the threaded mux pipeline terminated without
    /// sending its terminal sentinel — i.e. it panicked or was dropped
    /// mid-stream. Surfaced so a parser/demux panic is never silently
    /// reported to the caller as a clean end-of-stream (which would
    /// truncate output without any error).
    DemuxThreadPanicked,
    /// A pipeline `join()` exceeded its deadline while waiting for the
    /// consumer thread to drain. The consumer is intentionally leaked;
    /// the caller should fall back to a degraded path.
    PipelineJoinTimeout,
    /// The pipeline consumer thread panicked. The original panic
    /// payload is not preserved (no English text in the library); it is
    /// logged at the panic site instead.
    PipelineConsumerPanicked,
    /// A pipeline producer's `send` failed because the consumer thread
    /// has already terminated (the receiver end is gone).
    SweepConsumerGone,
    /// A producer thread tried to hand work to its pipeline consumer
    /// (sweep / patch sink) but the consumer thread had already
    /// terminated (panicked or dropped the receiver). The producer
    /// surfaces this so the outer pass can abort cleanly instead of
    /// blocking on a dead channel.
    PipelineConsumerGone,
    /// READ CAPACITY(10) reported a last-LBA of `0xFFFFFFFF` — the SPC
    /// sentinel meaning "capacity exceeds 32-bit addressing". Adding 1 to
    /// derive the sector count would overflow `u32`. Reachable from
    /// disc-reported bytes and synthetic [`crate::sector::SectorSource`]
    /// fixtures.
    DiscCapacityOverflow,
    /// An extent fed to the prefetch producer has a `sector_count`
    /// whose trailing 1-2 sectors cannot form a complete AACS aligned
    /// unit (3 sectors / 6144 bytes). Emitting that tail as a
    /// standalone batch would hand the decrypt step a sub-unit chunk
    /// it silently leaves encrypted. The producer surfaces this rather
    /// than emit still-encrypted bytes.
    ExtentNotUnitAligned,
    /// An MPEG-TS packet under construction violated the 188-byte fixed
    /// size (over-long adaptation field, overflowing payload, or a
    /// short/mis-assembled packet). Indicates a muxer invariant break,
    /// not untrusted input — surfaced instead of writing a corrupt
    /// transport stream.
    M2tsPacketMalformed,
    /// READ CAPACITY transferred fewer than 4 bytes, or the decoded
    /// last-LBA + 1 overflowed `u32`. Either case means the capacity
    /// response is unusable; no English commentary.
    DiscCapacityMalformed,
    /// `--raw` was given with a `dir://` destination. An encrypted file
    /// tree is useless; raw bytes belong in `iso://`.
    DirRawRejected,
    /// `--multipass` was given with a `dir://` destination. `dir://` is
    /// 1-shot; recovery is the `iso://` multipass path's job.
    DirMultipassRejected,
    /// A non-disc (byte-stream) source was routed into `dir://`, which
    /// requires a filesystem (only `disc://` / `iso://` qualify).
    DirSourceUnsupported,
    /// The `dir://` target directory is non-empty and `--force` was not
    /// given. Mixing two discs' trees is refused by default.
    DirNotEmpty,
    /// The `dir://` target filesystem's free space is below the sum of
    /// the file extents to extract. Carries required / available bytes.
    DirInsufficientSpace {
        required: u64,
        available: u64,
    },
    /// Two distinct disc paths sanitize to the same host path. Surfaced
    /// as a hard error rather than a silent overwrite. Carries the
    /// colliding host component.
    DirNameCollision {
        host: String,
    },
    /// A `dir://` create_dir_all / file write / rename failed. Carries
    /// the underlying errno when present.
    DirWriteFailed {
        errno: Option<i32>,
    },
}

impl Error {
    pub fn code(&self) -> u16 {
        match self {
            Error::DeviceNotFound { .. } => E_DEVICE_NOT_FOUND,
            Error::DevicePermission { .. } => E_DEVICE_PERMISSION,
            Error::DeviceNotReady { .. } => E_DEVICE_NOT_READY,
            Error::DeviceResetFailed { .. } => E_DEVICE_RESET_FAILED,
            Error::ScsiInterfaceUnavailable { .. } => E_SCSI_INTERFACE_UNAVAILABLE,
            Error::DeviceLocked { .. } => E_DEVICE_LOCKED,
            Error::IoKitPluginFailed { .. } => E_IOKIT_PLUGIN_FAILED,
            Error::UnsupportedDrive { .. } => E_UNSUPPORTED_DRIVE,
            Error::ProfileParse => E_PROFILE_PARSE,
            Error::UnsupportedPlatform { .. } => E_UNSUPPORTED_PLATFORM,
            Error::PlatformNotImplemented { .. } => E_PLATFORM_NOT_IMPLEMENTED,
            Error::UnlockFailed => E_UNLOCK_FAILED,
            Error::SignatureMismatch { .. } => E_SIGNATURE_MISMATCH,
            Error::ScsiError { .. } => E_SCSI_ERROR,
            Error::InvalidCdbLength { .. } => E_INVALID_CDB_LENGTH,
            Error::IoError { .. } => E_IO_ERROR,
            Error::DiscRead { .. } => E_DISC_READ,
            Error::Halted => E_HALTED,
            Error::MplsParse => E_MPLS_PARSE,
            Error::ClpiParse => E_CLPI_PARSE,
            Error::UdfNotFound { .. } => E_UDF_NOT_FOUND,
            Error::UdfNotFilesystem => E_UDF_NOT_FILESYSTEM,
            Error::UdfBufferTooSmall => E_UDF_BUFFER_TOO_SMALL,
            Error::DiscTitleRange { .. } => E_DISC_TITLE_RANGE,
            Error::IfoParse => E_IFO_PARSE,
            Error::MkvInvalid => E_MKV_INVALID,
            Error::NoStreams => E_NO_STREAMS,
            Error::SelectionPidUnknown { .. } => E_SELECTION_PID_UNKNOWN,
            Error::MapfileInvalid { .. } => E_MAPFILE_INVALID,
            Error::AacsNoKeys => E_AACS_NO_KEYS,
            Error::AacsCertShort => E_AACS_CERT_SHORT,
            Error::AacsAgidAlloc => E_AACS_AGID_ALLOC,
            Error::AacsCertRejected => E_AACS_CERT_REJECTED,
            Error::AacsCertRead => E_AACS_CERT_READ,
            Error::AacsCertVerify => E_AACS_CERT_VERIFY,
            Error::AacsKeyRead => E_AACS_KEY_READ,
            Error::AacsKeyRejected => E_AACS_KEY_REJECTED,
            Error::AacsKeyVerify => E_AACS_KEY_VERIFY,
            Error::AacsVidRead => E_AACS_VID_READ,
            Error::AacsVidMac => E_AACS_VID_MAC,
            Error::AacsDataKey => E_AACS_DATA_KEY,
            Error::DecryptFailed => E_DECRYPT_FAILED,
            Error::CssAuthFailed => E_CSS_AUTH_FAILED,
            Error::AacsHostCertRejected => E_AACS_HOST_CERT_REJECTED,
            Error::AacsRawReadUnsupported => E_AACS_RAW_READ_UNSUPPORTED,
            Error::AacsVidUnavailable => E_AACS_VID_UNAVAILABLE,
            Error::AacsMkUnavailable => E_AACS_MK_UNAVAILABLE,
            Error::AacsVukNotInKeydb => E_AACS_VUK_NOT_IN_KEYDB,
            Error::DriveProfileMissing => E_DRIVE_PROFILE_MISSING,
            Error::VidCdbUnavailable => E_VID_CDB_UNAVAILABLE,
            Error::NoDiscKey { .. } => E_NO_DISC_KEY,
            Error::CssKeyMissing => E_CSS_KEY_MISSING,
            Error::AacsNoHostCert { .. } => E_AACS_NO_HOST_CERT,
            Error::AacsBusKeyUnavailable => E_AACS_BUS_KEY_UNAVAILABLE,
            Error::FmtsKeyMissing => E_FMTS_KEY_MISSING,
            Error::KeydbConnect { .. } => E_KEYDB_CONNECT,
            Error::KeydbHttp { .. } => E_KEYDB_HTTP,
            Error::KeydbInvalid => E_KEYDB_INVALID,
            Error::KeydbWrite { .. } => E_KEYDB_WRITE,
            Error::KeydbParse => E_KEYDB_PARSE,
            Error::KeydbLoad { .. } => E_KEYDB_LOAD,
            Error::KeydbUnsupportedScheme { .. } => E_KEYDB_UNSUPPORTED_SCHEME,
            Error::KeydbTooManyRedirects => E_KEYDB_TOO_MANY_REDIRECTS,
            Error::StreamReadOnly => E_STREAM_READ_ONLY,
            Error::StreamWriteOnly => E_STREAM_WRITE_ONLY,
            Error::StreamUrlInvalid { .. } => E_STREAM_URL_INVALID,
            Error::StreamUrlMissingPath { .. } => E_STREAM_URL_MISSING_PATH,
            Error::StreamUrlMissingPort { .. } => E_STREAM_URL_MISSING_PORT,
            Error::NetworkAddrBlocked { .. } => E_NETWORK_ADDR_BLOCKED,
            Error::MuxEmpty => E_MUX_EMPTY,
            Error::Mp4NoVideoTrack => E_MP4_NO_VIDEO_TRACK,
            Error::Mp4Invalid => E_MP4_INVALID,
            Error::Mp4MissingCodecPrivate => E_MP4_MISSING_CODEC_PRIVATE,
            Error::PesFrameTooLarge { .. } => E_PES_FRAME_TOO_LARGE,
            Error::PesInvalidMagic => E_PES_INVALID_MAGIC,
            Error::PesTrackTooLarge { .. } => E_PES_TRACK_TOO_LARGE,
            Error::IsoTooLarge { .. } => E_ISO_TOO_LARGE,
            Error::NoMetadata => E_NO_METADATA,
            Error::DiscUrlNotDirect => E_DISC_URL_NOT_DIRECT,
            Error::HevcParamParse => E_HEVC_PARAM_PARSE,
            Error::MuxTrackRange { .. } => E_MUX_TRACK_RANGE,
            Error::Fmp4Unimplemented => E_FMP4_UNIMPLEMENTED,
            Error::DemuxThreadPanicked => E_DEMUX_THREAD_PANICKED,
            Error::PipelineJoinTimeout => E_PIPELINE_JOIN_TIMEOUT,
            Error::PipelineConsumerPanicked => E_PIPELINE_CONSUMER_PANICKED,
            Error::SweepConsumerGone => E_SWEEP_CONSUMER_GONE,
            Error::PipelineConsumerGone => E_PIPELINE_CONSUMER_GONE,
            Error::DiscCapacityOverflow => E_DISC_CAPACITY_OVERFLOW,
            Error::ExtentNotUnitAligned => E_EXTENT_NOT_UNIT_ALIGNED,
            Error::M2tsPacketMalformed => E_M2TS_PACKET_MALFORMED,
            Error::DiscCapacityMalformed => E_DISC_CAPACITY_MALFORMED,
            Error::DirRawRejected => E_DIR_RAW_REJECTED,
            Error::DirMultipassRejected => E_DIR_MULTIPASS_REJECTED,
            Error::DirSourceUnsupported => E_DIR_SOURCE_UNSUPPORTED,
            Error::DirNotEmpty => E_DIR_NOT_EMPTY,
            Error::DirInsufficientSpace { .. } => E_DIR_INSUFFICIENT_SPACE,
            Error::DirNameCollision { .. } => E_DIR_NAME_COLLISION,
            Error::DirWriteFailed { .. } => E_DIR_WRITE_FAILED,
        }
    }
}

/// Display: "E{code}" with structured data. No English words.
impl std::fmt::Display for Error {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Error::DeviceNotFound { path } => write!(f, "E{}: {}", self.code(), path),
            Error::DevicePermission { path } => write!(f, "E{}: {}", self.code(), path),
            Error::DeviceNotReady { path } => write!(f, "E{}: {}", self.code(), path),
            Error::DeviceResetFailed { path } => write!(f, "E{}: {}", self.code(), path),
            Error::ScsiInterfaceUnavailable { path } => write!(f, "E{}: {}", self.code(), path),
            Error::DeviceLocked { path, kr } => {
                write!(f, "E{}: {} 0x{:08x}", self.code(), path, kr)
            }
            Error::IoKitPluginFailed { path, kr } => {
                write!(f, "E{}: {} 0x{:08x}", self.code(), path, kr)
            }
            Error::UnsupportedPlatform { target } => {
                write!(f, "E{}: {}", self.code(), target)
            }
            Error::PlatformNotImplemented { platform } => {
                write!(f, "E{}: {}", self.code(), platform)
            }
            Error::MapfileInvalid { kind } => {
                write!(f, "E{}: {}", self.code(), kind)
            }
            Error::UnsupportedDrive {
                vendor_id,
                product_id,
                product_revision,
            } => write!(
                f,
                "E{}: {} {} {}",
                self.code(),
                vendor_id.trim(),
                product_id.trim(),
                product_revision.trim()
            ),
            Error::SignatureMismatch { expected, got } => write!(
                f,
                "E{}: {:02x}{:02x}{:02x}{:02x}!={:02x}{:02x}{:02x}{:02x}",
                self.code(),
                expected[0],
                expected[1],
                expected[2],
                expected[3],
                got[0],
                got[1],
                got[2],
                got[3]
            ),
            Error::ScsiError {
                opcode,
                status,
                sense,
            } => match sense {
                Some(s) => write!(
                    f,
                    "E{}: 0x{:02x}/0x{:02x}/0x{:02x}/0x{:02x}/0x{:02x}",
                    self.code(),
                    opcode,
                    status,
                    s.sense_key,
                    s.asc,
                    s.ascq,
                ),
                None => write!(f, "E{}: 0x{:02x}/0x{:02x}", self.code(), opcode, status,),
            },
            // Language-neutral: std::io::Error's Display is English
            // ("permission denied"); emit the raw OS errno when present,
            // else the ErrorKind debug name (an identifier, not prose).
            Error::IoError { source } => match source.raw_os_error() {
                Some(errno) => write!(f, "E{}: {}", self.code(), errno),
                None => write!(f, "E{}: {:?}", self.code(), source.kind()),
            },
            Error::DiscRead {
                sector,
                status,
                sense,
            } => match (status, sense) {
                (Some(st), Some(s)) => write!(
                    f,
                    "E{}: {} 0x{:02x}/0x{:02x}/0x{:02x}/0x{:02x}",
                    self.code(),
                    sector,
                    st,
                    s.sense_key,
                    s.asc,
                    s.ascq,
                ),
                (Some(st), None) => write!(f, "E{}: {} 0x{:02x}", self.code(), sector, st,),
                (None, Some(s)) => write!(
                    f,
                    "E{}: {} 0x{:02x}/0x{:02x}/0x{:02x}",
                    self.code(),
                    sector,
                    s.sense_key,
                    s.asc,
                    s.ascq,
                ),
                (None, None) => write!(f, "E{}: {}", self.code(), sector),
            },
            Error::Halted => write!(f, "E{}", self.code()),
            Error::UdfNotFound { path } => write!(f, "E{}: {}", self.code(), path),
            Error::DiscTitleRange { index, count } => {
                write!(f, "E{}: {}/{}", self.code(), index, count)
            }
            Error::KeydbConnect { host } => write!(f, "E{}: {}", self.code(), host),
            Error::KeydbHttp { status } => write!(f, "E{}: {}", self.code(), status),
            Error::KeydbWrite { path } => write!(f, "E{}: {}", self.code(), path),
            Error::KeydbLoad { path } => write!(f, "E{}: {}", self.code(), path),
            Error::AacsNoHostCert { path } => write!(f, "E{}: {}", self.code(), path),
            Error::KeydbUnsupportedScheme { scheme } => {
                write!(f, "E{}: {}", self.code(), scheme)
            }
            Error::StreamUrlInvalid { url } => write!(f, "E{}: {}", self.code(), url),
            Error::StreamUrlMissingPath { scheme } => write!(f, "E{}: {}", self.code(), scheme),
            Error::StreamUrlMissingPort { addr } => write!(f, "E{}: {}", self.code(), addr),
            Error::NetworkAddrBlocked { addr } => write!(f, "E{}: {}", self.code(), addr),
            Error::PesFrameTooLarge { size } => write!(f, "E{}: {}", self.code(), size),
            Error::PesTrackTooLarge { track } => write!(f, "E{}: {}", self.code(), track),
            Error::IsoTooLarge { path } => write!(f, "E{}: {}", self.code(), path),
            Error::NoDiscKey { disc_hash } => {
                if disc_hash.is_empty() {
                    write!(f, "E{}", self.code())
                } else {
                    write!(f, "E{}: {}", self.code(), disc_hash)
                }
            }
            Error::MuxTrackRange { track, tracks } => {
                write!(f, "E{}: {}/{}", self.code(), track, tracks)
            }
            Error::InvalidCdbLength { len, max } => {
                write!(f, "E{}: {}/{}", self.code(), len, max)
            }
            Error::SelectionPidUnknown { pid } => {
                write!(f, "E{}: 0x{:04x}", self.code(), pid)
            }
            _ => write!(f, "E{}", self.code()),
        }
    }
}

impl std::error::Error for Error {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            Error::IoError { source } => Some(source),
            _ => None,
        }
    }
}

impl From<std::io::Error> for Error {
    fn from(e: std::io::Error) -> Self {
        Error::IoError { source: e }
    }
}

impl From<Error> for std::io::Error {
    fn from(e: Error) -> Self {
        // An `Error::IoError` is just a wrapper around an underlying
        // `io::Error` that entered via `From<io::Error> for Error`.
        // Round-trip it back unchanged so the original `ErrorKind` and
        // raw OS error code survive instead of being flattened to
        // `Other` with a stringified message.
        if let Error::IoError { source } = e {
            return source;
        }
        let code = e.code();
        let msg = e.to_string();
        // Map our error categories to io::ErrorKind
        let kind = match code {
            // Device access-denied semantics map to PermissionDenied;
            // the rest of the 1xxx block is "device absent" -> NotFound.
            E_DEVICE_PERMISSION | E_DEVICE_LOCKED => std::io::ErrorKind::PermissionDenied,
            1000..=1999 => std::io::ErrorKind::NotFound,
            2000..=2999 => std::io::ErrorKind::Unsupported,
            3000..=3999 => std::io::ErrorKind::PermissionDenied,
            4000..=4999 => std::io::ErrorKind::Other,
            5000..=5999 => std::io::ErrorKind::Other,
            6000..=6999 => std::io::ErrorKind::InvalidData,
            7000..=7999 => std::io::ErrorKind::PermissionDenied,
            8000..=8999 => std::io::ErrorKind::Other,
            9000..=9001 => std::io::ErrorKind::Unsupported,
            9002..=9008 => std::io::ErrorKind::InvalidInput,
            // 9009 DiscUrlNotDirect: structurally unsupported entry point,
            // not a parse failure — caller used the wrong API.
            9009 => std::io::ErrorKind::Unsupported,
            // 9010 HevcParamParse: malformed hvcC payload.
            9010 => std::io::ErrorKind::InvalidData,
            // 9011 MuxTrackRange: caller passed a bad track index.
            9011 => std::io::ErrorKind::InvalidInput,
            // 9012 Fmp4Unimplemented: sink can't emit media yet.
            9012 => std::io::ErrorKind::Unsupported,
            // 9014 PipelineJoinTimeout: consumer drain exceeded deadline.
            E_PIPELINE_JOIN_TIMEOUT => std::io::ErrorKind::TimedOut,
            // 9017 PesTrackTooLarge: out-of-range track index on serialize.
            9017 => std::io::ErrorKind::InvalidInput,
            // 9020 DiscCapacityOverflow: disc reported a capacity sentinel
            // we can't represent — treat as bad/invalid device data.
            9020 => std::io::ErrorKind::InvalidData,
            // 9021 M2tsPacketMalformed: a muxer invariant break produced
            // a non-188-byte packet — treat as invalid data.
            9021 => std::io::ErrorKind::InvalidData,
            // 9022 NetworkAddrBlocked: the output host resolved only to
            // blocked (loopback/private/link-local) addresses — refuse.
            E_NETWORK_ADDR_BLOCKED => std::io::ErrorKind::PermissionDenied,
            // 9023 MuxEmpty: finish() reached with zero frames — the output
            // would be a header-only container. Treat as invalid output.
            E_MUX_EMPTY => std::io::ErrorKind::InvalidData,
            // mp4:// demux errors: a malformed/truncated source file
            // (E_MP4_INVALID), or a source whose tracks the mux can't use — no
            // video track / missing codec-private config. All are invalid data.
            E_MP4_NO_VIDEO_TRACK | E_MP4_INVALID | E_MP4_MISSING_CODEC_PRIVATE => {
                std::io::ErrorKind::InvalidData
            }
            // 9030 ExtentNotUnitAligned: a malformed/non-AACS-aligned
            // extent was handed to the prefetch producer.
            9030 => std::io::ErrorKind::InvalidInput,
            // 9047 DiscCapacityMalformed: the drive returned an unusable
            // READ CAPACITY response (short transfer / overflow).
            9047 => std::io::ErrorKind::InvalidData,
            // dir:// usage / footgun gates (9019, 9024–9026, 9028): the caller
            // gave an invalid flag/source/name combination — InvalidInput.
            E_DIR_RAW_REJECTED
            | E_DIR_MULTIPASS_REJECTED
            | E_DIR_SOURCE_UNSUPPORTED
            | E_DIR_NOT_EMPTY
            | E_DIR_NAME_COLLISION => std::io::ErrorKind::InvalidInput,
            // 9027 insufficient space / 9029 write failed: a filesystem-level
            // failure, not bad input.
            E_DIR_INSUFFICIENT_SPACE | E_DIR_WRITE_FAILED => std::io::ErrorKind::Other,
            _ => std::io::ErrorKind::Other,
        };
        std::io::Error::new(kind, msg)
    }
}

/// Convenience alias for `Result<T, Error>`.
pub type Result<T> = std::result::Result<T, Error>;

/// The numeric error code carried by an [`io::Error`](std::io::Error) that was
/// produced from an [`Error`], or `None` if it carries none.
///
/// [`From<Error> for io::Error`] is the ONLY path from a typed [`Error`] to an
/// `io::Error` in this crate, and it stringifies (`io::Error::new(kind, msg)`
/// where `msg` is the `Error`'s `E<code>[: …]` [`Display`](std::fmt::Display)
/// string) rather than boxing the typed value — no code path constructs an
/// `io::Error` that still holds a `crate::error::Error` via `get_ref`. So the
/// only recognised shape is the round-tripped `E<code>` message prefix.
fn io_error_code(e: &std::io::Error) -> Option<u16> {
    // Round-tripped: `From<Error> for io::Error` stringifies as "E<code>[: …]".
    let s = e.to_string();
    let digits = s.strip_prefix('E')?;
    let end = digits
        .find(|c: char| !c.is_ascii_digit())
        .unwrap_or(digits.len());
    digits.get(..end)?.parse::<u16>().ok()
}

/// Whether a per-title mux failure is a *skippable title stub* — a
/// copy-protected-but-uncrackable title ([`Error::CssKeyMissing`]) or a title
/// that produced no muxable frames ([`Error::MkvInvalid`], an empty nav/menu
/// PGC stub). An all-titles rip skips such a title and finishes the rest;
/// every other error stays fatal.
///
/// This replaces the CLI's `E7023`/`E6008` string-match with a typed check on
/// the [`io::Error`](std::io::Error) `mux_stream` returns.
pub fn is_skippable_title_stub(e: &std::io::Error) -> bool {
    matches!(io_error_code(e), Some(E_MKV_INVALID | E_CSS_KEY_MISSING))
}

/// Whether an [`io::Error`](std::io::Error) is a cooperative user stop
/// ([`Error::Halted`], code [`E_HALTED`]) — vs a structural failure. A stop is
/// resumable, not a rip failure: `mux_stream` maps a mid-run halt to
/// `completed = false`, and consumers preserve staging rather than quarantining.
/// Typed replacement for the consumers' `E<code>`-leading-token string match.
pub fn is_halt(e: &std::io::Error) -> bool {
    io_error_code(e) == Some(E_HALTED)
}

/// Whether an [`io::Error`](std::io::Error) is a **disc-level** key failure —
/// the disc as a whole cannot be decrypted, so EVERY title will fail the same
/// way. Distinct from a per-title skippable stub
/// ([`is_skippable_title_stub`]): `E_NO_DISC_KEY` (keydb present but no entry
/// for this disc), `E_KEYDB_LOAD` (no keydb at all), and `E_AACS_NO_KEYS` (no
/// usable AACS key material) are all whole-disc conditions. A multi-title rip
/// loop should stop immediately on this (fail-fast) rather than iterate every
/// title re-printing the same error.
pub fn is_disc_level_no_key(e: &std::io::Error) -> bool {
    matches!(
        io_error_code(e),
        Some(E_NO_DISC_KEY | E_KEYDB_LOAD | E_AACS_NO_KEYS)
    )
}

impl Error {
    /// Borrow the drive-returned SPC-4 sense triple if this error is a
    /// [`Error::ScsiError`] carrying sense data. `None` for any other
    /// variant **and** for `ScsiError`s that represent a transport-layer
    /// failure (where the device never delivered a SCSI status reply, so
    /// no sense data exists).
    pub fn scsi_sense(&self) -> Option<&crate::scsi::ScsiSense> {
        match self {
            Error::ScsiError { sense: Some(s), .. } => Some(s),
            Error::DiscRead { sense: Some(s), .. } => Some(s),
            _ => None,
        }
    }

    /// True if this is a [`Error::ScsiError`] representing a transport-layer
    /// failure — kernel timeout, USB bridge wedge, IOKit service error.
    /// The device never delivered a SCSI status reply, so there is no
    /// sense data to inspect; retrying typically requires physical
    /// intervention (replug).
    pub fn is_scsi_transport_failure(&self) -> bool {
        matches!(
            self,
            Error::ScsiError {
                status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
                ..
            }
        ) || matches!(
            self,
            Error::DiscRead {
                status: Some(crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE),
                ..
            }
        ) || matches!(
            // A failed `ioctl(SG_IO)` (Error::IoError, e.g. ENODEV/EIO on an
            // unplugged USB bridge) and a vanished device (Error::DeviceNotFound,
            // fd gone) are dead-bus / transport-layer faults too — NOT recoverable
            // bad sectors. Treat them as transport failures so sweep / patch /
            // fill_extents abort the pass and re-enumerate the bridge instead of
            // zero-filling every read against a wedged device.
            self,
            Error::IoError { .. } | Error::DeviceNotFound { .. }
        )
    }

    /// True if this error indicates bridge degradation — the SCSI status
    /// is neither GOOD (0x00), CHECK CONDITION (0x02), nor transport failure
    /// (0xFF). Observed on the Initio INIC-1618L USB bridge preceding a full
    /// crash: the bridge firmware returns non-standard status bytes (e.g.
    /// 0x04, 0x05) with empty sense data. The caller should cool down
    /// (10 s pause) and retry rather than hammering the bridge.
    pub fn is_bridge_degradation(&self) -> bool {
        let status = match self {
            Error::ScsiError { status, .. } => *status,
            Error::DiscRead { status, .. } => status.unwrap_or(0),
            _ => return false,
        };
        status != crate::scsi::SCSI_STATUS_GOOD
            && status != crate::scsi::SCSI_STATUS_CHECK_CONDITION
            && status != crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE
    }

    /// True if the underlying SCSI failure is a *marginal read* — the
    /// drive returned an error category in which smaller-granularity
    /// retries can sometimes recover the data:
    ///
    ///   - MEDIUM ERROR (sense key 3) — canonical bad-sector signal
    ///   - ABORTED COMMAND (sense key B) — transient; retry usually works
    ///   - NOT READY (sense key 2) — the dominant bad-sector response on
    ///     the BU40N (ASC 0x04/ASCQ 0x3E); a pause + retry often recovers
    ///   - RECOVERED ERROR (sense key 1) / NO SENSE (sense key 0) — not
    ///     classified as fatal; treat as recoverable
    ///
    /// Returns `false` for transport failures (no sense data delivered),
    /// HARDWARE ERROR, DATA PROTECT, UNIT ATTENTION, ILLEGAL
    /// REQUEST, BLANK CHECK, kernel `IoError`, and any non-SCSI variant.
    /// Caller-agnostic predicate — describes a property of the *error*,
    /// not what one specific call site should do with it. Used by
    /// `Disc::copy`'s hysteresis dispatch.
    pub fn is_marginal_read(&self) -> bool {
        self.scsi_sense()
            .map(crate::scsi::ScsiSense::is_marginal)
            .unwrap_or(false)
    }
}

#[cfg(test)]
mod tests {
    //! Smoke tests for the error code → variant mapping. Each new variant
    //! added in 0.13.0 (English-elimination work) gets a code() check + a
    //! Display sanity-check (no English words) + an io::ErrorKind mapping
    //! check. Without these, future drift between the const codes and the
    //! match arms in `code()` / the From impl could silently miscategorize.
    use super::*;

    #[test]
    fn is_skippable_title_stub_matches_only_the_two_stub_codes() {
        // The two skippable per-title stub codes, round-tripped through io::Error
        // exactly as `mux_stream` returns them.
        let mkv: std::io::Error = Error::MkvInvalid.into();
        let css: std::io::Error = Error::CssKeyMissing.into();
        assert!(is_skippable_title_stub(&mkv));
        assert!(is_skippable_title_stub(&css));

        // A different coded error is NOT skippable (kills a "match anything with
        // an E-code" mutant).
        let nostreams: std::io::Error = Error::NoStreams.into();
        assert!(!is_skippable_title_stub(&nostreams));

        // A plain io::Error with no E-code prefix is not skippable.
        let plain = std::io::Error::from(std::io::ErrorKind::BrokenPipe);
        assert!(!is_skippable_title_stub(&plain));
    }

    #[test]
    fn new_variants_have_distinct_codes() {
        let codes = [
            Error::ScsiInterfaceUnavailable { path: "p".into() }.code(),
            Error::DeviceLocked {
                path: "p".into(),
                kr: 0,
            }
            .code(),
            Error::IoKitPluginFailed {
                path: "p".into(),
                kr: 0,
            }
            .code(),
            Error::UnsupportedPlatform { target: "x".into() }.code(),
            Error::PlatformNotImplemented {
                platform: "renesas".into(),
            }
            .code(),
            Error::MapfileInvalid { kind: "hex" }.code(),
            Error::DiscUrlNotDirect.code(),
            Error::ExtentNotUnitAligned.code(),
            Error::M2tsPacketMalformed.code(),
            Error::DiscCapacityMalformed.code(),
            Error::DirRawRejected.code(),
            Error::DirMultipassRejected.code(),
            Error::DirSourceUnsupported.code(),
            Error::DirNotEmpty.code(),
            Error::DirInsufficientSpace {
                required: 1,
                available: 0,
            }
            .code(),
            Error::DirNameCollision { host: "x".into() }.code(),
            Error::DirWriteFailed { errno: Some(28) }.code(),
        ];
        let mut sorted = codes.to_vec();
        sorted.sort();
        sorted.dedup();
        assert_eq!(
            sorted.len(),
            codes.len(),
            "two new variants share a code — check error.rs constants"
        );
    }

    #[test]
    fn display_emits_no_english_words() {
        // Every variant's Display must be `E{code}: {data}` — no English.
        // Sample a few of the new variants and a few existing ones to
        // catch accidental string-stuffing in future edits.
        let cases: &[(Error, u16)] = &[
            (
                Error::ScsiInterfaceUnavailable {
                    path: "/dev/sg4".into(),
                },
                E_SCSI_INTERFACE_UNAVAILABLE,
            ),
            (
                Error::DeviceLocked {
                    path: "/dev/sg4".into(),
                    kr: 0xE00002C5,
                },
                E_DEVICE_LOCKED,
            ),
            (
                Error::UnsupportedPlatform {
                    target: "freebsd".into(),
                },
                E_UNSUPPORTED_PLATFORM,
            ),
            (
                Error::PlatformNotImplemented {
                    platform: "renesas".into(),
                },
                E_PLATFORM_NOT_IMPLEMENTED,
            ),
            (Error::MapfileInvalid { kind: "hex" }, E_MAPFILE_INVALID),
            (Error::DiscUrlNotDirect, E_DISC_URL_NOT_DIRECT),
            (Error::ExtentNotUnitAligned, E_EXTENT_NOT_UNIT_ALIGNED),
        ];
        for (e, want_code) in cases {
            let s = e.to_string();
            assert!(
                s.starts_with(&format!("E{}", want_code)),
                "{:?} display does not lead with code: {}",
                e,
                s
            );
            // Crude English filter — `Display` should never emit ASCII words
            // longer than 4 chars (codes/paths/identifiers like `/dev/sg4`,
            // `renesas`, `freebsd` all pass; "exclusive access denied" would
            // not).
            for word in s.split(|c: char| !c.is_ascii_alphabetic()) {
                assert!(
                    word.len() <= 8,
                    "Display contains suspicious English-looking word `{word}` in `{s}`"
                );
            }
        }
    }

    #[test]
    fn iokind_mapping_for_new_variants() {
        use std::io::ErrorKind;
        let mapped = |e: Error| -> ErrorKind {
            let io: std::io::Error = e.into();
            io.kind()
        };
        // 1xxx "device absent" → NotFound
        assert_eq!(
            mapped(Error::ScsiInterfaceUnavailable { path: "p".into() }),
            ErrorKind::NotFound
        );
        assert_eq!(
            mapped(Error::DeviceNotFound { path: "p".into() }),
            ErrorKind::NotFound
        );
        // 1xxx access-denied semantics → PermissionDenied (not NotFound)
        assert_eq!(
            mapped(Error::DevicePermission { path: "p".into() }),
            ErrorKind::PermissionDenied
        );
        assert_eq!(
            mapped(Error::DeviceLocked {
                path: "p".into(),
                kr: 0
            }),
            ErrorKind::PermissionDenied
        );
        // 2xxx range → Unsupported
        assert_eq!(
            mapped(Error::UnsupportedPlatform { target: "x".into() }),
            ErrorKind::Unsupported
        );
        assert_eq!(
            mapped(Error::PlatformNotImplemented {
                platform: "x".into()
            }),
            ErrorKind::Unsupported
        );
        // 6xxx range → InvalidData
        assert_eq!(
            mapped(Error::MapfileInvalid { kind: "hex" }),
            ErrorKind::InvalidData
        );
        // 9009 special-cased to Unsupported
        assert_eq!(mapped(Error::DiscUrlNotDirect), ErrorKind::Unsupported);
        // 9021 special-cased to InvalidData
        assert_eq!(mapped(Error::M2tsPacketMalformed), ErrorKind::InvalidData);
        // 9047 DiscCapacityMalformed → InvalidData
        assert_eq!(mapped(Error::DiscCapacityMalformed), ErrorKind::InvalidData);
    }

    /// `Error::IoError` must round-trip back to the *original*
    /// `io::Error` — preserving its `ErrorKind` and raw OS error —
    /// rather than being flattened to `Other` with a stringified
    /// message.
    #[test]
    fn ioerror_roundtrips_preserving_kind_and_oscode() {
        use std::io::ErrorKind;
        let original = std::io::Error::from_raw_os_error(13); // EACCES
        let original_kind = original.kind();
        let wrapped: Error = original.into(); // From<io::Error> for Error
        let back: std::io::Error = wrapped.into(); // From<Error> for io::Error
        assert_eq!(back.kind(), original_kind);
        assert_eq!(back.raw_os_error(), Some(13));

        // A synthesized kind (no OS code) must also survive.
        let timeout: Error = std::io::Error::from(ErrorKind::TimedOut).into();
        let back2: std::io::Error = timeout.into();
        assert_eq!(back2.kind(), ErrorKind::TimedOut);
    }

    /// `DiscRead` Display must include the ASCQ byte (the 5th field) so
    /// NOT_READY substates (0x04/0x3E vs 0x04/0x01) are distinguishable
    /// in logs and bug reports.
    #[test]
    fn discread_display_includes_ascq() {
        let e = Error::DiscRead {
            sector: 42,
            status: Some(0x02),
            sense: Some(crate::scsi::ScsiSense {
                sense_key: 0x02,
                asc: 0x04,
                ascq: 0x3e,
            }),
        };
        let s = e.to_string();
        // sense_key/asc/ascq triple all present.
        assert!(s.contains("0x02/0x04/0x3e"), "ascq missing from `{s}`");
    }

    /// `NoDiscKey` with an empty hash must not emit a dangling
    /// "colon space" suffix.
    #[test]
    fn nodisckey_empty_hash_has_no_trailing_colon() {
        let e = Error::NoDiscKey {
            disc_hash: String::new(),
        };
        assert_eq!(e.to_string(), format!("E{}", E_NO_DISC_KEY));
        let e2 = Error::NoDiscKey {
            disc_hash: "abc".into(),
        };
        assert_eq!(e2.to_string(), format!("E{}: abc", E_NO_DISC_KEY));
    }

    // ── New comprehensive tests ────────────────────────────────────────────────

    /// Every published error code constant must be unique.
    /// This pins all code assignments: a new variant that accidentally reuses
    /// an existing code will make this test fail.
    /// Mutation: changing E_KEYDB_PARSE from 8004 to 8000 (duplicating E_KEYDB_CONNECT) fails here.
    #[test]
    fn all_error_code_constants_are_unique() {
        let mut codes = vec![
            E_DEVICE_NOT_FOUND,
            E_DEVICE_PERMISSION,
            E_DEVICE_NOT_READY,
            E_DEVICE_RESET_FAILED,
            E_SCSI_INTERFACE_UNAVAILABLE,
            E_DEVICE_LOCKED,
            E_IOKIT_PLUGIN_FAILED,
            E_UNSUPPORTED_DRIVE,
            E_PROFILE_PARSE,
            E_UNSUPPORTED_PLATFORM,
            E_PLATFORM_NOT_IMPLEMENTED,
            E_UNLOCK_FAILED,
            E_SIGNATURE_MISMATCH,
            E_SCSI_ERROR,
            E_INVALID_CDB_LENGTH,
            E_IO_ERROR,
            E_DISC_READ,
            E_MPLS_PARSE,
            E_CLPI_PARSE,
            E_UDF_NOT_FOUND,
            E_DISC_TITLE_RANGE,
            E_IFO_PARSE,
            E_MKV_INVALID,
            E_NO_STREAMS,
            E_HALTED,
            E_MAPFILE_INVALID,
            E_UDF_BUFFER_TOO_SMALL,
            E_UDF_NOT_FILESYSTEM,
            E_AACS_NO_KEYS,
            E_AACS_CERT_SHORT,
            E_AACS_AGID_ALLOC,
            E_AACS_CERT_REJECTED,
            E_AACS_CERT_READ,
            E_AACS_CERT_VERIFY,
            E_AACS_KEY_READ,
            E_AACS_KEY_REJECTED,
            E_AACS_KEY_VERIFY,
            E_AACS_VID_READ,
            E_AACS_VID_MAC,
            E_AACS_DATA_KEY,
            E_DECRYPT_FAILED,
            E_CSS_AUTH_FAILED,
            E_AACS_HOST_CERT_REJECTED,
            E_AACS_RAW_READ_UNSUPPORTED,
            E_AACS_VID_UNAVAILABLE,
            E_AACS_MK_UNAVAILABLE,
            E_AACS_VUK_NOT_IN_KEYDB,
            E_DRIVE_PROFILE_MISSING,
            E_VID_CDB_UNAVAILABLE,
            E_NO_DISC_KEY,
            E_CSS_KEY_MISSING,
            E_AACS_NO_HOST_CERT,
            E_AACS_BUS_KEY_UNAVAILABLE,
            E_FMTS_KEY_MISSING,
            E_KEYDB_CONNECT,
            E_KEYDB_HTTP,
            E_KEYDB_INVALID,
            E_KEYDB_WRITE,
            E_KEYDB_PARSE,
            E_KEYDB_LOAD,
            E_KEYDB_UNSUPPORTED_SCHEME,
            E_KEYDB_TOO_MANY_REDIRECTS,
            E_STREAM_READ_ONLY,
            E_STREAM_WRITE_ONLY,
            E_STREAM_URL_INVALID,
            E_STREAM_URL_MISSING_PATH,
            E_STREAM_URL_MISSING_PORT,
            E_NETWORK_ADDR_BLOCKED,
            E_MUX_EMPTY,
            E_MP4_NO_VIDEO_TRACK,
            E_MP4_INVALID,
            E_MP4_MISSING_CODEC_PRIVATE,
            E_PES_FRAME_TOO_LARGE,
            E_PES_INVALID_MAGIC,
            E_PES_TRACK_TOO_LARGE,
            E_ISO_TOO_LARGE,
            E_NO_METADATA,
            E_DISC_URL_NOT_DIRECT,
            E_HEVC_PARAM_PARSE,
            E_MUX_TRACK_RANGE,
            E_FMP4_UNIMPLEMENTED,
            E_DEMUX_THREAD_PANICKED,
            E_PIPELINE_JOIN_TIMEOUT,
            E_PIPELINE_CONSUMER_PANICKED,
            E_SWEEP_CONSUMER_GONE,
            E_PIPELINE_CONSUMER_GONE,
            E_DISC_CAPACITY_OVERFLOW,
            E_M2TS_PACKET_MALFORMED,
            E_EXTENT_NOT_UNIT_ALIGNED,
            E_DISC_CAPACITY_MALFORMED,
        ];
        let original_len = codes.len();
        codes.sort();
        codes.dedup();
        assert_eq!(
            codes.len(),
            original_len,
            "duplicate error code constants detected — check error.rs"
        );
    }

    /// Error code ranges match their documented category buckets.
    /// E.g. all device codes are 1000–1999, all AACS codes are 7000–7999.
    /// Mutation: accidentally shifting a constant out of its range (e.g. E_DEVICE_NOT_FOUND = 2000)
    ///           breaks CLI range-based dispatch and logging.
    #[test]
    fn error_code_range_buckets_are_correct() {
        // Device (1xxx)
        assert!((1000..2000).contains(&E_DEVICE_NOT_FOUND));
        assert!((1000..2000).contains(&E_DEVICE_PERMISSION));
        assert!((1000..2000).contains(&E_SCSI_INTERFACE_UNAVAILABLE));
        // Profile (2xxx)
        assert!((2000..3000).contains(&E_UNSUPPORTED_DRIVE));
        assert!((2000..3000).contains(&E_PROFILE_PARSE));
        // Unlock (3xxx)
        assert!((3000..4000).contains(&E_UNLOCK_FAILED));
        assert!((3000..4000).contains(&E_SIGNATURE_MISMATCH));
        // SCSI (4xxx)
        assert!((4000..5000).contains(&E_SCSI_ERROR));
        // I/O (5xxx)
        assert!((5000..6000).contains(&E_IO_ERROR));
        // Disc format (6xxx)
        assert!((6000..7000).contains(&E_DISC_READ));
        assert!((6000..7000).contains(&E_HALTED));
        assert!((6000..7000).contains(&E_MAPFILE_INVALID));
        // AACS (7xxx)
        assert!((7000..8000).contains(&E_AACS_NO_KEYS));
        assert!((7000..8000).contains(&E_NO_DISC_KEY));
        // Keydb (8xxx)
        assert!((8000..9000).contains(&E_KEYDB_CONNECT));
        assert!((8000..9000).contains(&E_KEYDB_TOO_MANY_REDIRECTS));
        // Stream/mux (9xxx)
        assert!((9000..10000).contains(&E_STREAM_READ_ONLY));
        assert!((9000..10000).contains(&E_DISC_CAPACITY_MALFORMED));
    }

    /// Error.code() matches its associated constant for every new 9xxx variant.
    /// Mutation: swapping two adjacent code() arms (e.g. SweepConsumerGone ↔
    ///           PipelineConsumerGone) makes the wrong code appear in logs.
    #[test]
    fn error_code_matches_constant_for_stream_variants() {
        use std::io::ErrorKind;
        let cases: &[(Error, u16)] = &[
            (Error::StreamReadOnly, E_STREAM_READ_ONLY),
            (Error::StreamWriteOnly, E_STREAM_WRITE_ONLY),
            (Error::PesInvalidMagic, E_PES_INVALID_MAGIC),
            (Error::NoMetadata, E_NO_METADATA),
            (Error::DiscUrlNotDirect, E_DISC_URL_NOT_DIRECT),
            (Error::HevcParamParse, E_HEVC_PARAM_PARSE),
            (Error::Fmp4Unimplemented, E_FMP4_UNIMPLEMENTED),
            (Error::DemuxThreadPanicked, E_DEMUX_THREAD_PANICKED),
            (
                Error::PipelineConsumerPanicked,
                E_PIPELINE_CONSUMER_PANICKED,
            ),
            (Error::SweepConsumerGone, E_SWEEP_CONSUMER_GONE),
            (Error::PipelineConsumerGone, E_PIPELINE_CONSUMER_GONE),
            (Error::DiscCapacityOverflow, E_DISC_CAPACITY_OVERFLOW),
            (Error::MuxEmpty, E_MUX_EMPTY),
            (Error::Mp4NoVideoTrack, E_MP4_NO_VIDEO_TRACK),
            (Error::Mp4Invalid, E_MP4_INVALID),
            (Error::Mp4MissingCodecPrivate, E_MP4_MISSING_CODEC_PRIVATE),
            (Error::M2tsPacketMalformed, E_M2TS_PACKET_MALFORMED),
            (Error::ExtentNotUnitAligned, E_EXTENT_NOT_UNIT_ALIGNED),
            (Error::DiscCapacityMalformed, E_DISC_CAPACITY_MALFORMED),
            (
                Error::NetworkAddrBlocked {
                    addr: String::new(),
                },
                E_NETWORK_ADDR_BLOCKED,
            ),
        ];
        for (e, expected_code) in cases {
            assert_eq!(
                e.code(),
                *expected_code,
                "{:?}.code() must equal {} (const)",
                e,
                expected_code
            );
        }
        // io::ErrorKind mapping spot-check for 9xxx variants.
        let to_kind = |e: Error| -> ErrorKind {
            let io: std::io::Error = e.into();
            io.kind()
        };
        assert_eq!(to_kind(Error::StreamReadOnly), ErrorKind::Unsupported);
        assert_eq!(to_kind(Error::HevcParamParse), ErrorKind::InvalidData);
        assert_eq!(to_kind(Error::Fmp4Unimplemented), ErrorKind::Unsupported);
        assert_eq!(to_kind(Error::PipelineJoinTimeout), ErrorKind::TimedOut);
        assert_eq!(
            to_kind(Error::ExtentNotUnitAligned),
            ErrorKind::InvalidInput
        );
    }

    /// Error.code() for AACS variants matches their constants.
    /// Mutation: swapping E_AACS_CERT_READ and E_AACS_CERT_VERIFY codes
    ///           makes the wrong diagnostic appear in the UI.
    #[test]
    fn error_code_matches_constant_for_aacs_variants() {
        let aacs_cases: &[(Error, u16)] = &[
            (Error::AacsNoKeys, E_AACS_NO_KEYS),
            (Error::AacsCertShort, E_AACS_CERT_SHORT),
            (Error::AacsAgidAlloc, E_AACS_AGID_ALLOC),
            (Error::AacsCertRejected, E_AACS_CERT_REJECTED),
            (Error::AacsCertRead, E_AACS_CERT_READ),
            (Error::AacsCertVerify, E_AACS_CERT_VERIFY),
            (Error::AacsKeyRead, E_AACS_KEY_READ),
            (Error::AacsKeyRejected, E_AACS_KEY_REJECTED),
            (Error::AacsKeyVerify, E_AACS_KEY_VERIFY),
            (Error::AacsVidRead, E_AACS_VID_READ),
            (Error::AacsVidMac, E_AACS_VID_MAC),
            (Error::AacsDataKey, E_AACS_DATA_KEY),
            (Error::DecryptFailed, E_DECRYPT_FAILED),
            (Error::CssAuthFailed, E_CSS_AUTH_FAILED),
            (Error::AacsHostCertRejected, E_AACS_HOST_CERT_REJECTED),
            (Error::AacsRawReadUnsupported, E_AACS_RAW_READ_UNSUPPORTED),
            (Error::AacsVidUnavailable, E_AACS_VID_UNAVAILABLE),
            (Error::AacsMkUnavailable, E_AACS_MK_UNAVAILABLE),
            (Error::AacsVukNotInKeydb, E_AACS_VUK_NOT_IN_KEYDB),
            (Error::DriveProfileMissing, E_DRIVE_PROFILE_MISSING),
            (Error::VidCdbUnavailable, E_VID_CDB_UNAVAILABLE),
        ];
        for (e, expected_code) in aacs_cases {
            assert_eq!(
                e.code(),
                *expected_code,
                "{:?}.code() must be {}",
                e,
                expected_code
            );
        }
    }

    /// is_scsi_transport_failure is true for the 0xFF SCSI sentinel AND for the
    /// non-SCSI dead-bus faults (Error::IoError from a failed ioctl(SG_IO),
    /// Error::DeviceNotFound from a vanished fd) — but NEVER for a real SCSI
    /// reply (CHECK CONDITION) or unrelated errors.
    /// Mutation: testing against 0x02 (CHECK CONDITION) would wrongly mark CHECK
    ///           CONDITION replies as transport failures; dropping the IoError/
    ///           DeviceNotFound arm would let a dead bus zero-fill the disc.
    #[test]
    fn is_scsi_transport_failure_only_for_0xff() {
        use crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE;
        // True: transport failure sentinel.
        let tf = Error::ScsiError {
            opcode: 0x28,
            status: SCSI_STATUS_TRANSPORT_FAILURE,
            sense: None,
        };
        assert!(tf.is_scsi_transport_failure());

        // False: CHECK CONDITION is a real SCSI reply, not a transport failure.
        let cc = Error::ScsiError {
            opcode: 0x28,
            status: crate::scsi::SCSI_STATUS_CHECK_CONDITION,
            sense: Some(crate::scsi::ScsiSense {
                sense_key: 0x03,
                asc: 0x11,
                ascq: 0x00,
            }),
        };
        assert!(!cc.is_scsi_transport_failure());

        // True: non-SCSI dead-bus faults — a failed ioctl(SG_IO) and a vanished
        // device are transport-layer failures, not recoverable bad sectors.
        assert!(
            Error::IoError {
                source: std::io::Error::from(std::io::ErrorKind::NotConnected)
            }
            .is_scsi_transport_failure()
        );
        assert!(
            Error::DeviceNotFound {
                path: "/dev/sg9".into()
            }
            .is_scsi_transport_failure()
        );

        // False for unrelated errors.
        assert!(!Error::Halted.is_scsi_transport_failure());
    }

    /// is_marginal_read returns true for MEDIUM ERROR (3), NOT READY (2),
    /// ABORTED COMMAND (B), RECOVERED ERROR (1), NO SENSE (0).
    /// Spec: comment on is_marginal_read lists these five sense keys.
    /// Mutation: removing NOT_READY from the marginal set means BU40N "bad sector"
    ///           responses are treated as fatal instead of retriable.
    #[test]
    fn is_marginal_read_sense_key_coverage() {
        use crate::scsi::ScsiSense;
        let marginal_keys = [
            0x00, // NO SENSE
            0x01, // RECOVERED ERROR
            0x02, // NOT READY — dominant BU40N bad-sector sense key
            0x03, // MEDIUM ERROR — canonical bad sector
            0x0B, // ABORTED COMMAND
        ];
        for sk in marginal_keys {
            let e = Error::ScsiError {
                opcode: 0x28,
                status: crate::scsi::SCSI_STATUS_CHECK_CONDITION,
                sense: Some(ScsiSense {
                    sense_key: sk,
                    asc: 0x11,
                    ascq: 0x00,
                }),
            };
            assert!(
                e.is_marginal_read(),
                "sense_key=0x{:02x} must be marginal",
                sk
            );
        }
        // Non-marginal keys: HARDWARE ERROR (4), ILLEGAL REQUEST (5),
        // UNIT ATTENTION (6), DATA PROTECT (7), BLANK CHECK (8).
        let non_marginal_keys = [0x04, 0x05, 0x06, 0x07, 0x08];
        for sk in non_marginal_keys {
            let e = Error::ScsiError {
                opcode: 0x28,
                status: crate::scsi::SCSI_STATUS_CHECK_CONDITION,
                sense: Some(ScsiSense {
                    sense_key: sk,
                    asc: 0x00,
                    ascq: 0x00,
                }),
            };
            assert!(
                !e.is_marginal_read(),
                "sense_key=0x{:02x} must NOT be marginal",
                sk
            );
        }
    }

    /// is_bridge_degradation returns true for a status byte that is not GOOD,
    /// CHECK CONDITION, or TRANSPORT_FAILURE.
    /// Spec: comment says "bridge firmware returns non-standard status bytes
    ///       (e.g. 0x04, 0x05) with empty sense data."
    /// Mutation: checking only for 0x04 misses 0x05 and other degradation bytes.
    #[test]
    fn is_bridge_degradation_detects_non_standard_status() {
        use crate::scsi::{
            SCSI_STATUS_CHECK_CONDITION, SCSI_STATUS_GOOD, SCSI_STATUS_TRANSPORT_FAILURE,
        };
        // 0x04 and 0x05 are non-standard bridge degradation codes.
        for bad_status in [0x04u8, 0x05, 0x08, 0x10] {
            let e = Error::ScsiError {
                opcode: 0x28,
                status: bad_status,
                sense: None,
            };
            assert!(
                e.is_bridge_degradation(),
                "status=0x{:02x} must be bridge degradation",
                bad_status
            );
        }
        // Standard codes must NOT be classified as bridge degradation.
        assert!(
            !Error::ScsiError {
                opcode: 0x28,
                status: SCSI_STATUS_GOOD,
                sense: None
            }
            .is_bridge_degradation()
        );
        assert!(
            !Error::ScsiError {
                opcode: 0x28,
                status: SCSI_STATUS_CHECK_CONDITION,
                sense: None
            }
            .is_bridge_degradation()
        );
        assert!(
            !Error::ScsiError {
                opcode: 0x28,
                status: SCSI_STATUS_TRANSPORT_FAILURE,
                sense: None
            }
            .is_bridge_degradation()
        );
    }

    /// scsi_sense returns Some for ScsiError with sense and DiscRead with sense.
    /// Mutation: only checking ScsiError misses DiscRead sense data.
    #[test]
    fn scsi_sense_from_disc_read() {
        use crate::scsi::ScsiSense;
        let sense = ScsiSense {
            sense_key: 0x02,
            asc: 0x04,
            ascq: 0x3e,
        };
        let disc_read = Error::DiscRead {
            sector: 12345,
            status: Some(0x02),
            sense: Some(sense),
        };
        let got = disc_read.scsi_sense().unwrap();
        assert_eq!(got.sense_key, 0x02);
        assert_eq!(got.asc, 0x04);
        assert_eq!(got.ascq, 0x3e);

        // Non-SCSI errors return None.
        assert!(Error::Halted.scsi_sense().is_none());
        assert!(Error::NoMetadata.scsi_sense().is_none());
    }

    /// Display for SignatureMismatch includes both expected and got bytes in hex.
    /// Mutation: printing only `expected` without `got` makes the mismatch undiscoverable.
    #[test]
    fn signature_mismatch_display_includes_both_sides() {
        let e = Error::SignatureMismatch {
            expected: [0xAA, 0xBB, 0xCC, 0xDD],
            got: [0x11, 0x22, 0x33, 0x44],
        };
        let s = e.to_string();
        // Must include the E-code prefix.
        assert!(
            s.starts_with(&format!("E{}", E_SIGNATURE_MISMATCH)),
            "must start with code: {s}"
        );
        // Must include the expected bytes.
        assert!(s.contains("aabbccdd"), "must contain expected bytes: {s}");
        // Must include the got bytes.
        assert!(s.contains("11223344"), "must contain got bytes: {s}");
        // Must use '!=' as the separator between expected and got.
        assert!(s.contains("!="), "must use '!=' separator: {s}");
    }

    /// DiscTitleRange display format is "E6005: index/count".
    /// Mutation: swapping index and count in the format string makes logs misleading.
    #[test]
    fn disc_title_range_display_is_index_slash_count() {
        let e = Error::DiscTitleRange {
            index: 3,
            count: 10,
        };
        let expected = format!("E{}: 3/10", E_DISC_TITLE_RANGE);
        assert_eq!(e.to_string(), expected);
    }

    /// Keydb error variants display correctly with their structured data.
    /// Mutation: using a generic "E{code}" fallback drops the host/path data from logs.
    #[test]
    fn keydb_errors_include_structured_data_in_display() {
        let e_connect = Error::KeydbConnect {
            host: "mirror.example".into(),
        };
        assert!(
            e_connect.to_string().contains("mirror.example"),
            "KeydbConnect display must include host"
        );

        let e_http = Error::KeydbHttp { status: 403 };
        assert!(
            e_http.to_string().contains("403"),
            "KeydbHttp display must include status code"
        );

        let e_write = Error::KeydbWrite {
            path: "/root/.config/freemkv/keydb.cfg".into(),
        };
        assert!(
            e_write.to_string().contains("/root"),
            "KeydbWrite display must include path"
        );

        let e_load = Error::KeydbLoad {
            path: "<no keydb in search paths>".into(),
        };
        assert!(
            e_load.to_string().contains("<no keydb in search paths>"),
            "KeydbLoad display must include the sentinel path"
        );

        let e_no_cert = Error::AacsNoHostCert {
            path: "<no host cert>".into(),
        };
        assert!(
            e_no_cert.to_string().contains("<no host cert>"),
            "AacsNoHostCert display must include the sentinel path"
        );

        let e_scheme = Error::KeydbUnsupportedScheme {
            scheme: "ftp".into(),
        };
        assert!(
            e_scheme.to_string().contains("ftp"),
            "KeydbUnsupportedScheme display must include scheme"
        );
    }

    /// MuxTrackRange display format is "E9011: track/tracks".
    /// Mutation: formatting as "track/count" or "tracks/track" is wrong.
    #[test]
    fn mux_track_range_display_is_track_slash_tracks() {
        let e = Error::MuxTrackRange {
            track: 5,
            tracks: 3,
        };
        let expected = format!("E{}: 5/3", E_MUX_TRACK_RANGE);
        assert_eq!(e.to_string(), expected);
    }
}
