//! Disc structure -- scan titles, streams, and sector ranges from a Blu-ray disc.
//!
//! This is the high-level API for disc content. The CLI calls this,
//! never parses MPLS/CLPI/UDF directly.
//!
//! Usage:
//!   let disc = Disc::scan(&mut session)?;
//!   for title in disc.titles() { ... }
//!   for stream in title.streams() { ... }

mod bluray;
mod dvd;
pub(crate) mod dvd_audio_probe;
mod encrypt;
mod extract;
mod hddvd;
pub(crate) mod pgs_forced_probe;

use crate::drive::Drive;
use crate::error::{Error, Result};
use crate::sector::SectorSource;
use crate::udf;

use encrypt::HandshakeResult;

// Re-export label classification enums alongside AudioStream / SubtitleStream
// so the public surface keeps the structured metadata together. Callers map
// these to display text in their own locale.
pub use crate::labels::{LabelPurpose, LabelQualifier};
pub use extract::{ExtractOptions, ExtractResult, FileResult};

// ─── Public types ───────────────────────────────────────────────────────────

/// A scanned Blu-ray disc.
#[derive(Debug)]
pub struct Disc {
    /// UDF Volume Identifier from Primary Volume Descriptor (always present)
    pub volume_id: String,
    /// Disc title from META/DL/bdmt_eng.xml (None if disc has no metadata)
    pub meta_title: Option<String>,
    /// Disc format (BD, UHD, DVD)
    pub format: DiscFormat,
    /// Disc capacity in sectors
    pub capacity_sectors: u32,
    /// Disc capacity in bytes
    pub capacity_bytes: u64,
    /// Number of layers (1 = single, 2 = dual)
    pub layers: u8,
    /// Titles sorted by duration (longest first), then playlist name
    pub titles: Vec<DiscTitle>,
    /// Disc region
    pub region: DiscRegion,
    /// AACS state -- None if disc is unencrypted or keys unavailable
    pub aacs: Option<AacsState>,
    /// CSS state -- None if not a CSS-encrypted DVD
    pub css: Option<crate::css::CssState>,
    /// Whether this disc requires decryption (AACS or CSS)
    pub encrypted: bool,
    /// AACS resolution error when `encrypted` is true and `aacs` is None.
    /// Lets callers distinguish "no KEYDB found", "KEYDB failed to parse",
    /// "disc hash not in KEYDB", etc. None when AACS resolution wasn't
    /// attempted (unencrypted disc) or succeeded.
    pub aacs_error: Option<crate::error::Error>,
    /// CSS crack failure: `Some(Error::CssKeyMissing)` when the scan SAW
    /// scrambled sectors but could NOT recover a title key (the
    /// known-plaintext attack found no crackable crib, or the scrambled
    /// region was unreadable). `css` is `None` in that case — but the disc is
    /// genuinely encrypted, so callers MUST surface this hard error rather
    /// than treat `css.is_none()` as "unencrypted" and mux scrambled MPEG as
    /// plaintext garbage. `None` when no scrambled sector was seen (genuinely
    /// unencrypted) or a key was recovered (`css.is_some()`). The CSS analogue
    /// of [`Self::aacs_error`].
    pub css_error: Option<crate::error::Error>,
    /// Content format (BD transport stream vs DVD program stream)
    pub content_format: ContentFormat,
}

/// Content format — determines how sectors are interpreted downstream.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ContentFormat {
    /// Blu-ray BD Transport Stream (192-byte packets)
    BdTs,
    /// MPEG-2 Program Stream — DVD (`.vob`) and HD-DVD (`.evo`). For AACS content
    /// this selects the PS-aware encrypted-flag / structural checks.
    MpegPs,
}

/// Disc format.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum DiscFormat {
    /// 4K UHD Blu-ray (HEVC 2160p)
    Uhd,
    /// UHD Blu-ray with AACS 2.1 FMTS content — the main feature is a `.fmts`
    /// clip (M2TS transport stream plus interleaved forensic variant segments).
    /// A BD-tree disc (enumerated by [`Disc::scan_bluray_titles`]); distinct
    /// from [`DiscFormat::Uhd`] only in the container + AACS generation.
    Fmts,
    /// Standard Blu-ray (1080p/1080i)
    BluRay,
    /// HD-DVD — `HVDVD_TS/` tree with `.evo` (Enhanced VOB, MPEG program stream)
    /// clips. A tree-level peer of DVD/BD, enumerated by its own scanner.
    HdDvd,
    /// DVD
    Dvd,
    /// Unknown
    Unknown,
}

/// Disc playback region.
#[derive(Debug, Clone, PartialEq)]
pub enum DiscRegion {
    /// Region-free (all UHD discs, some BD/DVD)
    Free,
    /// Blu-ray regions (A/B/C or combination)
    BluRay(Vec<BdRegion>),
    /// DVD regions (1-8 or combination)
    Dvd(Vec<u8>),
}

/// Blu-ray region codes.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum BdRegion {
    /// Region A/1 -- Americas, East Asia (Japan, Korea, Southeast Asia)
    A,
    /// Region B/2 -- Europe, Africa, Australia, Middle East
    B,
    /// Region C/3 -- Central/South Asia, China, Russia
    C,
}

/// A title (one MPLS playlist).
#[derive(Debug, Clone)]
pub struct DiscTitle {
    /// Playlist filename (e.g. "00800.mpls")
    pub playlist: String,
    /// Playlist number (e.g. 800)
    pub playlist_id: u16,
    /// Duration in seconds
    pub duration_secs: f64,
    /// Total size in bytes
    pub size_bytes: u64,
    /// Clip references in playback order
    pub clips: Vec<Clip>,
    /// All streams (video, audio, subtitle, etc.)
    pub streams: Vec<Stream>,
    /// Chapter points
    pub chapters: Vec<Chapter>,
    /// Sector extents for ripping (clip LBA ranges)
    pub extents: Vec<Extent>,
    /// Content format for this title
    pub content_format: ContentFormat,
    /// Codec initialization data per stream (SPS/PPS, etc).
    /// Index matches `streams`. None for streams without codec init data.
    pub codec_privates: Vec<Option<Vec<u8>>>,
}

/// A clip reference within a title.
#[derive(Debug, Clone)]
pub struct Clip {
    /// Clip filename without extension (e.g. "00001")
    pub clip_id: String,
    /// In-time in 45kHz ticks
    pub in_time: u32,
    /// Out-time in 45kHz ticks
    pub out_time: u32,
    /// Duration in seconds
    pub duration_secs: f64,
    /// Source packet count (from CLPI, 0 if unavailable)
    pub source_packets: u32,
}

/// A stream within a title.
#[derive(Debug, Clone)]
pub enum Stream {
    Video(VideoStream),
    Audio(AudioStream),
    Subtitle(SubtitleStream),
}

/// A video stream.
#[derive(Debug, Clone)]
pub struct VideoStream {
    /// MPEG-TS packet ID
    pub pid: u16,
    /// Codec (HEVC, H.264, VC-1, MPEG-2)
    pub codec: Codec,
    /// Resolution
    pub resolution: Resolution,
    /// Frame rate
    pub frame_rate: FrameRate,
    /// HDR format
    pub hdr: HdrFormat,
    /// Color space
    pub color_space: ColorSpace,
    /// Intended display aspect ratio as `(num, den)` when the coded pixels are
    /// **anamorphic** (display shape ≠ pixel grid) — e.g. DVD 720x576 shown as
    /// 16:9 → `Some((16, 9))`. `None` means square pixels: the display aspect
    /// equals the pixel dimensions (HD/UHD, BD). Consumed by the MKV muxer to
    /// write DisplayWidth/DisplayHeight; passthrough muxers (TS/M2TS) ignore it
    /// because the aspect already lives in the elementary stream.
    pub display_aspect: Option<(u32, u32)>,
    /// Whether this is a secondary stream (PiP, Dolby Vision EL)
    pub secondary: bool,
    /// Extra label (e.g. "Dolby Vision EL")
    pub label: String,
    /// CICP colour signalling (matrix, transfer, primaries, full_range) MEASURED
    /// from the bitstream — HEVC/H.264 VUI `colour_description` or MPEG-2
    /// `sequence_display_extension`. `Some(...)` takes precedence over the
    /// coarse `color_space` enum (a playlist nibble / PAL-NTSC guess); `None`
    /// means the bitstream did not state it, so the enum-derived triplet is used.
    /// Codes are ITU-T H.273 (CICP); `range` is 1 = limited/TV, 2 = full.
    pub measured_cicp: Option<MeasuredCicp>,
}

/// Label marking a video stream as the Blu-ray 3D **MVC dependent (right-eye)
/// view** — the paired substream of the AVC base view. Set by the BD scan
/// (`bluray.rs`) and recognised by the mux path (`resolve.rs` builds its parser
/// in param-set-preserving mode; `mkvstream.rs` folds it into the base track as
/// per-frame `BlockAdditional`). Single source of truth for the contract.
pub const MVC_DEPENDENT_LABEL: &str = "MVC dependent view (3D right eye)";

impl VideoStream {
    /// Whether this video stream is the MVC dependent (right-eye) view — the
    /// 3D substream that the muxer merges into the base track as a per-frame
    /// `BlockAdditional` rather than emitting as an independent track.
    pub fn is_mvc_dependent(&self) -> bool {
        self.label == MVC_DEPENDENT_LABEL
    }
}

/// Measured CICP colour signalling read directly from a video elementary stream
/// (ITU-T H.273). Preferred over the coarse [`ColorSpace`] enum when present.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MeasuredCicp {
    /// MatrixCoefficients (ITU-T H.273 Table 4).
    pub matrix: u8,
    /// TransferCharacteristics (ITU-T H.273 Table 3).
    pub transfer: u8,
    /// ColourPrimaries (ITU-T H.273 Table 2).
    pub primaries: u8,
    /// Range: 1 = limited (studio/TV), 2 = full. Matroska Colour/Range values.
    pub range: u8,
}

/// An audio stream.
#[derive(Debug, Clone)]
pub struct AudioStream {
    /// MPEG-TS packet ID
    pub pid: u16,
    /// Codec (TrueHD, DTS-HD MA, DD, LPCM, etc.)
    pub codec: Codec,
    /// Channel layout
    pub channels: AudioChannels,
    /// ISO 639-2 language code (e.g. "eng", "fra")
    pub language: String,
    /// Sample rate
    pub sample_rate: SampleRate,
    /// Whether this is a secondary stream (commentary)
    pub secondary: bool,
    /// Stream purpose (commentary / descriptive / score / IME / normal).
    /// Callers translate this to display text in their own locale.
    pub purpose: LabelPurpose,
    /// Codec / variant text (e.g. "Dolby TrueHD 5.1", "(US)").
    /// NEVER contains English purpose words — see `purpose` for that.
    pub label: String,
}

/// A subtitle stream.
#[derive(Debug, Clone)]
pub struct SubtitleStream {
    /// MPEG-TS packet ID
    pub pid: u16,
    /// Codec (PGS)
    pub codec: Codec,
    /// ISO 639-2 language code (e.g. "eng", "fra")
    pub language: String,
    /// Whether this is a forced subtitle
    pub forced: bool,
    /// Subtitle qualifier (SDH / descriptive service / forced / none).
    /// Callers translate this to display text in their own locale.
    pub qualifier: LabelQualifier,
    /// Pre-formatted codec private data (e.g. VobSub .idx palette header)
    pub codec_data: Option<Vec<u8>>,
}

/// Video/audio codec.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Codec {
    // Video
    Hevc,
    H264,
    Vc1,
    Mpeg2,
    Mpeg1,
    Av1,
    // Audio
    TrueHd,
    DtsHdMa,
    DtsHdHr,
    Dts,
    Ac3,
    Ac3Plus,
    Lpcm,
    Aac,
    Mp2,
    Mp3,
    Flac,
    Opus,
    // Subtitle
    Pgs,
    DvdSub,
    Srt,
    Ssa,
    // Unknown
    Unknown(u8),
}

/// Video resolution.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Resolution {
    /// 480i (720x480 interlaced) — NTSC DVD
    R480i,
    /// 480p (720x480 progressive)
    R480p,
    /// 576i (720x576 interlaced) — PAL DVD
    R576i,
    /// 576p (720x576 progressive)
    R576p,
    /// 720p (1280x720 progressive) — some Blu-rays
    R720p,
    /// 1080i (1920x1080 interlaced) — broadcast, some BD
    R1080i,
    /// 1080p (1920x1080 progressive) — standard Blu-ray
    R1080p,
    /// 2160p (3840x2160 progressive) — 4K UHD Blu-ray
    R2160p,
    /// 4320p (7680x4320 progressive) — 8K, future-proof
    R4320p,
    /// Unknown resolution
    Unknown,
}

/// Video frame rate.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum FrameRate {
    /// 23.976 fps — film-based BD/UHD (NTSC pulldown)
    F23_976,
    /// 24.000 fps — true film rate
    F24,
    /// 25.000 fps — PAL standard
    F25,
    /// 29.970 fps — NTSC standard
    F29_97,
    /// 30.000 fps
    F30,
    /// 50.000 fps — PAL high frame rate
    F50,
    /// 59.940 fps — NTSC high frame rate
    F59_94,
    /// 60.000 fps
    F60,
    /// Unknown frame rate
    Unknown,
}

/// Audio channel layout.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AudioChannels {
    /// 1.0 mono
    Mono,
    /// 2.0 stereo
    Stereo,
    /// 2.1 (stereo + LFE)
    Stereo21,
    /// 4.0 quadraphonic
    Quad,
    /// 5.0 surround (no LFE)
    Surround50,
    /// 5.1 surround — standard BD/DVD surround
    Surround51,
    /// 6.1 surround (DTS-ES, Dolby EX)
    Surround61,
    /// 7.1 surround — UHD Atmos beds, DTS:X
    Surround71,
    /// Unknown channel layout
    Unknown,
}

/// Audio sample rate.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SampleRate {
    /// 44.1 kHz — CD audio (rare on disc)
    S44_1,
    /// 48 kHz — standard BD/DVD/UHD audio
    S48,
    /// 88.2 kHz — 44.1 kHz-family high-res TrueHD (music BD)
    S88_2,
    /// 96 kHz — high-res BD audio
    S96,
    /// 176.4 kHz — 44.1 kHz-family high-res TrueHD (music BD)
    S176_4,
    /// 192 kHz — highest BD audio (LPCM)
    S192,
    /// 48/96 kHz combo (secondary audio resampled)
    S48_96,
    /// 48/192 kHz combo (secondary audio resampled)
    S48_192,
    /// Unknown sample rate
    Unknown,
}

/// HDR format.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum HdrFormat {
    Sdr,
    Hdr10,
    Hdr10Plus,
    DolbyVision,
    Hlg,
}

/// Color space.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ColorSpace {
    Bt709,
    Bt2020,
    /// SD PAL/576-line colorimetry (ITU-R BT.470 System B/G — primaries 5,
    /// transfer 5, matrix 5). DVDs are SD, not HD: stamping BT.709 mis-tags
    /// their colour.
    Bt470bg,
    /// SD NTSC/480-line colorimetry (SMPTE 170M / BT.601-525 — primaries 6,
    /// transfer 6, matrix 6).
    Smpte170m,
    Unknown,
}

/// A chapter point within a title.
#[derive(Debug, Clone)]
pub struct Chapter {
    /// Chapter start time in seconds
    pub time_secs: f64,
    /// Chapter name — a bare 1-based index ("1", "2", …). The library
    /// emits no localized prose; consuming apps prepend any "Chapter "
    /// prefix in the user's language.
    pub name: String,
}

/// Default chapter name for the 0-based chapter index `i`: the bare
/// 1-based ordinal as a string. Keeps chapter labelling language-neutral
/// (apps localize) and gives BD and DVD a single source of truth.
pub(crate) fn chapter_name(i: usize) -> String {
    (i + 1).to_string()
}

/// A contiguous range of sectors on disc.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Extent {
    pub start_lba: u32,
    pub sector_count: u32,
}

/// Union a set of extents into sorted, merged, disjoint `(start_lba,
/// sector_count)` ranges — the pure, testable core of
/// [`Disc::encrypted_content_ranges`]. Reuses [`crate::udf::merge_ranges`].
fn merged_extents<'a>(extents: impl Iterator<Item = &'a Extent>) -> Vec<(u32, u32)> {
    let mut ranges: Vec<(u32, u32)> = extents.map(|e| (e.start_lba, e.sector_count)).collect();
    ranges.sort_by_key(|r| r.0);
    crate::udf::merge_ranges(&ranges)
}

/// Correct a title's TrueHD audio-stream metadata by probing the first
/// decrypted access units — channel count, real sample rate, and Atmos
/// detection in a single major-sync read. The MPLS descriptors declare the BASE
/// layout (often 5.1 / a container-guessed rate) even for a 7.1/Atmos TrueHD
/// track; the truth is in the MLP major sync. `reader` must yield DECRYPTED
/// sectors (the m2ts is AACS-encrypted, so this can only run at mux time, not
/// scan). Reads a bounded window of the title's first extent.
///
/// Corrections, each individually guarded so a malformed field never writes a
/// wrong header:
/// - **Channels**: from the presentation channel masks (as before).
/// - **Sample rate**: from the whitelisted rate nibble; left untouched on an
///   unknown rate or no major sync.
/// - **Atmos**: when a 4th substream is detected AND the stream still carries
///   the basic descriptor label, the label is promoted to the Atmos form;
///   richer editorial labels (e.g. an existing "Dolby Atmos") are left intact.
pub(crate) fn correct_truehd_channels(reader: &mut dyn SectorSource, title: &mut DiscTitle) {
    use crate::mux::codec::truehd::{
        truehd_channels, truehd_sample_rate_hz, truehd_sync_info_from_stream,
    };

    let pids: Vec<u16> = title
        .streams
        .iter()
        .filter_map(|s| match s {
            Stream::Audio(a) if matches!(a.codec, Codec::TrueHd) => Some(a.pid),
            _ => None,
        })
        .collect();
    if pids.is_empty() {
        return;
    }
    let Some(ext) = title.extents.first() else {
        return;
    };
    // Bounded probe: up to 8 MiB from the start of the title — enough for the
    // first interleaved TrueHD major sync of each stream.
    const PROBE_SECTORS: u32 = 4096;
    let n = ext.sector_count.min(PROBE_SECTORS) as u16;
    if n == 0 {
        return;
    }
    let mut buf = vec![0u8; n as usize * 2048];
    // Anchor the AACS unit-alignment gate to the title's encrypted-region start
    // before probing. Without this a `DecryptingSectorSource` falls back to an
    // absolute `start_lba % 3` gate; a non-3-aligned `ext.start_lba` then trips
    // DecryptFailed on the very first probe read, so the TrueHD channel count is
    // never corrected and Atmos / 7.1 is silently understated as 5.1. No-op for
    // CSS / unencrypted sources (set_unit_base default is a no-op).
    reader.set_unit_base(ext.start_lba);
    if reader
        .read_sectors(ext.start_lba, n, &mut buf, true)
        .is_err()
    {
        return;
    }

    let mut demux = crate::mux::ts::TsDemuxer::new(&pids);
    let mut payloads: std::collections::HashMap<u16, Vec<u8>> = std::collections::HashMap::new();
    for pes in demux.feed(&buf).into_iter().chain(demux.flush()) {
        payloads
            .entry(pes.pid)
            .or_default()
            .extend_from_slice(&pes.data);
    }

    for s in title.streams.iter_mut() {
        let Stream::Audio(a) = s else { continue };
        if !matches!(a.codec, Codec::TrueHd) {
            continue;
        }
        let Some(payload) = payloads.get(&a.pid) else {
            continue;
        };
        // One major-sync read yields channels, sample rate and the Atmos signal.
        let Some(info) = truehd_sync_info_from_stream(payload) else {
            continue;
        };

        // Whether the label is still the plain descriptor (no richer editorial
        // label). Captured against the CURRENT channels before any correction so
        // a label promotion only happens when nothing editorial is present.
        let was_basic =
            a.label == crate::labels::generate_audio_label(&a.codec, &a.channels, a.secondary);

        // (1) Channels — only when the major sync resolves a different layout.
        if let Some(count) = truehd_channels(info.format_info) {
            let new_ch = AudioChannels::from_count(count);
            if new_ch != AudioChannels::Unknown && new_ch != a.channels {
                a.channels = new_ch;
            }
        }

        // (2) Sample rate — whitelisted rates only; an unknown nibble or a rate
        // that maps to no enum variant leaves the container value untouched
        // (never write a wrong SamplingFrequency).
        if let Some(hz) = truehd_sample_rate_hz(info.format_info) {
            let new_sr = SampleRate::from_hz(hz);
            if new_sr != SampleRate::Unknown && new_sr != a.sample_rate {
                a.sample_rate = new_sr;
            }
        }

        // (3) Label — refresh to the corrected channels; promote to the Atmos
        // form only when the stream carried the basic descriptor (no editorial
        // Atmos already) AND a 4th substream was positively detected.
        if was_basic {
            a.label = if info.is_atmos == Some(true) {
                crate::labels::generate_audio_label_atmos(&a.codec, &a.channels, a.secondary)
            } else {
                crate::labels::generate_audio_label(&a.codec, &a.channels, a.secondary)
            };
        }
    }
}

/// Merge per-title AACS key ranges into the sorted, disjoint set the whole-disc map
/// needs ([`crate::decrypt::AacsKeyMap::entry_for`] requires disjoint ranges).
/// Titles that share a clip resolve the SAME physical span (same LBAs → same CPS
/// unit → same key). When a later range overlaps a kept one that carries the SAME
/// key index and phase, the two are UNIONED (end extended to the max) — this covers
/// both the exact-duplicate (shared clip) case and any partial overlap without ever
/// dropping coverage, so no encrypted LBA is left in no range (which would pass
/// through as ciphertext). A real disc never produces two DIFFERENT keys for one
/// LBA; if that malformed case ever appeared, the later range is dropped to keep the
/// set disjoint rather than extend one key over another key's LBAs.
fn merge_content_key_ranges(
    mut ranges: Vec<(u32, u32, usize, crate::decrypt::Phase)>,
) -> Vec<(u32, u32, usize, crate::decrypt::Phase)> {
    ranges.sort_by_key(|&(s, _, _, _)| s);
    let mut merged: Vec<(u32, u32, usize, crate::decrypt::Phase)> = Vec::new();
    for r in ranges {
        match merged.last_mut() {
            // Overlaps the previous kept range.
            Some(last) if r.0 < last.1 => {
                // Same key + phase → union (coverage-preserving); a genuine
                // different-key overlap (malformed disc) is dropped to stay disjoint.
                if r.2 == last.2 && r.3 == last.3 {
                    last.1 = last.1.max(r.1);
                }
            }
            // Disjoint or exactly adjacent → keep as its own range.
            _ => merged.push(r),
        }
    }
    merged
}

/// Calculate how many bytes of bad/unreadable data fall within a title's extents.
/// `pub(crate)` so autorip can use it for main-movie lost_ms computation.
pub fn bytes_bad_in_title(title: &DiscTitle, bad_ranges: &[(u64, u64)]) -> u64 {
    if bad_ranges.is_empty() || title.extents.is_empty() {
        return 0;
    }
    // Overlap each bad range against every extent individually. A single
    // bounding box (first extent start → last extent end) would count
    // bad sectors in inter-extent gaps (other titles' data, BDMV
    // metadata) as bad bytes in this title, over-counting lost_ms for
    // titles with non-contiguous clips.
    let mut total: u64 = 0;
    for ext in &title.extents {
        let es = (ext.start_lba as u64) * 2048;
        let ee = ((ext.start_lba as u64) + (ext.sector_count as u64)) * 2048;
        for (pos, size) in bad_ranges {
            let r_start = *pos;
            let r_end = pos.saturating_add(*size);
            let overlap_start = r_start.max(es);
            let overlap_end = r_end.min(ee);
            total = total.saturating_add(overlap_end.saturating_sub(overlap_start));
        }
    }
    total
}

/// Byte offset of `lba` within `title`'s extents (concatenated in order), or
/// `None` if the LBA falls outside every extent. The title is a virtual
/// contiguous stream; this maps a disc LBA into that stream so a chapter/time
/// lookup can place it. (Moved from autorip — clients must not re-derive it.)
fn byte_offset_in_title(lba: u32, title: &DiscTitle) -> Option<u64> {
    use crate::consts::SECTOR_BYTES_U64;
    let mut cumulative = 0u64;
    for ext in &title.extents {
        if lba >= ext.start_lba && lba < ext.start_lba + ext.sector_count {
            return Some(cumulative + (lba - ext.start_lba) as u64 * SECTOR_BYTES_U64);
        }
        cumulative += ext.sector_count as u64 * SECTOR_BYTES_U64;
    }
    None
}

/// The 1-based chapter index + movie-time offset a byte position within a title
/// falls in, or `None` if the title has no size/chapters. Pure helper for the
/// range→chapter/time annotation the progress drilldown ([`locate_ranges`])
/// renders — also used by autorip's done-card range annotation. (Formerly lived
/// in the removed standalone sector-verify module.)
pub fn chapter_at_offset(
    chapters: &[Chapter],
    byte_offset: u64,
    duration_secs: f64,
    total_bytes: u64,
) -> Option<(usize, f64)> {
    if total_bytes == 0 || chapters.is_empty() {
        return None;
    }
    let time_secs = byte_offset as f64 / total_bytes as f64 * duration_secs;
    let mut chapter_idx = 0;
    for (i, ch) in chapters.iter().enumerate() {
        if ch.time_secs <= time_secs {
            chapter_idx = i;
        } else {
            break;
        }
    }
    Some((chapter_idx + 1, time_secs))
}

/// The 1-based chapter + movie-time offset an LBA falls in, or `(None, None)`
/// if it isn't inside the title.
fn range_chapter(lba: u32, title: &DiscTitle) -> (Option<u32>, Option<f64>) {
    if let Some(byte_offset) = byte_offset_in_title(lba, title) {
        if let Some((ch, t)) = chapter_at_offset(
            &title.chapters,
            byte_offset,
            title.duration_secs,
            title.size_bytes,
        ) {
            return (Some(ch as u32), Some(t));
        }
    }
    (None, None)
}

/// Annotate raw bad byte-ranges with chapter + movie time, producing the
/// rendered drilldown ([`crate::progress::LocatedProgress`]) a client draws.
/// `raw` is the mapfile's `(byte_pos, byte_len)` set for whichever statuses the
/// caller cares about (the live "Maybe" set during a patch, or terminal
/// `Unreadable` for the verdict). The list is sorted largest-movie-time first
/// and capped at 50; `truncated` reports the overflow. `bps` (title bytes/sec)
/// is derived from the title so callers don't thread it.
///
/// This is the single place range→chapter/time annotation happens; autorip used
/// to own it and read the mapfile to do so. Now the library computes it from
/// its in-memory mapfile + title, and the client renders the result verbatim.
pub fn locate_ranges(raw: &[(u64, u64)], title: &DiscTitle) -> crate::progress::LocatedProgress {
    use crate::consts::{MILLIS_PER_SEC, SECTOR_BYTES_U64};
    use crate::progress::{LocatedProgress, LocatedRange};
    const MAX_LOCATED: usize = 50;
    let bps = if title.duration_secs > 0.0 {
        title.size_bytes as f64 / title.duration_secs
    } else {
        0.0
    };
    let num_ranges = raw.len() as u32;
    let mut ranges: Vec<LocatedRange> = raw
        .iter()
        .map(|(pos, size)| {
            let lba = pos / SECTOR_BYTES_U64;
            let count = (size / SECTOR_BYTES_U64) as u32;
            let duration_ms = if bps > 0.0 {
                (*size as f64) / bps * MILLIS_PER_SEC
            } else {
                0.0
            };
            let (chapter, time_offset_secs) = range_chapter(lba as u32, title);
            LocatedRange {
                lba,
                count,
                duration_ms,
                chapter,
                time_offset_secs,
            }
        })
        .collect();
    ranges.sort_by(|a, b| {
        b.duration_ms
            .partial_cmp(&a.duration_ms)
            .unwrap_or(std::cmp::Ordering::Equal)
    });
    let largest_gap_ms = ranges.first().map(|r| r.duration_ms).unwrap_or(0.0);
    let truncated = ranges.len().saturating_sub(MAX_LOCATED) as u32;
    ranges.truncate(MAX_LOCATED);
    // At-risk movie time = duration of the ranges that intersect the title
    // extents (the others are menus/extras → no movie impact).
    let main_at_risk_ms = if bps > 0.0 {
        bytes_bad_in_title(title, raw) as f64 * MILLIS_PER_SEC / bps
    } else {
        0.0
    };
    LocatedProgress {
        ranges,
        num_ranges,
        truncated,
        main_at_risk_ms,
        largest_gap_ms,
    }
}

// ─── Display helpers ────────────────────────────────────────────────────────

impl Codec {
    /// Human-readable display name.
    pub fn name(&self) -> &'static str {
        for (_, name, v) in Self::ALL_CODECS {
            if v == self {
                return name;
            }
        }
        "Unknown"
    }

    /// Compact identifier for serialization (lowercase, no spaces).
    pub fn id(&self) -> &'static str {
        for (id, _, v) in Self::ALL_CODECS {
            if v == self {
                return id;
            }
        }
        "unknown"
    }

    const ALL_CODECS: &[(&'static str, &'static str, Codec)] = &[
        ("hevc", "HEVC", Codec::Hevc),
        ("h264", "H.264", Codec::H264),
        ("vc1", "VC-1", Codec::Vc1),
        ("mpeg2", "MPEG-2", Codec::Mpeg2),
        ("mpeg1", "MPEG-1", Codec::Mpeg1),
        ("av1", "AV1", Codec::Av1),
        ("truehd", "TrueHD", Codec::TrueHd),
        ("dtshd_ma", "DTS-HD MA", Codec::DtsHdMa),
        ("dtshd_hr", "DTS-HD HR", Codec::DtsHdHr),
        ("dts", "DTS", Codec::Dts),
        ("ac3", "AC-3", Codec::Ac3),
        ("eac3", "EAC-3", Codec::Ac3Plus),
        ("lpcm", "LPCM", Codec::Lpcm),
        ("aac", "AAC", Codec::Aac),
        ("mp2", "MP2", Codec::Mp2),
        ("mp3", "MP3", Codec::Mp3),
        ("flac", "FLAC", Codec::Flac),
        ("opus", "Opus", Codec::Opus),
        ("pgs", "PGS", Codec::Pgs),
        ("dvdsub", "DVD Subtitle", Codec::DvdSub),
        ("srt", "SRT", Codec::Srt),
        ("ssa", "SSA", Codec::Ssa),
    ];

    pub(crate) fn from_coding_type(ct: u8) -> Self {
        use crate::consts::coding_type as c;
        match ct {
            c::HEVC => Codec::Hevc,
            // 0x1B base-view AVC and 0x20 MVC dependent-view (Blu-ray 3D right
            // eye) are both H.264. Mapping 0x20 to video is what makes the PMT
            // scan enumerate the dependent view as a second H.264 stream (its
            // own PID in the SSIF) instead of dropping it — the basis of 3D.
            c::H264 | c::H264_MVC => Codec::H264,
            c::VC1 => Codec::Vc1,
            c::MPEG2_VIDEO => Codec::Mpeg2,
            c::TRUEHD => Codec::TrueHd,
            c::DTS_HD_MA => Codec::DtsHdMa,
            c::DTS_HD_HR => Codec::DtsHdHr,
            c::DTS => Codec::Dts,
            c::AC3 => Codec::Ac3,
            c::AC3_PLUS | c::AC3_PLUS_SECONDARY => Codec::Ac3Plus,
            c::LPCM => Codec::Lpcm,
            // DTS_HD_MA (primary 0x86) / DTS_HD_SECONDARY (0xA2) are the
            // DTS-HD MA lossless pair, parallel to AC3/AC3_PLUS_SECONDARY for
            // AC-3. The secondary code is lossless MA, not lossy HR.
            c::DTS_HD_SECONDARY => Codec::DtsHdMa,
            // PG (0x90) = Presentation Graphics (subtitles). IG (0x91, menus)
            // and TEXT_SUBTITLE (0x92) are distinct HDMV coding types and are
            // NOT PG subtitle streams; only PG maps to Pgs. IG falls through to
            // Unknown so the PMT/STN walker drops it rather than surfacing a
            // bogus PGS subtitle track for a menu ES.
            c::PG => Codec::Pgs,
            ct => Codec::Unknown(ct),
        }
    }

    /// Broad stream category for a codec. Used by demuxers to decide
    /// whether a PMT/STN entry becomes a video, audio, or subtitle
    /// `Stream` without duplicating per-codec knowledge.
    pub fn kind(&self) -> CodecKind {
        match self {
            Codec::Hevc | Codec::H264 | Codec::Vc1 | Codec::Mpeg2 | Codec::Mpeg1 | Codec::Av1 => {
                CodecKind::Video
            }
            Codec::TrueHd
            | Codec::DtsHdMa
            | Codec::DtsHdHr
            | Codec::Dts
            | Codec::Ac3
            | Codec::Ac3Plus
            | Codec::Lpcm
            | Codec::Aac
            | Codec::Mp2
            | Codec::Mp3
            | Codec::Flac
            | Codec::Opus => CodecKind::Audio,
            Codec::Pgs | Codec::DvdSub | Codec::Srt | Codec::Ssa => CodecKind::Subtitle,
            Codec::Unknown(_) => CodecKind::Unknown,
        }
    }
}

/// Broad category of a [`Codec`] — video / audio / subtitle / unknown.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CodecKind {
    Video,
    Audio,
    Subtitle,
    Unknown,
}

impl std::fmt::Display for Codec {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.name())
    }
}

impl Resolution {
    /// Parse from MPLS video_format byte.
    pub fn from_video_format(vf: u8) -> Self {
        match vf {
            1 => Resolution::R480i,
            2 => Resolution::R576i,
            3 => Resolution::R480p,
            4 => Resolution::R1080i,
            5 => Resolution::R720p,
            6 => Resolution::R1080p,
            7 => Resolution::R576p,
            8 => Resolution::R2160p,
            other => {
                tracing::warn!(video_format = other, "unknown MPLS video_format byte");
                Resolution::Unknown
            }
        }
    }

    /// Pixel dimensions (width, height).
    pub fn pixels(&self) -> (u32, u32) {
        match self {
            Resolution::R480i | Resolution::R480p => (720, 480),
            Resolution::R576i | Resolution::R576p => (720, 576),
            Resolution::R720p => (1280, 720),
            Resolution::R1080i | Resolution::R1080p => (1920, 1080),
            Resolution::R2160p => (3840, 2160),
            Resolution::R4320p => (7680, 4320),
            Resolution::Unknown => (1920, 1080),
        }
    }

    /// True if this is a UHD (4K+) resolution.
    pub fn is_uhd(&self) -> bool {
        matches!(self, Resolution::R2160p | Resolution::R4320p)
    }

    /// True if this is an interlaced resolution (the `R*i` variants).
    pub fn is_interlaced(&self) -> bool {
        matches!(
            self,
            Resolution::R480i | Resolution::R576i | Resolution::R1080i
        )
    }

    /// True if this is an HD (720p+) resolution.
    pub fn is_hd(&self) -> bool {
        !matches!(
            self,
            Resolution::R480i
                | Resolution::R480p
                | Resolution::R576i
                | Resolution::R576p
                | Resolution::Unknown
        )
    }

    /// True if this is an SD (480/576) resolution.
    pub fn is_sd(&self) -> bool {
        matches!(
            self,
            Resolution::R480i | Resolution::R480p | Resolution::R576i | Resolution::R576p
        )
    }

    /// Parse from pixel height (e.g. from MKV track).
    pub fn from_height(h: u32) -> Self {
        match h {
            0..=480 => Resolution::R480p,
            481..=576 => Resolution::R576p,
            577..=720 => Resolution::R720p,
            721..=1080 => Resolution::R1080p,
            1081..=2160 => Resolution::R2160p,
            _ => Resolution::R4320p,
        }
    }
}

// Display for Resolution is generated by enum_str! macro

impl FrameRate {
    /// Parse from MPLS video_rate byte.
    pub fn from_video_rate(vr: u8) -> Self {
        match vr {
            1 => FrameRate::F23_976,
            2 => FrameRate::F24,
            3 => FrameRate::F25,
            4 => FrameRate::F29_97,
            5 => FrameRate::F30,
            6 => FrameRate::F50,
            7 => FrameRate::F59_94,
            8 => FrameRate::F60,
            other => {
                tracing::warn!(video_rate = other, "unknown MPLS video_rate byte");
                FrameRate::Unknown
            }
        }
    }

    /// Frame rate as (numerator, denominator) for precise representation.
    pub fn as_fraction(&self) -> (u32, u32) {
        match self {
            FrameRate::F23_976 => (24000, 1001),
            FrameRate::F24 => (24, 1),
            FrameRate::F25 => (25, 1),
            FrameRate::F29_97 => (30000, 1001),
            FrameRate::F30 => (30, 1),
            FrameRate::F50 => (50, 1),
            FrameRate::F59_94 => (60000, 1001),
            FrameRate::F60 => (60, 1),
            FrameRate::Unknown => (0, 1),
        }
    }
}

// Display for FrameRate is generated by enum_str! macro

impl AudioChannels {
    /// Parse from MPLS audio_format byte.
    pub fn from_audio_format(af: u8) -> Self {
        match af {
            1 => AudioChannels::Mono,
            3 => AudioChannels::Stereo,
            6 => AudioChannels::Surround51,
            12 => AudioChannels::Surround71,
            other => {
                tracing::warn!(audio_format = other, "unknown MPLS audio_format byte");
                AudioChannels::Unknown
            }
        }
    }

    /// Channel count as a number.
    pub fn count(&self) -> u8 {
        match self {
            AudioChannels::Mono => 1,
            AudioChannels::Stereo => 2,
            AudioChannels::Stereo21 => 3,
            AudioChannels::Quad => 4,
            AudioChannels::Surround50 => 5,
            AudioChannels::Surround51 => 6,
            AudioChannels::Surround61 => 7,
            AudioChannels::Surround71 => 8,
            AudioChannels::Unknown => 6,
        }
    }

    /// Parse from channel count number.
    pub fn from_count(n: u8) -> Self {
        match n {
            1 => AudioChannels::Mono,
            2 => AudioChannels::Stereo,
            3 => AudioChannels::Stereo21,
            4 => AudioChannels::Quad,
            5 => AudioChannels::Surround50,
            6 => AudioChannels::Surround51,
            7 => AudioChannels::Surround61,
            8 => AudioChannels::Surround71,
            _ => AudioChannels::Unknown,
        }
    }
}

// Display for AudioChannels is generated by enum_str! macro

impl SampleRate {
    /// Parse from MPLS audio_rate byte.
    pub fn from_audio_rate(ar: u8) -> Self {
        match ar {
            1 => SampleRate::S48,
            4 => SampleRate::S96,
            5 => SampleRate::S192,
            12 => SampleRate::S48_192,
            14 => SampleRate::S48_96,
            other => {
                tracing::warn!(audio_rate = other, "unknown MPLS audio_rate byte");
                SampleRate::Unknown
            }
        }
    }

    /// Sample rate in Hz (primary rate for combo rates).
    pub fn hz(&self) -> f64 {
        match self {
            SampleRate::S44_1 => 44100.0,
            SampleRate::S48 | SampleRate::S48_96 | SampleRate::S48_192 => 48000.0,
            SampleRate::S88_2 => 88200.0,
            SampleRate::S96 => 96000.0,
            SampleRate::S176_4 => 176400.0,
            SampleRate::S192 => 192000.0,
            SampleRate::Unknown => 48000.0,
        }
    }

    /// Parse from Hz value.
    pub fn from_hz(hz: u32) -> Self {
        match hz {
            44100 => SampleRate::S44_1,
            48000 => SampleRate::S48,
            88200 => SampleRate::S88_2,
            96000 => SampleRate::S96,
            176400 => SampleRate::S176_4,
            192000 => SampleRate::S192,
            _ => SampleRate::Unknown,
        }
    }
}

// Display for SampleRate is generated by enum_str! macro

impl HdrFormat {
    pub fn name(&self) -> &'static str {
        match self {
            HdrFormat::Sdr => "SDR",
            HdrFormat::Hdr10 => "HDR10",
            HdrFormat::Hdr10Plus => "HDR10+",
            HdrFormat::DolbyVision => "Dolby Vision",
            HdrFormat::Hlg => "HLG",
        }
    }

    const ALL_HDR: &[(&'static str, HdrFormat)] = &[
        ("sdr", HdrFormat::Sdr),
        ("hdr10", HdrFormat::Hdr10),
        ("hdr10+", HdrFormat::Hdr10Plus),
        ("dv", HdrFormat::DolbyVision),
        ("hlg", HdrFormat::Hlg),
    ];

    /// Compact identifier for serialization.
    pub fn id(&self) -> &'static str {
        for (id, v) in Self::ALL_HDR {
            if v == self {
                return id;
            }
        }
        "sdr"
    }
}

impl std::fmt::Display for HdrFormat {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.name())
    }
}

impl ColorSpace {
    pub fn name(&self) -> &'static str {
        match self {
            ColorSpace::Bt709 => "BT.709",
            ColorSpace::Bt2020 => "BT.2020",
            ColorSpace::Bt470bg => "BT.470BG",
            ColorSpace::Smpte170m => "SMPTE 170M",
            ColorSpace::Unknown => "",
        }
    }

    const ALL_CS: &[(&'static str, ColorSpace)] = &[
        ("bt709", ColorSpace::Bt709),
        ("bt2020", ColorSpace::Bt2020),
        ("bt470bg", ColorSpace::Bt470bg),
        ("smpte170m", ColorSpace::Smpte170m),
        ("unknown", ColorSpace::Unknown),
    ];

    /// Compact identifier for serialization (round-trips via `FromStr`).
    pub fn id(&self) -> &'static str {
        for (id, v) in Self::ALL_CS {
            if v == self {
                return id;
            }
        }
        "unknown"
    }
}

impl std::fmt::Display for ColorSpace {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.name())
    }
}

impl std::str::FromStr for ColorSpace {
    type Err = ();
    fn from_str(s: &str) -> std::result::Result<Self, ()> {
        for (id, v) in ColorSpace::ALL_CS {
            if *id == s {
                return Ok(*v);
            }
        }
        // Also accept display names (e.g. "BT.2020").
        for (_id, v) in ColorSpace::ALL_CS {
            if ColorSpace::name(v) == s {
                return Ok(*v);
            }
        }
        Ok(ColorSpace::Unknown)
    }
}

// ─── FromStr impls — single source of truth via ALL_* arrays ───────────────
//
// Each enum defines a const array of (str, variant) pairs. Display, FromStr,
// and id() all derive from this one table — no string appears twice.

macro_rules! enum_str {
    ($name:ident, $default:expr, [ $( ($s:expr, $v:expr) ),* $(,)? ]) => {
        impl $name {
            const ALL: &[(&'static str, $name)] = &[ $( ($s, $v), )* ];
        }
        impl std::fmt::Display for $name {
            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
                for (s, v) in $name::ALL {
                    if v == self { return f.write_str(s); }
                }
                // The only variant not in ALL is the Unknown fallback (kept out
                // of ALL so FromStr("unknown") round-trips to it via $default
                // without ALL gaining a duplicate key). Display it visibly as
                // "unknown" rather than an empty string, which produced blank
                // metadata in labels and logs.
                f.write_str("unknown")
            }
        }
        impl std::str::FromStr for $name {
            type Err = ();
            fn from_str(s: &str) -> std::result::Result<Self, ()> {
                for (k, v) in $name::ALL {
                    if *k == s { return Ok(*v); }
                }
                Ok($default)
            }
        }
    };
}

enum_str!(
    Resolution,
    Resolution::Unknown,
    [
        ("480i", Resolution::R480i),
        ("480p", Resolution::R480p),
        ("576i", Resolution::R576i),
        ("576p", Resolution::R576p),
        ("720p", Resolution::R720p),
        ("1080i", Resolution::R1080i),
        ("1080p", Resolution::R1080p),
        ("2160p", Resolution::R2160p),
        ("4320p", Resolution::R4320p),
    ]
);

enum_str!(
    FrameRate,
    FrameRate::Unknown,
    [
        ("23.976", FrameRate::F23_976),
        ("24", FrameRate::F24),
        ("25", FrameRate::F25),
        ("29.97", FrameRate::F29_97),
        ("30", FrameRate::F30),
        ("50", FrameRate::F50),
        ("59.94", FrameRate::F59_94),
        ("60", FrameRate::F60),
    ]
);

enum_str!(
    AudioChannels,
    AudioChannels::Unknown,
    [
        ("mono", AudioChannels::Mono),
        ("stereo", AudioChannels::Stereo),
        ("2.1", AudioChannels::Stereo21),
        ("4.0", AudioChannels::Quad),
        ("5.0", AudioChannels::Surround50),
        ("5.1", AudioChannels::Surround51),
        ("6.1", AudioChannels::Surround61),
        ("7.1", AudioChannels::Surround71),
    ]
);

enum_str!(
    SampleRate,
    SampleRate::Unknown,
    [
        ("44.1kHz", SampleRate::S44_1),
        ("48kHz", SampleRate::S48),
        ("88.2kHz", SampleRate::S88_2),
        ("96kHz", SampleRate::S96),
        ("176.4kHz", SampleRate::S176_4),
        ("192kHz", SampleRate::S192),
        ("48/96kHz", SampleRate::S48_96),
        ("48/192kHz", SampleRate::S48_192),
    ]
);

impl std::str::FromStr for Codec {
    type Err = ();
    fn from_str(s: &str) -> std::result::Result<Self, ()> {
        for (id, _, v) in Codec::ALL_CODECS {
            if *id == s {
                return Ok(*v);
            }
        }
        Ok(Codec::Unknown(0))
    }
}

impl std::str::FromStr for HdrFormat {
    type Err = ();
    fn from_str(s: &str) -> std::result::Result<Self, ()> {
        for (id, v) in HdrFormat::ALL_HDR {
            if *id == s {
                return Ok(*v);
            }
        }
        // Also accept display names
        for (_id, v) in HdrFormat::ALL_HDR {
            if HdrFormat::name(v) == s {
                return Ok(*v);
            }
        }
        // An unrecognised string is an error, not silently SDR. ("sdr"/"SDR"
        // already matched above.) Callers that want SDR-on-unknown opt in
        // explicitly with `.unwrap_or(HdrFormat::Sdr)` (e.g. mux/meta.rs).
        Err(())
    }
}

impl DiscTitle {
    /// Empty DiscTitle with no streams.
    pub fn empty() -> Self {
        Self {
            playlist: String::new(),
            playlist_id: 0,
            duration_secs: 0.0,
            size_bytes: 0,
            clips: Vec::new(),
            streams: Vec::new(),
            chapters: Vec::new(),
            extents: Vec::new(),
            content_format: ContentFormat::BdTs,
            codec_privates: Vec::new(),
        }
    }

    /// Duration formatted as "Xh Ym"
    pub fn duration_display(&self) -> String {
        let hrs = (self.duration_secs / 3600.0) as u32;
        let mins = ((self.duration_secs % 3600.0) / 60.0) as u32;
        format!("{hrs}h {mins:02}m")
    }

    /// Size in GB
    pub fn size_gb(&self) -> f64 {
        self.size_bytes as f64 / (1024.0 * 1024.0 * 1024.0)
    }

    /// Total sectors across all extents
    pub fn total_sectors(&self) -> u64 {
        self.extents.iter().map(|e| e.sector_count as u64).sum()
    }

    /// The title's audio streams, in declared order. Cleaner than matching on
    /// the [`Stream`] enum for the common "iterate the audio tracks" case
    /// (stream selection, the desktop UI's info panel, disc-info listing).
    pub fn audio_streams(&self) -> impl Iterator<Item = &AudioStream> {
        self.streams.iter().filter_map(|s| match s {
            Stream::Audio(a) => Some(a),
            _ => None,
        })
    }

    /// The title's subtitle streams, in declared order.
    pub fn subtitle_streams(&self) -> impl Iterator<Item = &SubtitleStream> {
        self.streams.iter().filter_map(|s| match s {
            Stream::Subtitle(s) => Some(s),
            _ => None,
        })
    }

    /// The title's video streams, in declared order (usually one; two for a
    /// Blu-ray 3D MVC title — the base view plus the dependent view).
    pub fn video_streams(&self) -> impl Iterator<Item = &VideoStream> {
        self.streams.iter().filter_map(|s| match s {
            Stream::Video(v) => Some(v),
            _ => None,
        })
    }
}

// ─── Encryption ─────────────────────────────────────────────────────────────

/// AACS decryption state for a disc.
pub struct AacsState {
    /// AACS version (1 or 2)
    pub version: u8,
    /// Whether bus encryption is enabled (always true for AACS 2.0 / UHD)
    pub bus_encryption: bool,
    /// MKB version from disc (e.g. 68, 77)
    pub mkb_version: Option<u32>,
    /// Disc hash (SHA1 of Unit_Key_RO.inf) -- hex string with 0x prefix
    pub disc_hash: String,
    /// How keys were resolved
    pub key_source: KeyOrigin,
    /// Volume Unique Key (16 bytes). `None` when keys were resolved
    /// via the [`KeyOrigin::KeyDbUnitKeys`] path — that source delivers
    /// pre-decrypted unit keys without a VUK to derive them from.
    pub vuk: Option<[u8; 16]>,
    /// Decrypted unit keys (CPS unit number, key)
    pub unit_keys: Vec<(u32, [u8; 16])>,
    /// Read data key for AACS 2.0 bus decryption -- None for AACS 1.0
    pub read_data_key: Option<[u8; 16]>,
    /// Volume ID (16 bytes) -- from SCSI handshake
    pub volume_id: [u8; 16],
    /// Raw `Unit_Key_RO.inf` bytes (encrypted unit keys + CPS map). Stashed at
    /// scan so an external resolver (key-resolver) can derive the unit keys
    /// from a VUK without re-reading the disc. Empty when not captured.
    pub uk_ro: Vec<u8>,
    /// Raw MKB bytes (`MKB_RO.inf`). Stashed at scan so an external resolver can
    /// walk it (device/processing key → media key). Empty when not captured.
    pub mkb: Vec<u8>,
}

// Redacting `Debug`: `AacsState` is crate-root re-exported and reachable via the
// public `Disc.aacs` field; it carries VUK / unit keys / read-data (bus) key /
// volume id / raw .inf + MKB. Print only non-secret shape; redact every
// key/secret field. Guarded by `aacs_state_and_key_debug_are_redacted`.
impl std::fmt::Debug for AacsState {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("AacsState")
            .field("version", &self.version)
            .field("bus_encryption", &self.bus_encryption)
            .field("mkb_version", &self.mkb_version)
            .field("disc_hash", &self.disc_hash)
            .field("key_source", &self.key_source)
            .field("vuk", &self.vuk.map(|_| "<redacted>"))
            .field("unit_keys_len", &self.unit_keys.len())
            .field("read_data_key", &self.read_data_key.map(|_| "<redacted>"))
            .field("volume_id", &"<redacted>")
            .field("uk_ro_len", &self.uk_ro.len())
            .field("mkb_len", &self.mkb.len())
            .finish()
    }
}

/// How AACS keys were resolved. Variants are ordered root-of-trust →
/// per-disc-leaf, matching the resolver's path-try order: the resolver
/// attempts derivation from the strongest input it has first and falls
/// back toward pre-computed per-disc material.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum KeyOrigin {
    /// MKB + device keys → subset-difference tree → VUK
    DeviceKey,
    /// MKB + processing keys → media key → VUK
    ProcessingKey,
    /// Media key + Volume ID from KEYDB → derived VUK
    KeyDbDerived,
    /// VUK found directly in KEYDB by disc hash
    KeyDb,
    /// Pre-decrypted unit keys taken directly from KEYDB by disc hash.
    /// No VUK present in the entry — `AacsState::vuk` is `None`.
    KeyDbUnitKeys,
    /// Unit key supplied directly by the caller (the external Unit Key path).
    /// No keydb, no derivation — `AacsState::vuk` is `None`.
    ExternalUk,
}

// No `KeyOrigin::name()`: the library holds ZERO user-facing English (CLAUDE.md).
// `KeyOrigin` is a typed enum; applications map its variants to display text
// (see freemkv's `disc_info::key_origin_label`).

// ─── Disc scanning ──────────────────────────────────────────────────────────

/// AACS host credentials for the live-drive authenticated handshake.
///
/// Optional and source-agnostic: an ISO scan has no handshake at all, and a
/// live drive without supplied credentials simply skips cert auth. The
/// caller supplies the host cert(s) from wherever it likes — today the keydb's
/// `host_certs()`, tomorrow a cert file or built-in. Decoupled from the key
/// source: a locked drive needs the cert to unlock even when the decryption key
/// comes from an online service.
#[derive(Default, Clone)]
pub struct DriveCredentials {
    /// Host certificate(s) + private key(s) for the SCSI AACS handshake.
    pub host_certs: Vec<crate::aacs::types::HostCert>,
}

/// Options for disc scanning.
///
/// libfreemkv is lookup-free — it resolves no keys. The caller resolves a key
/// out-of-band (a key source) and applies it via [`Disc::decrypt_with`]. The
/// only scan input is the optional drive credentials for the live-drive
/// authenticated handshake.
#[derive(Default)]
pub struct ScanOptions {
    /// Host credentials for the live-drive AACS handshake. `None` for ISO
    /// scans, or a live drive where cert auth should be skipped.
    ///
    /// Host certs may ALSO be supplied through [`Self::key_sources`]: the
    /// handshake unifies certs from both, so the app can pass its already-built
    /// keysource layer rather than (or in addition to) pre-extracting certs into
    /// `DriveCredentials`. Either route is keysource-served — certs are never
    /// compiled into the library.
    pub credentials: Option<DriveCredentials>,
    /// The application's key-source layer. The handshake collects host certs
    /// across these (via [`crate::KeySource::host_certs`]) for the OEM/AACS
    /// cert-auth route, unioned with [`Self::credentials`]. Empty by default —
    /// an ISO scan supplies none, and a live-drive caller that pre-extracted
    /// certs into `credentials` may leave it empty too. The library still
    /// resolves NO keys from these at scan time; they are consulted only for
    /// their host certs here (key *resolution* stays out-of-band via
    /// `Disc::decrypt_with`).
    pub key_sources: Vec<Box<dyn crate::KeySource>>,
    /// Optional cooperative-cancellation token. When set, long scan-time
    /// loops (notably the CSS known-plaintext crack, which can scan up to
    /// 50_000 sectors on a live DVD) poll it and bail out cleanly so a
    /// scan-phase watchdog or operator Stop is never stuck behind a hang.
    pub halt: Option<crate::halt::Halt>,
    /// Read the PGS subtitle streams during the scan to detect forced-narrative
    /// tracks from their content (the `forced_on_flag`), matching what the mux
    /// derives during a rip. OFF by default — it reads the clip's PGS content,
    /// which is slow, so only callers that want authoritative forced flags in the
    /// scanned title (e.g. `freemkv info`) opt in. The rip path leaves it off:
    /// the muxer detects forced during muxing without a second read.
    pub probe_forced_subtitles: bool,
}

/// Quick disc identification — name, format, capacity. No title/stream parsing.
#[derive(Debug)]
pub struct DiscId {
    /// UDF Volume Identifier (always present, e.g. "SAMPLE_FILM")
    pub volume_id: String,
    /// Disc title from META/DL/bdmt_eng.xml (e.g. "Sample Film")
    pub meta_title: Option<String>,
    /// Disc format (BD, UHD, DVD) — UHD vs BD requires full scan to confirm
    pub format: DiscFormat,
    /// Disc capacity in sectors
    pub capacity_sectors: u32,
    /// Whether AACS directory exists (disc is likely encrypted)
    pub encrypted: bool,
    /// Number of layers
    pub layers: u8,
}

impl DiscId {
    /// Best available name: meta_title, then formatted volume_id.
    pub fn name(&self) -> &str {
        self.meta_title.as_deref().unwrap_or(&self.volume_id)
    }
}

impl Disc {
    /// Fast disc identification — reads only UDF metadata for name and format.
    /// No AACS handshake, no playlist parsing, no CLPI, no labels.
    /// Typically completes in 2-3 seconds on USB drives.
    pub fn identify(session: &mut Drive) -> Result<DiscId> {
        let (capacity, mut buffered, udf_fs) = Self::read_udf(session)?;

        let meta_title = Self::read_meta_title(&mut buffered, &udf_fs);
        // Authoritative here — the same MKB-driven detector the full scan uses
        // (no titles needed: BD/UHD/FMTS come from the MKB generation). It no
        // longer defaults to BluRay or defers UHD/FMTS to the full scan.
        let format = Self::detect_disc_format(&mut buffered, &udf_fs, &[]);
        let encrypted =
            udf_fs.find_dir("/AACS").is_some() || udf_fs.find_dir("/BDMV/AACS").is_some();
        let layers = if capacity > 24_000_000 { 2 } else { 1 };

        Ok(DiscId {
            volume_id: udf_fs.volume_id,
            meta_title,
            format,
            capacity_sectors: capacity,
            encrypted,
            layers,
        })
    }

    /// Disc capacity in GB
    pub fn capacity_gb(&self) -> f64 {
        self.capacity_sectors as f64 * 2048.0 / (1024.0 * 1024.0 * 1024.0)
    }

    /// Read UDF filesystem and set up buffered reader with metadata prefetched.
    /// Shared setup for both identify() and scan().
    fn read_udf(session: &mut Drive) -> Result<(u32, udf::BufferedSectorReader<'_>, udf::UdfFs)> {
        let capacity = Self::read_capacity(session).unwrap_or_else(|e| {
            // A READ CAPACITY failure (transient drive spin-up, SCSI error)
            // must not be silently treated as a 0-sector disc: capacity=0
            // skews the layer heuristic (always 1 layer) and title ordering.
            // Recovery is unchanged (we still proceed with 0), but surface it.
            tracing::warn!(
                target: "freemkv::scan",
                error = %e,
                "READ CAPACITY failed; treating disc capacity as 0 sectors (layer count and title ordering may be wrong)"
            );
            0
        });
        let batch = detect_max_batch_sectors(session.device_path());
        let mut buffered = udf::BufferedSectorReader::new(session, batch);
        let udf_fs = udf::read_filesystem(&mut buffered)?;
        buffered.prefetch(udf_fs.metadata_start(), udf_fs.metadata_sectors());
        Ok((capacity, buffered, udf_fs))
    }

    /// Scan a disc — parse filesystem, playlists, streams, and set up
    /// AACS decryption. This is the main entry point; after `scan()` the
    /// Disc is ready (titles populated with streams, AACS inputs
    /// captured, content readable and decryptable transparently).
    ///
    /// One pipeline, one order:
    ///   1. Read capacity + UDF filesystem
    ///   2. AACS handshake + key resolution
    ///   3. Parse playlists + streams
    ///   4. Apply labels
    ///
    /// The session must be open and unlocked (`Drive::open` handles this).
    /// All disc reads use standard READ(10) via UDF — no vendor SCSI commands.
    pub fn scan(session: &mut Drive, opts: &ScanOptions) -> Result<Self> {
        // AACS handshake (Blu-ray/UHD). Acquires the Volume ID via the
        // cert-based mutual-auth handshake (the OEM route); drive unlock
        // itself runs separately behind the pluggable `Unlocker` seam.
        // AACS is Blu-ray/UHD only. A DVD uses CSS — skip the AACS handshake
        // entirely (the drive already classified the disc as DVD at init), so a
        // DVD never issues AACS OEM-VID / cert SCSI against the drive before the
        // CSS bus-auth runs.
        let (handshake, handshake_error) = if session.disc_is_dvd() {
            (None, None)
        } else {
            tracing::info!(target: "freemkv::scan", "phase: AACS handshake");
            Self::do_handshake(session, opts)
        };
        tracing::info!(target: "freemkv::scan", handshake = handshake.is_some(), "phase: handshake done");

        // Request max read speed — removes riplock on DVD
        // (BD/UHD speed is set by drive unlock/init, but DVD needs explicit SET CD SPEED)
        session.set_speed(0xFFFF);

        // CSS bus-auth unlock — run BEFORE any scrambled-sector read.
        // On a CSS-enforcing drive (e.g. the BU40N) the UDF metadata prefetch
        // below reaches small/menu VOB extents that are themselves CSS-scrambled;
        // without the bus-auth handshake first, each of those reads is rejected
        // with sense 05/6F/03 ("read of scrambled sector without authentication")
        // after a full drive round-trip — ~13s of pure waste on a real disc, and
        // the prefetch caches nothing. The handshake needs no title info (it takes
        // no extents), is self-guarding to DVD media, and is non-fatal on failure,
        // so run it as soon as the drive has classified the disc as a DVD. The
        // per-VTS title key is NOT recovered here (or anywhere in scan) — it is
        // cracked keylessly at read/mux time via `css::resolve_dvd_title_key`;
        // this only unlocks the drive's read gating so those reads can happen.
        if session.disc_is_dvd() {
            tracing::info!(target: "freemkv::scan", "phase: CSS — bus-auth unlock (pre-scan)");
            let drive_id = session.drive_id.clone();
            let (_, css_unlock_res) = crate::unlock_bridge::run_bus(
                session.scsi_mut(),
                &drive_id,
                freemkv_unlock::DiscKind::Css,
                &[],
            );
            if let Err(e) = css_unlock_res {
                tracing::warn!(
                    target: "freemkv::scan",
                    outcome = ?e,
                    "CSS bus-auth unlock did not apply; scrambled sectors may be unavailable"
                );
            }
        }

        // Read UDF filesystem with buffered sector reader
        tracing::info!(target: "freemkv::scan", "phase: reading UDF filesystem");
        let (capacity, mut buffered, udf_fs) = Self::read_udf(session)?;
        tracing::info!(target: "freemkv::scan", capacity, "phase: UDF read");

        // Pre-read all small file sectors (AACS, MPLS, CLPI, META, *.bdmv).
        // Without this, each read_file() triggers individual SCSI commands at 500ms each.
        if let Ok(ranges) = udf_fs.metadata_sector_ranges(&mut buffered) {
            buffered.prefetch_ranges(&ranges);
        }

        tracing::info!(target: "freemkv::scan", "phase: parsing titles/streams");
        let disc = Self::scan_with(
            &mut buffered,
            capacity,
            handshake,
            handshake_error,
            opts,
            udf_fs,
        )?;
        tracing::info!(target: "freemkv::scan", titles = disc.titles.len(), format = ?disc.content_format, "phase: titles parsed");

        // No CSS key recovery at scan time. DVD CSS keys are per-VTS/per-title,
        // and the descramble path re-cracks each title's key keylessly from that
        // title's own extents at the moment its sectors are read/decrypted — so a
        // single up-front "disc key" is meaningless (it's not valid for the other
        // titles and every title re-derives its key at read time anyway). The
        // scan's only CSS responsibility is the bus-auth unlock above, which opens
        // the drive's read gating so the sweep can read scrambled sectors at all.
        // Detection is likewise moot: encrypted or not, a DVD muxes identically —
        // the read-time descrambler cracks a title key if the sectors are
        // scrambled and is a no-op if they are clear.

        // `disc.css` is intentionally never set at scan time now (per-title CSS
        // keys are cracked at read/mux time), so log the format the scan actually
        // determined rather than a key state that is always `None` here.
        tracing::info!(target: "freemkv::scan", format = ?disc.format, titles = disc.titles.len(), "phase: scan complete");
        Ok(disc)
    }

    /// Scan a disc image (ISO or any SectorSource). No SCSI, no handshake.
    /// AACS resolution uses KEYDB VUK lookup only.
    pub fn scan_image(
        reader: &mut dyn SectorSource,
        capacity: u32,
        opts: &ScanOptions,
    ) -> Result<Self> {
        let udf_fs = udf::read_filesystem(reader)?;
        let mut disc = Self::scan_with(reader, capacity, None, None, opts, udf_fs)?;

        // CSS for a raw (still-scrambled) DVD image: recover the title key from
        // the scrambled movie data itself (known-plaintext attack), same as the
        // live-drive path — but with no SCSI auth/unlock (an image is already
        // readable). This lets the CLI mux a RAW CSS ISO, not only a
        // pre-decrypted one. A pre-decrypted image has its scramble flags clear,
        // so `crack_key` finds no crackable sector and the disc stays in the
        // clear. AACS images go through KEYDB VUK lookup, not here.
        //
        // Gate on `DiscFormat::Dvd`, NOT `content_format == MpegPs`: HD-DVD
        // `.evo` images are ALSO MPEG-PS but are AACS, not CSS — they must not
        // enter the CSS crack path. A CSS DVD's IFO (which defines the titles
        // this branch reads) is unscrambled, so `detect_format` reliably sets
        // `Dvd` from the SD-resolution titles even on a still-scrambled image.
        if disc.css.is_none() && disc.format == DiscFormat::Dvd && !disc.titles.is_empty() {
            let main_extents = match disc
                .titles
                .iter()
                .filter(|t| !t.extents.is_empty())
                .max_by_key(|t| t.extents.iter().map(|e| e.sector_count as u64).sum::<u64>())
            {
                Some(t) => {
                    let mut v = t.extents.clone();
                    v.sort_by(|a, b| b.sector_count.cmp(&a.sector_count));
                    v
                }
                None => Vec::new(),
            };
            if !main_extents.is_empty() {
                // Image reads aren't drive-batch-limited; use a generous batch.
                match crate::css::crack_key_outcome(reader, &main_extents, 32, None) {
                    crate::css::CrackOutcome::Cracked(state) => {
                        tracing::info!(target: "freemkv::scan", "image css: title key recovered via known-plaintext crack");
                        disc.css = Some(state);
                        disc.encrypted = true;
                    }
                    crate::css::CrackOutcome::ScrambledUncracked => {
                        // Scrambled image data with no recoverable key — a hard
                        // failure, surfaced so the mux path doesn't pass scrambled
                        // MPEG through as plaintext (garbage at exit 0).
                        tracing::warn!(target: "freemkv::scan", "image css: scrambled sectors seen but no title key cracked");
                        disc.encrypted = true;
                        disc.css_error = Some(crate::error::Error::CssKeyMissing);
                    }
                    crate::css::CrackOutcome::Unencrypted => {}
                }
            }
        }

        Ok(disc)
    }

    /// Read a disc's AACS key-input files from a sector source: returns
    /// `(Unit_Key_RO.inf, MKB)` raw bytes. Shared body for
    /// [`Disc::read_aacs_inputs`] (ISO) and
    /// [`Disc::read_aacs_inputs_from_drive`] (live drive).
    ///
    /// Prefers MKB_RO, falls back to MKB_RW, then TRIMS to the real
    /// record length. Both files are allocated to a fixed ~128 MiB and
    /// zero-padded, so reading either ships up to ~124 MiB of nothing —
    /// trim to the record stream so callers send/store a few MB, not
    /// 128 MiB.
    pub(crate) fn read_aacs_inputs_from_reader(
        reader: &mut dyn SectorSource,
        udf_fs: &udf::UdfFs,
    ) -> Result<(Vec<u8>, Vec<u8>, u8)> {
        let inf = crate::aacs::read_first(
            &crate::aacs::role_paths(udf_fs, crate::aacs::AacsRole::UnitKey),
            |p| udf_fs.read_file(reader, p),
        )?;
        let mkb = Self::read_mkb_content(reader, udf_fs)?;
        let version = Self::read_aacs_version(reader, udf_fs);
        Ok((inf, mkb, version))
    }

    /// AACS major version ([`crate::aacs::mkb::AACS_MAJOR_BD`] /
    /// [`crate::aacs::mkb::AACS_MAJOR_UHD`]) from the content certificate. Drives the
    /// `Unit_Key_RO.inf` parse stride (48-byte V10 vs 64-byte V20/V21), so the
    /// out-of-band key-fetch path parses `enc_title_keys` at the right stride (a
    /// server VUK then derives the correct unit keys).
    ///
    /// When no content certificate is readable/parseable, defaults to **UHD
    /// (V20, 64-byte stride)** — the conservative choice the pre-1.2.0 fetch path
    /// hardcoded — and logs it: a wrong stride here folds a server VUK against
    /// mis-strided title keys (silent wrong unit keys), so a missing cert must
    /// not quietly pick the V10 stride for a UHD disc.
    fn read_aacs_version(reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs) -> u8 {
        match crate::aacs::read_first(
            &crate::aacs::role_paths(udf_fs, crate::aacs::AacsRole::ContentCert),
            |p| udf_fs.read_file(reader, p),
        )
        .ok()
        .as_deref()
        .and_then(crate::aacs::inf::parse_content_cert)
        {
            Some(c) => c.version.major(),
            None => {
                tracing::warn!(
                    target: "freemkv::disc",
                    phase = "scan_aacs_version",
                    "no readable AACS content certificate; defaulting to the V20/UHD \
                     Unit_Key_RO stride (a VUK-from-server path would otherwise mis-stride)"
                );
                crate::aacs::mkb::AACS_MAJOR_UHD
            }
        }
    }

    /// Read the AACS MKB's real record stream — NOT its zero padding.
    ///
    /// `MKB_RO.inf` / `MKB_RW.inf` are allocated to a fixed ~128 MiB and
    /// zero-padded; the actual record stream is a few MiB. We read a bounded
    /// prefix, find the record-stream length via [`crate::aacs::mkb::mkb_content_len`]
    /// and return exactly that, growing the prefix if the records run past it.
    /// This avoids reading 100+ MiB of padding on every scan AND avoids the
    /// `read_file` `MAX_FILE_BYTES` cap that (since 0.31.0) rejected the padded
    /// 128 MiB MKB outright — which made `read_aacs_inputs` fail and autorip
    /// report "could not read this disc's key files" without ever contacting
    /// the keyserver.
    fn read_mkb_content(reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs) -> Result<Vec<u8>> {
        const START_BYTES: usize = 16 * 1024 * 1024;
        const MAX_BYTES: usize = 64 * 1024 * 1024;
        let mut want = START_BYTES;
        loop {
            let buf = crate::aacs::read_first(
                &crate::aacs::role_paths(udf_fs, crate::aacs::AacsRole::Mkb),
                |p| udf_fs.read_file_prefix(reader, p, want),
            )?;
            let n = crate::aacs::mkb::mkb_content_len(&buf);
            // `n` strictly inside `buf` => the record walk reached the padding
            // boundary (full content captured). `buf` shorter than `want` =>
            // the whole file is already read. Otherwise the records may run
            // past the prefix — grow and retry, bounded by MAX_BYTES.
            if (n > 0 && n < buf.len()) || buf.len() < want || want >= MAX_BYTES {
                return Ok(crate::aacs::mkb::trim_mkb(buf));
            }
            want = (want * 2).min(MAX_BYTES);
        }
    }

    /// Read a disc's AACS key-input files from an ISO image: returns
    /// `(Unit_Key_RO.inf, MKB, aacs_major_version)`. For callers that resolve a
    /// Unit Key out-of-band: obtain the key however you like, then apply it via
    /// [`Disc::decrypt_with`]. libfreemkv never makes a network call.
    pub fn read_aacs_inputs(iso_path: &std::path::Path) -> Result<(Vec<u8>, Vec<u8>, u8)> {
        // Preserve the underlying open error (`Error::IoError`, E5000, carrying
        // the OS errno) instead of collapsing ENOENT/EPERM/etc. into
        // `Error::AacsNoKeys` (E7000). A missing or unreadable ISO is an I/O
        // fault, not a key-resolution failure; callers that dispatch on
        // `.code()` must be able to tell the two apart.
        let mut reader = crate::io::file_sector_source::FileSectorSource::open(iso_path)?;
        let udf_fs = udf::read_filesystem(&mut reader)?;
        Self::read_aacs_inputs_from_reader(&mut reader, &udf_fs)
    }

    /// Same as [`Disc::read_aacs_inputs`] but reads from a live drive. The
    /// out-of-band Unit Key path fetches the disc's key files from the drive,
    /// resolves a key from them however it likes, then applies it via
    /// [`Disc::decrypt_with`]. These files are plaintext UDF metadata — no
    /// AACS handshake or keys are required to read them.
    pub fn read_aacs_inputs_from_drive(drive: &mut Drive) -> Result<(Vec<u8>, Vec<u8>, u8)> {
        let (_, mut reader, udf_fs) = Self::read_udf(drive)?;
        Self::read_aacs_inputs_from_reader(&mut reader, &udf_fs)
    }

    /// Core scan pipeline — works with any SectorSource.
    ///
    /// `handshake_error` is plumbed from `do_handshake` so failures
    /// (cert rejected, raw-read unsupported, VID read failed) are
    /// preserved as `disc.aacs_error` for callers to render. When key
    /// resolution succeeds despite the handshake failure (built-in
    /// keys + disc-hash lookup hit) the error is dropped.
    fn scan_with(
        reader: &mut dyn SectorSource,
        capacity: u32,
        handshake: Option<HandshakeResult>,
        handshake_error: Option<Error>,
        opts: &ScanOptions,
        udf_fs: udf::UdfFs,
    ) -> Result<Self> {
        let scan_with_t0 = std::time::Instant::now();
        tracing::info!(target: "freemkv::scan", phase = "scan_with", "begin");
        // 2. Resolve encryption (AACS, CSS, or none)
        let encrypted =
            udf_fs.find_dir("/AACS").is_some() || udf_fs.find_dir("/BDMV/AACS").is_some();

        let (aacs, aacs_error) = if !encrypted {
            (None, None)
        } else {
            // Lookup-free: capture the disc's AACS inputs (MKB, VID,
            // Unit_Key_RO.inf) but resolve NO key. The caller resolves a Key
            // from a key source and applies it via `Disc::decrypt_with`. The
            // disc reports "encrypted, no keys" until then.
            match Self::resolve_vid_only(&udf_fs, reader, handshake.as_ref()) {
                Ok(state) => (Some(state), None),
                // A handshake failure (no VID) is more actionable than the
                // generic capture error, so surface it when present.
                Err(e) => (None, Some(handshake_error.unwrap_or(e))),
            }
        };

        // 3. Titles + container — dispatched by on-disc tree. HD-DVD and DVD are
        //    tree-level peers, each with its own enumerator; FMTS shares the BD
        //    tree (a `.fmts` stream variant). Disc FORMAT is a separate axis
        //    derived below from the AACS MKB generation, not the tree.
        let (mut titles, content_format) = if udf_fs.find_dir("/BDMV").is_some() {
            (
                Self::scan_bluray_titles(reader, &udf_fs),
                ContentFormat::BdTs,
            )
        } else if udf_fs.find_dir("/HVDVD_TS").is_some() {
            (
                Self::scan_hddvd_titles(reader, &udf_fs),
                ContentFormat::MpegPs,
            )
        } else if udf_fs.find_dir("/VIDEO_TS").is_some() {
            (
                Self::scan_dvd_titles(reader, &udf_fs),
                ContentFormat::MpegPs,
            )
        } else {
            (Vec::new(), ContentFormat::BdTs)
        };
        // Title ordering: titles[0] should be the canonical main feature.
        //
        // Naive "longest duration first" misranks branching UHDs (see
        // `canonical_title_order` for the full rationale). Sort the
        // titles so the consumer-side `-t 1` / autorip's main-feature
        // picker / `disc.titles.first()` all converge on the actual
        // movie instead of the virtual play-all composite.
        let capacity_bytes = capacity as u64 * 2048;
        titles.sort_by(|a, b| Self::canonical_title_order(a, b, capacity_bytes));

        // 4. Metadata + labels
        let meta_title = Self::read_meta_title(reader, &udf_fs);
        crate::labels::apply(reader, &udf_fs, &mut titles);

        // Optional content-based forced-subtitle detection. `info` opts in so its
        // forced flags match what the muxer derives during a rip (both use the
        // one shared PGS classifier); the rip path leaves it off — the muxer
        // detects forced while muxing, without a second read of the clip.
        if opts.probe_forced_subtitles {
            for title in &mut titles {
                if title.content_format == ContentFormat::BdTs {
                    pgs_forced_probe::probe_and_set_forced(reader, title);
                }
            }
        }
        crate::labels::fill_defaults(&mut titles);

        // 5. Format (AACS MKB generation → BD/UHD/FMTS; tree → HD-DVD/DVD) and
        //    layers. Region coding is not yet decoded from the disc — every disc
        //    reports Region-free for now (correct for all UHD; a stub for
        //    region-locked BD/DVD until region detection is implemented).
        let format = Self::detect_disc_format(reader, &udf_fs, &titles);
        let layers = if capacity > 24_000_000 { 2 } else { 1 };
        let region = DiscRegion::Free;

        // 6. CSS detection for DVDs.
        //    Detection from a single probe sector would miss
        //    DVDs whose first sector is unscrambled, so the crack path
        //    scans extents internally and bottoms out at None on
        //    unencrypted media.
        // CSS for a live-drive DVD is resolved by the drive-authentication
        // path in `Disc::scan` (which has `&mut Drive`), AFTER this function
        // returns. We deliberately do NOT run the reader-based crack path here:
        // it is non-functional against this crate's descrambler (always returns
        // None — see `css::crack_key`), and on a CSS-protected disc it would scan up
        // to 50,000 scrambled sectors one-by-one, each rejected by the drive
        // with sense 05/6F/03 ("read of scrambled sector without
        // authentication") — roughly an hour of failing reads before the real
        // auth path ever runs. Leave `css` unresolved here.
        let css = None;
        let encrypted = encrypted || css.is_some();

        tracing::info!(
            target: "freemkv::scan",
            phase = "scan_with",
            titles = titles.len(),
            encrypted,
            elapsed_ms = scan_with_t0.elapsed().as_millis() as u64,
            "end"
        );
        let disc = Disc {
            volume_id: udf_fs.volume_id.clone(),
            meta_title,
            format,
            capacity_sectors: capacity,
            capacity_bytes: capacity as u64 * 2048,
            layers,
            titles,
            region,
            aacs,
            css,
            encrypted,
            aacs_error,
            // CSS crack runs AFTER scan_with returns (in `scan` / `scan_image`),
            // which set this when they observe scrambled-but-uncracked content.
            css_error: None,
            content_format,
        };

        // Structured scan diagnostic block (--log-level 3). Emits the
        // per-title / per-stream / decision / AACS rows under the
        // `freemkv::diag` target; a no-op unless that target is enabled.
        // (DVD per-cell category rows are emitted earlier from the IFO scan,
        // before the per-cell detail is lowered away.)
        crate::diag::dump_disc(&disc);

        Ok(disc)
    }

    // ── Internal helpers ────────────────────────────────────────────────────

    /// Detect disc format from the main title's video streams.
    /// Total ordering used to sort `Disc::titles` so `titles[0]` is the
    /// canonical main feature.
    ///
    /// **Why not just sort by duration descending?** Branching UHDs
    /// (and some BD authoring) ship a "play-all" virtual playlist that
    /// references the same source clips multiple times for seamless
    /// alternate-angle / alternate-ending playback. Those playlists
    /// report an inflated `duration_secs` (often 4+ hours) and an
    /// inflated `size_bytes` greater than the disc's physical
    /// capacity. Example seen in the wild — *The Amateur (2025)* UHD,
    /// 58.5 GB BD-100 disc:
    ///
    /// | Title | Playlist     | Duration | Size    | Clips |
    /// |-------|--------------|----------|---------|-------|
    /// |   1   | 00020.mpls   | 4h 13m   | 92.4 GB |  253  |
    /// |   2   | 00800.mpls   | 2h 02m   | 57.2 GB |    1  |
    ///
    /// Title 1's 92.4 GB cannot fit on a 58.5 GB disc unless the same
    /// clip data is referenced multiple times — proof it's a virtual
    /// composite. A duration-only sort would put it at `titles[0]`,
    /// so `freemkv -t 1`, `disc.titles.first()`, and autorip's
    /// main-feature picker all grab the 4-hour composite instead of
    /// the 2-hour movie that actually matches TMDB.
    ///
    /// **Sort priority (titles[0] = most likely main feature):**
    /// 1. Real titles (`size_bytes ≤ capacity_bytes`) before virtual
    ///    composites. The capacity check is a hard "physically
    ///    possible data on this disc" gate.
    /// 2. Among real titles, LARGEST physical size first — the main
    ///    feature is the biggest real title on the disc. (This replaced
    ///    the old clip-count ordering, which mis-ranked chapter-per-clip
    ///    discs like Fast & Furious.)
    /// 3. Tiebreak on longer duration first.
    ///
    /// **Effect on non-branching discs:** unchanged — the main movie
    /// is already the longest 1-clip title.
    /// **Effect on branching UHDs:** the virtual play-all playlist is
    /// pushed to the back, the actual movie surfaces at index 0.
    pub fn canonical_title_order(
        a: &DiscTitle,
        b: &DiscTitle,
        capacity_bytes: u64,
    ) -> std::cmp::Ordering {
        // A title bigger than the whole disc is a "play-all" composite artifact
        // (its declared size double-counts clips shared with other playlists) —
        // demote it below any real single title.
        let a_oversize = a.size_bytes > capacity_bytes;
        let b_oversize = b.size_bytes > capacity_bytes;
        a_oversize
            .cmp(&b_oversize)
            // PRIMARY: largest physical size = the main feature. Robust where
            // duration and clip-count are not — a decoy "play-all" playlist runs
            // long (e.g. 1h31m) but is tiny (0.4 GB of reused/junk clips), and the
            // real feature is often chaptered into MANY clips (one per chapter),
            // which the old clip-count-ascending key wrongly demoted below 1-clip
            // bonus reels. Validated across 23 UHD/BD discs — fixes F9 / Fast Five
            // / Fast & Furious 6 / Furious 7 (feature was ranked ~#13–36), no
            // regressions on the 19 already correct.
            .then_with(|| b.size_bytes.cmp(&a.size_bytes))
            // Tiebreak for equal-size twins: longer duration, then richer audio —
            // the same feature authored as sibling playlists (a full-audio main
            // vs an audio-reduced twin, e.g. Fight Club's 00800 [DTS-HD MA + 13
            // tracks] vs 00004 [stereo AC-3 only]). Prefer lossless-multichannel.
            .then_with(|| b.duration_secs.total_cmp(&a.duration_secs))
            .then_with(|| Self::audio_richness(b).cmp(&Self::audio_richness(a)))
    }

    /// Audio-richness rank for `canonical_title_order`'s same-length tiebreak.
    /// Higher is better: `(any lossless track, best channel count, audio count)`.
    fn audio_richness(t: &DiscTitle) -> (u8, u8, usize) {
        let mut lossless = 0u8;
        let mut max_ch = 0u8;
        let mut count = 0usize;
        for s in &t.streams {
            if let Stream::Audio(a) = s {
                count += 1;
                if matches!(
                    a.codec,
                    Codec::TrueHd | Codec::DtsHdMa | Codec::DtsHdHr | Codec::Lpcm | Codec::Flac
                ) {
                    lossless = 1;
                }
                let ch = match a.channels {
                    AudioChannels::Surround71 => 8,
                    AudioChannels::Surround61 => 7,
                    AudioChannels::Surround51 => 6,
                    AudioChannels::Surround50 => 5,
                    AudioChannels::Quad => 4,
                    AudioChannels::Stereo21 => 3,
                    AudioChannels::Stereo => 2,
                    AudioChannels::Mono => 1,
                    AudioChannels::Unknown => 0,
                };
                max_ch = max_ch.max(ch);
            }
        }
        (lossless, max_ch, count)
    }

    /// The disc format, from the two on-disc axes:
    ///   * **tree** → HD-DVD (`HVDVD_TS/`) and DVD (`VIDEO_TS/`) are tree-level
    ///     peers with their own enumerators;
    ///   * **AACS MKB generation** → within the BD tree (`BDMV/`), the MKB Type
    ///     record decides BD (1.0) / UHD (2.0) / FMTS (2.1). This is the
    ///     authoritative, cheap signal (the Type record is the first bytes of
    ///     `MKB_RO.inf`) — reusing [`crate::aacs::mkb::mkb_type`] /
    ///     [`crate::aacs::mkb::MkbType::generation`], not a filesystem heuristic.
    ///
    /// An unencrypted / MKB-less BD tree falls back to video resolution (still a
    /// BD-tree disc, so never below [`DiscFormat::BluRay`]).
    fn detect_disc_format(
        reader: &mut dyn SectorSource,
        udf_fs: &crate::udf::UdfFs,
        titles: &[DiscTitle],
    ) -> DiscFormat {
        use crate::aacs::mkb::{AacsVersion, mkb_type};
        // Tree priority MUST match the title-scan dispatch (BDMV → HVDVD_TS →
        // VIDEO_TS): otherwise a disc carrying two trees would be classified as
        // one format but enumerated as another (e.g. BD titles tagged HdDvd).
        if udf_fs.find_dir("/BDMV").is_some() {
            // Only the Type-and-Version record (first record) is needed.
            if let Ok(mkb) = udf_fs.read_file_prefix(reader, "/AACS/MKB_RO.inf", 64) {
                match mkb_type(&mkb).map(|t| t.generation()) {
                    Some(AacsVersion::V21) => return DiscFormat::Fmts,
                    Some(AacsVersion::V20) => return DiscFormat::Uhd,
                    Some(AacsVersion::V10) => return DiscFormat::BluRay,
                    None => {}
                }
            }
            // Unencrypted / unreadable MKB: refine by resolution, but a BD-tree
            // disc is never below Blu-ray — only UHD can promote it. detect_format
            // is a general resolution classifier that can return Dvd for an SD
            // bonus/menu title, which must NOT tag a BDMV disc as DVD (that
            // mis-sizes the ECC-block sweep). Clamp anything but UHD up to BluRay.
            return match Self::detect_format(titles) {
                DiscFormat::Uhd => DiscFormat::Uhd,
                _ => DiscFormat::BluRay,
            };
        }
        if udf_fs.find_dir("/HVDVD_TS").is_some() {
            return DiscFormat::HdDvd;
        }
        if udf_fs.find_dir("/VIDEO_TS").is_some() {
            return DiscFormat::Dvd;
        }
        DiscFormat::Unknown
    }

    fn detect_format(titles: &[DiscTitle]) -> DiscFormat {
        for title in titles.iter().take(3) {
            for stream in &title.streams {
                if let Stream::Video(v) = stream {
                    if v.resolution.is_uhd() {
                        return DiscFormat::Uhd;
                    }
                    if v.resolution.is_hd() {
                        return DiscFormat::BluRay;
                    }
                    if v.resolution.is_sd() {
                        return DiscFormat::Dvd;
                    }
                }
            }
        }
        DiscFormat::Unknown
    }

    fn read_capacity(session: &mut Drive) -> Result<u32> {
        let cdb = [
            crate::scsi::SCSI_READ_CAPACITY,
            0x00,
            0x00,
            0x00,
            0x00,
            0x00,
            0x00,
            0x00,
            0x00,
            0x00,
        ];
        let mut buf = [0u8; 8];
        session.scsi_execute(
            &cdb,
            crate::scsi::DataDirection::FromDevice,
            &mut buf,
            5_000,
        )?;
        let lba = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]);
        // `last_lba + 1` = sector count. Guard the 0xFFFF_FFFF sentinel
        // (capacity exceeds 32 bits) so it surfaces as an error instead of
        // wrapping to 0 in release — mirrors the public `decode_read_capacity`.
        lba.checked_add(1)
            .ok_or(crate::error::Error::DiscCapacityOverflow)
    }
}

/// A decryption key handed to libfreemkv by the caller.
///
/// libfreemkv is **lookup-free**: it never reads a keydb, never talks to a key
/// server, never searches paths. The application resolves a key from whatever
/// source it likes (a local keydb, an online key service, a mapfile cache) and
/// hands it in here; libfreemkv uses it to decrypt, deriving any remaining
/// AACS-chain steps it can from disc-read inputs (MKB / VID / `Unit_Key_RO.inf`).
///
/// `#[non_exhaustive]`: AACS is a derivation chain
/// (`DK →(MKB)→ MK →(VID)→ VK →(Unit_Key_RO)→ UK`). Each variant is an entry
/// point at one level of that chain; [`Disc::decrypt_with`] derives down from
/// it to the per-CPS unit keys. New levels can be added without breaking
/// callers.
#[derive(Clone)]
#[non_exhaustive]
pub enum Key {
    /// Device key(s) (AACS DK, positioned). libfreemkv walks the MKB
    /// (subset-difference tree) to find the one that applies → media key →
    /// VUK → unit keys. A source hands in its FULL device-key set, because
    /// choosing which one applies *is* the MKB walk (derivation), and all
    /// derivation lives here — never in a source.
    Device(Vec<crate::aacs::types::DeviceKey>),
    /// Processing key(s) (AACS PK). libfreemkv applies each against the MKB
    /// → media key → VUK → unit keys.
    Processing(Vec<[u8; 16]>),
    /// Media key candidate(s) (Km). A source hands its full pool because an MK
    /// is MKB-scoped (shared across a pressing/MKB family) — picking the one
    /// that applies is `km_verifies` against this disc's MKB, which is
    /// derivation, so it lives here. libfreemkv verifies, then derives the VUK
    /// via the Volume ID and the per-CPS-unit keys.
    Media(Vec<[u8; 16]>),
    /// Volume Unique Key (VK / VUK). libfreemkv decrypts `Unit_Key_RO.inf`
    /// into the per-CPS-unit keys. NOT terminal — the chain continues to the
    /// unit keys.
    Volume([u8; 16]),
    /// Final per-CPS-unit AACS keys (`(cps_unit, 16-byte key)`). A key source
    /// (keydb / key server) resolved these, or they were cached in the mapfile
    /// at sweep; libfreemkv decrypts directly with no further derivation. This
    /// is the terminal level every other variant derives down into.
    Unit(Vec<(u32, [u8; 16])>),
}

// Redacting `Debug`: `Key` is crate-root re-exported and is the key-transport
// type crossing `Disc::decrypt_with`; every variant carries raw key material.
// Print only the variant name and count — never bytes. Guarded by
// `aacs_state_and_key_debug_are_redacted`.
impl std::fmt::Debug for Key {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Key::Device(v) => write!(f, "Key::Device(<{} redacted>)", v.len()),
            Key::Processing(v) => write!(f, "Key::Processing(<{} redacted>)", v.len()),
            Key::Media(v) => write!(f, "Key::Media(<{} redacted>)", v.len()),
            Key::Volume(_) => f.write_str("Key::Volume(<redacted>)"),
            Key::Unit(v) => write!(f, "Key::Unit(<{} redacted>)", v.len()),
        }
    }
}

/// True if `unit_keys` covers EVERY supplied scrambled content `sample` — the
/// validation gate for [`Disc::decrypt_with`]. Conservative: a sample that is
/// not AACS-scrambled proves nothing, and with no scrambled sample at all there
/// is nothing to disprove against, so it returns `true` (accept).
///
/// It returns `false` when ANY scrambled sample cannot be restored to clear
/// MPEG-TS by ANY unit key in the set. That covers two distinct failure shapes:
///   1. a wholly wrong key (a keydb VK that does not match this disc) — no
///      sample decrypts; and
///   2. a *partially* applicable key set on a multi-CPS-unit disc — the resolved
///      keys cover CPS unit 0 but not CPS unit 1. Accepting on the first sample
///      that decrypts (the old behaviour) would commit such a set, after which
///      CPS-unit-1 sectors pass through as raw encrypted bytes into the ISO/MKV
///      with no error surfaced anywhere. Requiring every scrambled sample to
///      decrypt rejects the incomplete set so the caller falls through to the
///      next candidate (and ultimately surfaces a key error rather than silently
///      writing ciphertext).
///
/// Reuses the ecosystem's single `is_clean` content-clarity predicate and the
/// full (bus + AACS) unit decrypt, so it agrees with the actual mux decrypt.
fn aligned_unit_keys_validate(
    unit_keys: &[(u32, [u8; 16])],
    read_data_key: Option<&[u8; 16]>,
    samples: &[Vec<u8>],
    format: ContentFormat,
) -> bool {
    use crate::aacs::content::{
        ALIGNED_UNIT_LEN, aacs_unit_needs_decrypt, decrypt_bus, decrypt_unit, is_clean,
    };
    let scrambled: Vec<&[u8]> = samples
        .iter()
        .map(|s| s.as_slice())
        .filter(|s| aacs_unit_needs_decrypt(s, format))
        .collect();
    if scrambled.is_empty() {
        return true; // nothing to disprove against — accept
    }
    if unit_keys.is_empty() {
        return false;
    }
    let mut probe = vec![0u8; ALIGNED_UNIT_LEN];
    let total = (scrambled.len() as u64) * (unit_keys.len() as u64);
    let mut tried = 0u64;
    let mut hb = crate::progress::Heartbeat::new("scan_key_trial");
    // Every scrambled sample must be covered by SOME unit key. A single sample
    // that no key descrambles means the key set is incomplete (wrong key, or a
    // CPS unit left uncovered) — reject so the wrong/partial set never commits.
    for sample in scrambled {
        let mut covered = false;
        for (_, k) in unit_keys {
            // Pure-CPU inner loop: only consult the clock every 256 trials.
            hb.tick_cpu(tried, total);
            tried += 1;
            probe.copy_from_slice(&sample[..ALIGNED_UNIT_LEN]);
            // bus layer (AACS 2.0) first, then the CPS unit key, then the structural
            // proof — the composed form of the old `decrypt_unit_full`.
            if let Some(rdk) = read_data_key {
                decrypt_bus(&mut probe, rdk);
            }
            decrypt_unit(&mut probe, k);
            if is_clean(&probe, format) {
                covered = true;
                break;
            }
        }
        if !covered {
            return false;
        }
    }
    true
}

impl Disc {
    /// Get the resolved decryption keys for this disc.
    /// Used by disc-to-ISO and other full-disc operations.
    pub fn decrypt_keys(&self) -> crate::decrypt::DecryptKeys {
        if let Some(ref aacs) = self.aacs {
            // An AACS state with NO unit keys is "encrypted, no keys" — e.g.
            // the VID-only state from out-of-band resolution before a Unit Key
            // is supplied. Report None so callers treat it as missing keys
            // (not a usable, empty key set).
            if aacs.unit_keys.is_empty() {
                return crate::decrypt::DecryptKeys::None;
            }
            crate::decrypt::DecryptKeys::Aacs {
                unit_keys: aacs.unit_keys.clone(),
                read_data_key: aacs.read_data_key,
                format: self.content_format,
            }
        } else if let Some(ref css) = self.css {
            crate::decrypt::DecryptKeys::Css {
                title_key: css.title_key,
            }
        } else {
            crate::decrypt::DecryptKeys::None
        }
    }

    /// Resolve a WHOLE-DISC AACS key map for a decrypting sweep (`disc:// → iso://`):
    /// the union of every title's proactive key map ([`crate::mux::resolve_mux_key_map`]),
    /// so a sequential read of the entire disc decrypts each content unit with its
    /// mapped key and passes clear filesystem/nav sectors (in no range) through.
    /// Fails loud (via the per-title resolve) if any content unit's key is missing.
    /// `keys` is mutated as fetched keys are banked; the merged ranges are disjoint
    /// (titles that share a clip resolve the same span — the duplicate is dropped).
    pub fn resolve_content_key_map(
        &self,
        reader: &mut dyn SectorSource,
        keys: &mut crate::decrypt::DecryptKeys,
        fetch: Option<&crate::sector::KeyFetch>,
        halt: Option<&crate::halt::Halt>,
    ) -> Result<crate::decrypt::AacsKeyMap> {
        let mut ranges: Vec<(u32, u32, usize, crate::decrypt::Phase)> = Vec::new();
        for title in &self.titles {
            let map = crate::mux::resolve_mux_key_map(
                reader,
                title,
                keys,
                fetch,
                self.content_format,
                halt,
            )?;
            ranges.extend_from_slice(map.ranges());
        }
        Ok(crate::decrypt::AacsKeyMap::from_ranges_phased(
            merge_content_key_ranges(ranges),
        ))
    }

    /// The disc's AACS-encrypted content as a sorted, merged, disjoint set of
    /// `(start_lba, sector_count)` ranges — the union of every title's m2ts
    /// stream extents.
    ///
    /// This is the authoritative "which sectors are encrypted" map for a
    /// whole-disc read. AACS only encrypts the m2ts AV streams, so a sector is
    /// encrypted content **iff** it falls inside one of these ranges; everything
    /// else (UDF filesystem, BDMV nav, PLAYLIST/CLIPINF) is always clear.
    ///
    /// The in-read decrypt-verify gate (`DecryptingSectorSource`) uses this so it
    /// never consults the TS-sync content check about
    /// non-content bytes — filesystem data has no TS sync and would otherwise be
    /// mistaken for ciphertext (the first-2-GB false-positive this fixes).
    ///
    /// Empty when the disc has no parsed titles (CSS / unencrypted / unscanned);
    /// callers treat an empty map as "no content gate" and fall back accordingly.
    pub fn encrypted_content_ranges(&self) -> Vec<(u32, u32)> {
        merged_extents(self.titles.iter().flat_map(|t| &t.extents))
    }

    /// The 40-hex AACS disc id (SHA1 of `Unit_Key_RO.inf`, no `0x` prefix), or
    /// empty when this disc has no captured AACS state. Used to name the disc in
    /// a [`Error::NoDiscKey`] so the application can tell the user which disc to
    /// add to the keydb.
    pub(crate) fn aacs_disc_hash(&self) -> String {
        self.aacs
            .as_ref()
            .map(|a| crate::hex::strip_hex_prefix(&a.disc_hash).to_string())
            .unwrap_or_default()
    }

    /// The unlocker matrix for this scanned disc on `drive`: each REGISTERED
    /// unlocker's name + whether it actually **did work this rip** — i.e. ran and
    /// accomplished its job, NOT merely "matched the disc kind". Registry-driven
    /// names (no hardcoding) so the CLI and autorip render an identical, always-
    /// current report; the per-unlocker runtime signal is computed here because
    /// the library owns unlock semantics AND the disc/drive state.
    ///
    /// The distinction matters: on a LibreDrive drive, LibreDrive's firmware route
    /// removes the AACS bus and reads the VID, so the AACS host-cert unlocker
    /// never runs — it "matched" (AACS disc) but did nothing. `did-work` reports
    /// that honestly (`AACS: no`), and on a *stock* drive that fell back to the
    /// cert route it reports `LibreDrive: no, AACS: yes` — the real diagnostic.
    pub fn unlocker_matrix(&self, drive: &crate::Drive) -> Vec<(&'static str, bool)> {
        // The drive-prep unlocker that actually ran (recorded on init):
        // "LibreDrive" (MediaTek) or "Renesas" — mutually exclusive per drive.
        let prep = drive.unlocker_name();
        // Only LibreDrive (MediaTek) removes AACS bus encryption AT THE DRIVE;
        // Renesas unlocks features but leaves the bus to the cert.
        let ld_removed_bus = prep == Some("LibreDrive");
        crate::unlock_bridge::unlocker_names()
            .into_iter()
            .map(|name| {
                let did_work = match name {
                    // Each firmware unlocker did work iff it was the one that ran.
                    "LibreDrive" => ld_removed_bus,
                    "Renesas" => prep == Some("Renesas"),
                    // The AACS host-cert route removed the bus ONLY when the
                    // firmware didn't (stock or Renesas drive) AND AACS state was
                    // actually obtained.
                    "AACS" => self.aacs.is_some() && !ld_removed_bus,
                    // The DVD read-unlock (CSS bus-auth) is issued during the scan
                    // for EVERY DVD to clear the drive's scrambled-read barrier;
                    // unlike the firmware/AACS arms above (whose success is already
                    // captured in disc state), the bus-auth outcome is not tracked
                    // separately, so this arm reports the MEDIUM — "the DVD
                    // read-unlock path engaged" — rather than a per-rip success
                    // bit. That is intentional: the CSS descramble is keyless and
                    // handled at mux time, and a genuine bus-auth failure surfaces
                    // downstream as a read/crack error, not here. (Formerly this
                    // reported `self.css.is_some()` — "a crack recovered a key" —
                    // which under-reported: an encrypted DVD that reads and muxes
                    // fine showed "no".)
                    "DVD" => self.format == DiscFormat::Dvd,
                    // A newly-registered unlocker with no runtime signal wired
                    // here yet: report `no` rather than guess.
                    _ => false,
                };
                (name, did_work)
            })
            .collect()
    }

    /// The system-wide decrypt correctness gate.
    ///
    /// Returns `Ok(())` when it is safe to proceed with a copy or mux, and a
    /// clear typed error when decryption is **needed but unavailable** — the
    /// case that would otherwise write ciphertext (disc→ISO) or feed the demux
    /// undecryptable bytes (mux) and exit 0. Every copy/mux entry point calls
    /// this **after key resolution and before any source-data processing
    /// begins**, so the verdict is identical everywhere and the failure is a
    /// pre-flight one (no partial output).
    ///
    /// The verdict, in order:
    /// - `raw == true` → `Ok(())`. `--raw` intentionally skips decryption and
    ///   needs no key (the caller wants an encrypted image).
    /// - `self.css_error.is_some()` → `Err(Error::CssKeyMissing)`. The scan saw
    ///   scrambled CSS sectors but recovered no title key (`self.css` is `None`
    ///   yet the content IS encrypted). Treating `css.is_none()` as
    ///   "unencrypted" would mux scrambled MPEG as plaintext garbage.
    /// - AACS-encrypted (`self.aacs.is_some()`) with no usable key
    ///   (`decrypt_keys()` is `None`) → `Err(Error::NoDiscKey { .. })`, naming
    ///   the disc by hash.
    /// - CSS-encrypted (`self.css.is_some()`) with no usable key →
    ///   `Err(Error::CssKeyMissing)`. (The disc-wide `decrypt_keys()` yields
    ///   `Css{..}` whenever `css.is_some()`, so this is defensive; the live
    ///   multi-VTS case is gated by [`Self::ensure_decryptable_keys`].)
    /// - otherwise → `Ok(())`. A genuinely unencrypted disc has `None` keys
    ///   legitimately, and a CSS disc whose keyless crack succeeded has a key.
    pub fn ensure_decryptable(&self, raw: bool) -> Result<()> {
        self.ensure_decryptable_keys(raw, &self.decrypt_keys())
    }

    /// [`Self::ensure_decryptable`] against a caller-resolved key set, for the
    /// per-title path. A multi-VTS CSS DVD resolves its key with
    /// [`Self::decrypt_keys_for_title`] (which can return `None` when the chosen
    /// title's VTS could not be re-cracked even though the disc-wide
    /// `decrypt_keys()` is `Css{..}`); the gate must judge THAT key, not the
    /// disc-wide one. The "is the source encrypted?" question is answered by the
    /// scan-captured disc state (`css_error`/`aacs`/`css`), never by the keys —
    /// so an unencrypted disc (no AACS/CSS state) never false-errors regardless
    /// of `keys`.
    pub fn ensure_decryptable_keys(
        &self,
        raw: bool,
        keys: &crate::decrypt::DecryptKeys,
    ) -> Result<()> {
        // --raw skips decryption entirely: never error, even on an encrypted
        // disc with no key (the user asked for the encrypted image).
        if raw {
            return Ok(());
        }
        // Scrambled-but-uncracked CSS: the disc is encrypted but `css` is None,
        // so the key check below can't see it. Surface the recorded hard error.
        if self.css_error.is_some() {
            return Err(Error::CssKeyMissing);
        }
        // Decryption is needed iff the disc carries cipher state. A no-key
        // verdict on a non-encrypted disc is impossible here (the disc has no
        // AACS/CSS state), so a genuinely unencrypted disc never errors.
        let needs_key = matches!(keys, crate::decrypt::DecryptKeys::None);
        if needs_key {
            if self.aacs.is_some() {
                // E7017 vs E7022 split: when key resolution had derivation
                // material (device / processing keys) but no Volume ID to derive
                // the unit key, the captured `aacs_error` is `AacsVidUnavailable`
                // — report THAT (the fix is recovering the VID, not adding keys),
                // not the generic `NoDiscKey`. Any other (or absent) reason →
                // `NoDiscKey` naming the disc by hash, unchanged.
                if matches!(self.aacs_error, Some(Error::AacsVidUnavailable)) {
                    return Err(Error::AacsVidUnavailable);
                }
                return Err(Error::NoDiscKey {
                    disc_hash: self.aacs_disc_hash(),
                });
            }
            if self.css.is_some() {
                return Err(Error::CssKeyMissing);
            }
        }
        Ok(())
    }

    /// Resolve decryption keys for muxing a *specific* title.
    ///
    /// For a **DVD** the CSS title key MUST be recovered before descrambling: a
    /// scrambled sector without a Stevenson crib cannot self-crack, and CSS leaves
    /// the pack/PES header clear, so a sector left un-descrambled would mux as a
    /// structurally-valid but corrupt PES packet with no loss reported. Two ways
    /// to get it:
    ///
    /// - **Fast path** — the scan already cracked a key whose LBA span
    ///   ([`crate::css::CssState::crack_span`]) covers this title's VTS: reuse it.
    ///   No re-read, and on a live drive no second CSS bus-auth round-trip. CSS
    ///   title keys are per-VTS, so an overlapping span is the same key.
    /// - **Crack** — when up-front detection missed (`self.css == None`) or the
    ///   title lives in a different VTS: crack the key from this title's OWN
    ///   extents in a SINGLE scan, in natural PLAYBACK ORDER (never largest-cell-
    ///   first, which starved the crack in a big cell's clear prefix — the 1.5.1
    ///   bug). Playback order reaches the scrambled feature body after only the
    ///   small clear front matter (logo / rating card) that precedes it. One
    ///   scan = one CSS-locked early-bail, so a locked title is not re-hammered
    ///   per cell against a live drive (hard rule #2); its 50k-sector budget is
    ///   the same accepted bound the disc-wide scan uses.
    ///
    /// Crucially the crack path does NOT gate on `self.css`: a detection miss can
    /// never route the mux into raw passthrough of scrambled sectors.
    ///
    /// Outcomes (`batch_sectors` sizes the crack's batched reads):
    /// - `(Css{title_key}, false)` — descramble with the recovered/reused key.
    /// - `(None, true)` — a genuinely-clear title needs no key; the gate passes it.
    /// - `(None, false)` — scrambled but no key recoverable → hard failure via
    ///   [`Self::ensure_title_decryptable`], never a silent garbage mux.
    ///
    /// Non-DVD schemes (AACS / FMTS / genuinely unencrypted) return
    /// [`Self::decrypt_keys`] unchanged.
    pub fn decrypt_keys_for_title(
        &self,
        idx: usize,
        reader: &mut dyn SectorSource,
        batch_sectors: u16,
    ) -> (crate::decrypt::DecryptKeys, bool) {
        // Non-DVD (AACS / FMTS / genuinely unencrypted): disc-wide keys, unchanged.
        if self.format != DiscFormat::Dvd {
            return (self.decrypt_keys(), false);
        }
        let title = match self.titles.get(idx) {
            Some(t) if !t.extents.is_empty() => t,
            // No extents to crack from: nothing scrambled to worry about, so mark
            // it clear (`true`). Returning `false` here would let the gate's DVD
            // "None keys + not clear = scrambled-uncracked" rule wrongly hard-fail
            // a genuinely-unencrypted DVD title that has no extents.
            _ => return (self.decrypt_keys(), true),
        };
        // Fast path: reuse the scan's cracked key if its span covers this title's
        // VTS. `crack_span: None` (unknown provenance) is treated as covering.
        if let Some(css) = self.css.as_ref() {
            let covers = match css.crack_span {
                None => true,
                Some((cs, ce)) => title
                    .extents
                    .iter()
                    .any(|e| e.start_lba < ce && cs < e.start_lba.saturating_add(e.sector_count)),
            };
            if covers {
                return (
                    crate::decrypt::DecryptKeys::Css {
                        title_key: css.title_key,
                    },
                    false,
                );
            }
        }
        // Detection miss or a different VTS: crack this title's key from its OWN
        // extents in a SINGLE scan, in natural PLAYBACK ORDER (never largest-cell-
        // first — that was the 1.5.1 garbage bug, where a big cell's clear prefix
        // starved the crack). Playback order reaches the scrambled feature body
        // after only the (small) clear front matter that precedes it on a real
        // disc. This is ONE crack_key_outcome call, exactly like the disc-wide
        // scan: its single CSS-locked early-bail runs at most once, so a locked/
        // uncrackable title is NOT re-hammered per cell against the live drive
        // (hard rule #2). The crack's 50k-sector budget bounds a fully-clear title,
        // the same accepted bound the disc-wide scan uses.
        match crate::css::crack_key_outcome(reader, &title.extents, batch_sectors, None) {
            crate::css::CrackOutcome::Cracked(state) => (
                crate::decrypt::DecryptKeys::Css {
                    title_key: state.title_key,
                },
                false,
            ),
            // Scrambled but no key recoverable → hard failure.
            crate::css::CrackOutcome::ScrambledUncracked => {
                (crate::decrypt::DecryptKeys::None, false)
            }
            // No scrambled sector anywhere in the whole title → genuinely clear.
            crate::css::CrackOutcome::Unencrypted => (crate::decrypt::DecryptKeys::None, true),
        }
    }

    /// Per-title decrypt gate that honours the `title_is_clear` verdict from
    /// [`Self::decrypt_keys_for_title`].
    ///
    /// Identical to [`Self::ensure_decryptable_keys`] EXCEPT it does not raise
    /// `E7023` when the chosen title proved genuinely clear (`title_is_clear`):
    /// a multi-VTS CSS disc can carry an unencrypted stub title in its own VTS,
    /// and that title needs no key. The disc-wide `css.is_some()` is true, so the
    /// plain gate would false-error; this one passes the clear title through.
    /// A scrambled-but-uncrackable title (`title_is_clear == false`, key `None`)
    /// still hard-fails exactly as before.
    pub fn ensure_title_decryptable(
        &self,
        raw: bool,
        keys: &crate::decrypt::DecryptKeys,
        title_is_clear: bool,
    ) -> Result<()> {
        if raw {
            return Ok(());
        }
        // A title proven clear by its own re-crack (no scrambled sector in its
        // extents) needs no key even though the disc is CSS — pass it. The
        // disc-wide `css_error` is deliberately NOT consulted here: it reflects
        // the MAIN feature's crack, not this clear extra title.
        if title_is_clear && !keys.is_encrypted() {
            return Ok(());
        }
        // A DVD title that cracked to no key and is NOT clear is scrambled-but-
        // uncrackable (`decrypt_keys_for_title` → `ScrambledUncracked`). Hard-fail
        // here directly: `ensure_decryptable_keys` gates CSS on `self.css.is_some()`
        // (the scan's disc-wide detection), which can be `None` when that up-front
        // detection missed — exactly the case the per-title crack exists to catch.
        // Without this, an uncrackable DVD title would fall through to `Ok` and mux
        // scrambled sectors as corrupt PES at exit 0.
        if self.format == DiscFormat::Dvd && !title_is_clear && !keys.is_encrypted() {
            return Err(Error::CssKeyMissing);
        }
        // A usable per-title key was resolved (a freshly-cracked CSS key, or AACS
        // unit keys) — the title IS decryptable, so pass it WITHOUT consulting the
        // disc-wide gate. `ensure_decryptable_keys` hard-fails on `self.css_error`
        // unconditionally, which reflects the MAIN feature's crack: a bonus title
        // in a different VTS that just cracked its own key must not be blocked by
        // the main title having failed.
        if keys.is_encrypted() {
            return Ok(());
        }
        self.ensure_decryptable_keys(raw, keys)
    }

    /// Inject pre-resolved AACS unit keys into a scanned disc — the deferred-mux
    /// / resume path. The keys come from the mapfile's `# freemkv-uk:` header
    /// (persisted at sweep time when the disc was keyed), so the mux decrypts
    /// directly with NO key-service round-trip. Populates `self.aacs.unit_keys`
    /// so [`decrypt_keys`] returns them and marks the source `ExternalUk`.
    ///
    /// If the scan built no AACS state (`self.aacs == None`) — which happens
    /// when the keydb was absent at scan time (`scan_aacs_no_keydb` →
    /// `aacs_error = KeydbLoad`) — this synthesizes a minimal `ExternalUk`
    /// state for an encrypted AACS disc. A Unit Key is the FINAL per-title
    /// decryption key; the keydb is only needed to *derive* it, and that
    /// derivation already happened at sweep (the UK is in the mapfile). So a UK
    /// alone is sufficient to decrypt the on-disk ISO — AACS 2.0 bus decryption
    /// was applied by the drive at read time, so `read_data_key` is unused for
    /// file-backed mux. Without this, a keyed disc swept without a keydb would
    /// recover its UK yet still report E8005 (no usable `decrypt_keys`) at
    /// remux. No-op for an unencrypted or CSS (DVD) disc.
    pub(crate) fn inject_unit_keys(&mut self, keys: Vec<(u32, [u8; 16])>) {
        if let Some(aacs) = self.aacs.as_mut() {
            aacs.unit_keys = keys;
            aacs.key_source = KeyOrigin::ExternalUk;
        } else if self.encrypted && self.css.is_none() {
            // FMTS is AACS 2.1, a UHD-family (bus-encrypted) format — not BD.
            let uhd_family = matches!(self.format, DiscFormat::Uhd | DiscFormat::Fmts);
            self.aacs = Some(AacsState {
                version: if uhd_family {
                    crate::aacs::mkb::AACS_MAJOR_UHD
                } else {
                    crate::aacs::mkb::AACS_MAJOR_BD
                },
                bus_encryption: uhd_family,
                mkb_version: None,
                disc_hash: String::new(),
                key_source: KeyOrigin::ExternalUk,
                vuk: None,
                unit_keys: keys,
                read_data_key: None,
                volume_id: [0u8; 16],
                uk_ro: Vec::new(),
                mkb: Vec::new(),
            });
            // The prior resolution error (e.g. KeydbLoad) is now moot — we have
            // the decryption key. Clear it so callers don't treat the disc as
            // keyless on the stale error.
            self.aacs_error = None;
        }
    }

    /// The public AACS inputs for this disc, for a [`crate::KeySource`] to look
    /// a key up. `None` when the disc carries no AACS state (unencrypted, CSS,
    /// or AACS inputs not captured at scan). Contains no secrets — just disc
    /// identity plus the on-disc AACS structures.
    pub fn inputs(&self) -> Option<crate::keysource::DiscInputs> {
        self.aacs.as_ref().map(|a| crate::keysource::DiscInputs {
            disc_hash: a.disc_hash.clone(),
            volume_id: a.volume_id,
            version: a.version,
            mkb: a.mkb.clone(),
            unit_key_ro: a.uk_ro.clone(),
            // Content samples need the disc reader, which scan does not retain;
            // the caller fills these for sources that validate against ciphertext.
            samples: Vec::new(),
            // Human title: prefer the UDF/ISO volume identifier, fall back to the
            // BDMV display name. Identity only — a key service may catalog it.
            volume_label: {
                let v = self.volume_id.trim();
                if v.is_empty() {
                    self.meta_title.clone()
                } else {
                    Some(v.to_string())
                }
            },
        })
    }

    /// Apply a caller-resolved [`Key`] so [`Self::decrypt_keys`] yields usable
    /// decryption state. **Lookup-free**: no keydb, no network — the caller
    /// (an application, via a key source) does all resolution and hands the key
    /// in here. For [`Key::Unit`] this is the deferred-mux / resume path: the
    /// unit keys came from a key source or the mapfile cache, and libfreemkv
    /// decrypts directly (see [`Self::inject_unit_keys`]).
    /// `samples` are encrypted on-disc aligned units (each 6144 bytes), supplied
    /// by the caller for content validation. A wrong key — a keydb VK that does
    /// not match this disc, a stale UK — can still *derive* a non-empty (garbage)
    /// unit-key set, so before the key touches disc state we confirm it actually
    /// de-scrambles real ciphertext. This is the single home of key validation:
    /// every caller loops a key source's candidates through `decrypt_with` and a
    /// rejected key (`Err(AacsKeyRejected)`) transparently falls through to the
    /// next. Pass `&[]` when no content sample is available (resume / mapfile
    /// cache) — validation is then skipped and the key is applied as-is.
    pub fn decrypt_with(&mut self, key: Key, samples: &[Vec<u8>]) -> Result<()> {
        // The AACS 2.x bus key, needed to de-scramble a sample for validation;
        // captured before any mutable borrow. None for AACS 1.0 and file-backed
        // ISO units (bus encryption was already removed at read time).
        let read_data_key = self.aacs.as_ref().and_then(|a| a.read_data_key);

        // Resolve the supplied key DOWN to candidate unit keys WITHOUT
        // committing them, so a wrong higher-level key can be rejected before it
        // poisons disc state.
        let (candidate_unit_keys, candidate_vuk) = if let Key::Unit(keys) = key {
            // Terminal — the source / mapfile already holds the final UKs.
            (keys, None)
        } else {
            // Every higher level derives DOWN to the unit keys, reusing the
            // version-dispatched resolver — the single home for all AACS
            // derivation (1.0 / 2.0 / 2.1 / 2.x). It needs the AACS inputs
            // (Unit_Key_RO.inf, MKB, VID) stashed on the disc at scan time.
            let aacs = self.aacs.as_ref().ok_or(crate::error::Error::AacsNoKeys)?;
            if aacs.uk_ro.is_empty() {
                // Scan captured no Unit_Key_RO.inf — nothing to derive into.
                return Err(crate::error::Error::AacsNoKeys);
            }

            // Map the supplied Key -> raw material. The source handed in material
            // at exactly one level; choosing/applying it is the resolver's job.
            let mut supplied = crate::aacs::provider::SuppliedKey {
                device_keys: Vec::new(),
                processing_keys: Vec::new(),
                media_keys: Vec::new(),
                disc_entry: None,
            };
            match key {
                Key::Device(dks) => supplied.device_keys = dks,
                Key::Processing(pks) => supplied.processing_keys = pks,
                Key::Media(mks) => supplied.media_keys = mks,
                Key::Volume(vuk) => {
                    supplied.disc_entry = Some(crate::aacs::types::DiscEntry {
                        disc_hash: aacs.disc_hash.clone(),
                        title: String::new(),
                        media_key: None,
                        disc_id: None,
                        vuk: Some(vuk),
                        unit_keys: Vec::new(),
                    });
                }
                Key::Unit(_) => unreachable!("Key::Unit handled above"),
            }

            // Snapshot inputs (releases the &self borrow before the &mut below).
            let volume_id = aacs.volume_id;
            let mkb = aacs.mkb.clone();
            let uk_ro = aacs.uk_ro.clone();
            let version_u8 = aacs.version;

            let provider_refs: [&dyn crate::aacs::provider::KeyProvider; 1] = [&supplied];
            let ctx = crate::aacs::resolve::ResolveContext {
                unit_key_ro: &uk_ro,
                content_cert: None,
                volume_id: &volume_id,
                providers: &provider_refs,
                mkb: if mkb.is_empty() { None } else { Some(&mkb) },
            };

            // Version dispatch — V10 uses the classical resolver at 48-byte
            // stride; V20/V21 share the 64-byte stride, so try the classical V20
            // paths first and fall back to the 2.1 variant chain. The
            // reason-preserving wrapper threads the no-key cause out so the
            // decrypt gate can report E7017 (had derivation material but no VID)
            // vs E7022 (no usable material) instead of a flat AacsKeyRejected.
            let resolved = crate::aacs::resolve::resolve_keys_with_reason(&ctx, version_u8)
                .map_err(|_reason| crate::error::Error::AacsKeyRejected)?;

            if resolved.unit_keys.is_empty() {
                return Err(crate::error::Error::AacsKeyRejected);
            }
            (resolved.unit_keys, resolved.vuk)
        };

        // VALIDATE against real ciphertext. Conservative: reject only when a
        // supplied sample is AACS-scrambled and NO candidate unit key can
        // de-scramble it. With no samples (or only clear ones) there is nothing
        // to disprove against, so the key is accepted as-is — keeping the
        // sample-less paths (resume / mapfile cache) byte-for-byte unchanged.
        if !aligned_unit_keys_validate(
            &candidate_unit_keys,
            read_data_key.as_ref(),
            samples,
            self.content_format,
        ) {
            return Err(crate::error::Error::AacsKeyRejected);
        }

        // Commit — only now does the key touch disc state.
        match self.aacs.as_mut() {
            Some(a) => {
                a.unit_keys = candidate_unit_keys;
                if candidate_vuk.is_some() {
                    a.vuk = candidate_vuk;
                }
                a.key_source = KeyOrigin::ExternalUk;
            }
            // A Unit key for an AACS disc whose scan built no state (keyless
            // scan, no keydb): synthesize a minimal ExternalUk state.
            None => self.inject_unit_keys(candidate_unit_keys),
        }
        // A prior scan-time resolution error (e.g. keyless scan) is now moot.
        self.aacs_error = None;
        Ok(())
    }
}

/// Mapfile path for a regular output file: appends `.mapfile` to the
/// output path. For `/dev/null` (benchmark) output use
/// [`Disc::mapfile_for`], which special-cases it to a temp-dir path
/// derived from the disc title.
pub(crate) fn mapfile_path_for(iso_path: &std::path::Path) -> std::path::PathBuf {
    let mut s = iso_path.as_os_str().to_os_string();
    s.push(".mapfile");
    std::path::PathBuf::from(s)
}

impl Disc {
    /// Path to the mapfile for a given output path.
    ///
    /// For `/dev/null` output, returns
    /// `{temp_dir}/{volume_id_or_title}.mapfile` (temp dir is
    /// `TMPDIR`-aware and cross-platform). For regular files, returns
    /// `{path}.mapfile`.
    pub fn mapfile_for(&self, path: &std::path::Path) -> std::path::PathBuf {
        if path.as_os_str() == "/dev/null" {
            let name: String = self
                .meta_title
                .as_deref()
                .unwrap_or(&self.volume_id)
                .chars()
                .map(|c| {
                    if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
                        c
                    } else {
                        '_'
                    }
                })
                .collect();
            std::env::temp_dir().join(format!("{name}.mapfile"))
        } else {
            mapfile_path_for(path)
        }
    }
}

const MAX_BATCH_SECTORS: u16 = 510;
const DEFAULT_BATCH_SECTORS_OPTICAL: u16 = 60;
const DEFAULT_BATCH_SECTORS_BLOCK: u16 = 8192;
const MIN_BATCH_SECTORS: u16 = 3;

/// Whether the Linux-sysfs transfer-size probe applies to this device path.
///
/// The probe reads `/sys/block/<name>/...` / `/sys/class/scsi_generic/<name>/...`,
/// which only exist on Linux and only for `/`-delimited node paths. A Windows
/// `\\.\CdRom0` / `\\.\D:` path has no forward slash and no sysfs node, so the
/// probe cannot run and the caller must fall back to the optical default.
fn sysfs_batch_probe_supported(device_path: &str) -> bool {
    cfg!(target_os = "linux") && device_path.contains('/')
}

/// Detect the maximum transfer size in sectors for a device.
pub fn detect_max_batch_sectors(device_path: &str) -> u16 {
    // The sysfs probe below is Linux-only. Non-sysfs platforms (Windows in
    // particular) use `\\.\`-form device paths (e.g. `\\.\CdRom0`, `\\.\D:`)
    // that have no forward slash, so the Linux name-parsing below would treat
    // the whole path as the device name, find no `/sys` node, and fall through
    // to the block default (8192 sectors = 16 MiB) — far over the optical cap.
    // Every device we open on a non-sysfs platform here is an optical drive,
    // so return the optical default directly.
    if !sysfs_batch_probe_supported(device_path) {
        return DEFAULT_BATCH_SECTORS_OPTICAL;
    }

    let dev_name = device_path.rsplit('/').next().unwrap_or("");
    if dev_name.is_empty() {
        return DEFAULT_BATCH_SECTORS_OPTICAL;
    }

    // Check whether THIS device (not any device on the host) is an
    // optical drive: read the SCSI peripheral type of the target node
    // only. Type 0x05 (decimal 5) = CD/DVD. A previous version scanned
    // every /sys/class/scsi_device entry and returned true if any was
    // optical, misclassifying a block device as optical on a host that
    // also has an optical drive.
    let is_optical = {
        // For an sg node the type lives at scsi_generic/<sg>/device/type;
        // for a block node (sr0/sdX) at /sys/block/<name>/device/type.
        let type_path = if dev_name.starts_with("sg") {
            format!("/sys/class/scsi_generic/{dev_name}/device/type")
        } else {
            format!("/sys/block/{dev_name}/device/type")
        };
        std::fs::read_to_string(&type_path)
            .ok()
            .map(|c| c.trim().parse::<u32>() == Ok(5))
            .unwrap_or(false)
    };

    if is_optical {
        // For sg devices, find the corresponding block device name
        let block_name = if dev_name.starts_with("sg") {
            let block_dir = format!("/sys/class/scsi_generic/{dev_name}/device/block");
            std::fs::read_dir(&block_dir)
                .ok()
                .and_then(|mut entries| entries.next())
                .and_then(|e| e.ok())
                .map(|e| e.file_name().to_string_lossy().to_string())
        } else {
            Some(dev_name.to_string())
        };

        if let Some(bname) = block_name {
            let sysfs_path = format!("/sys/block/{bname}/queue/max_hw_sectors_kb");
            if let Ok(content) = std::fs::read_to_string(&sysfs_path) {
                if let Ok(kb) = content.trim().parse::<u32>() {
                    // Convert KB to sectors (1 sector = 2 KB = 2048 bytes)
                    let sectors = (kb / 2).min(u16::MAX as u32) as u16;
                    // Align down to 3 (one aligned unit)
                    let aligned = (sectors / 3) * 3;
                    if aligned >= MIN_BATCH_SECTORS {
                        return aligned.min(MAX_BATCH_SECTORS);
                    }
                }
            }
        }
        DEFAULT_BATCH_SECTORS_OPTICAL
    } else {
        DEFAULT_BATCH_SECTORS_BLOCK
    }
}

// ─── Format helpers ────────────────────────────────────────────────────────

// Old format_* functions replaced by Resolution/FrameRate/AudioChannels/SampleRate enums

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

    /// `AacsState` (public via `Disc.aacs`) and `Key` (the key-transport enum)
    /// must never print raw key bytes on `{:?}`. Sentinel 213 (0xD5); non-secret
    /// fields below are not 213.
    #[test]
    fn aacs_state_and_key_debug_are_redacted() {
        let st = AacsState {
            version: 2,
            bus_encryption: true,
            mkb_version: Some(77),
            disc_hash: "0xAA".into(),
            key_source: KeyOrigin::ExternalUk,
            vuk: Some([0xD5; 16]),
            unit_keys: vec![(1, [0xD5; 16])],
            read_data_key: Some([0xD5; 16]),
            volume_id: [0xD5; 16],
            uk_ro: vec![1, 2, 3],
            mkb: vec![4, 5, 6],
        };
        let d = format!("{st:?}");
        assert!(!d.contains("213"), "AacsState leaked key bytes: {d}");
        assert!(d.contains("redacted"), "AacsState missing marker: {d}");

        for k in [
            Key::Unit(vec![(1, [0xD5; 16])]),
            Key::Volume([0xD5; 16]),
            Key::Processing(vec![[0xD5; 16]]),
            Key::Media(vec![[0xD5; 16]]),
        ] {
            let d = format!("{k:?}");
            assert!(!d.contains("213"), "Key leaked bytes: {d}");
            assert!(d.contains("redacted"), "Key missing marker: {d}");
        }
    }

    // ── encrypted-content map (`merged_extents` core) ────────────────────────

    fn ext(start_lba: u32, sector_count: u32) -> Extent {
        Extent {
            start_lba,
            sector_count,
        }
    }

    #[test]
    fn merged_extents_empty_is_empty() {
        assert_eq!(merged_extents([].iter()), Vec::<(u32, u32)>::new());
    }

    #[test]
    fn merged_extents_single() {
        assert_eq!(merged_extents([ext(100, 50)].iter()), vec![(100, 50)]);
    }

    /// Out-of-order extents from several titles, with an OVERLAP, an ADJACENT
    /// pair, and a DISJOINT one, must come back sorted + merged + disjoint.
    #[test]
    fn merged_extents_unions_sorts_and_merges() {
        // [300,310) ; [100,150) ; [150,200) adjacent→merges with prev ;
        // [120,160) overlaps [100,150)&[150,200) ; [500,505) disjoint.
        let v = vec![
            ext(300, 10),
            ext(100, 50),
            ext(150, 50),
            ext(120, 40),
            ext(500, 5),
        ];
        assert_eq!(
            merged_extents(v.iter()),
            vec![(100, 100), (300, 10), (500, 5)],
            "[100,200) merged, [300,310), [500,505)"
        );
    }

    /// The same clip referenced by two titles (identical extents) de-duplicates
    /// to a single range — no double-counting of shared content.
    #[test]
    fn merged_extents_dedups_shared_clip() {
        let v = vec![ext(100, 50), ext(100, 50)];
        assert_eq!(merged_extents(v.iter()), vec![(100, 50)]);
    }

    // ── merge_content_key_ranges (whole-disc AACS map assembly) ────────────────
    use crate::decrypt::Phase;

    /// Ranges from different titles are sorted by start LBA and kept disjoint.
    #[test]
    fn merge_key_ranges_sorts_and_keeps_disjoint() {
        let v = vec![
            (500u32, 600u32, 1usize, Phase::All),
            (100, 200, 0, Phase::All),
            (300, 400, 2, Phase::All),
        ];
        assert_eq!(
            merge_content_key_ranges(v),
            vec![
                (100, 200, 0, Phase::All),
                (300, 400, 2, Phase::All),
                (500, 600, 1, Phase::All),
            ]
        );
    }

    /// A clip shared by two titles resolves the SAME span twice; the duplicate is
    /// dropped so `entry_for` sees a disjoint set (one key for the span).
    #[test]
    fn merge_key_ranges_dedups_shared_clip_span() {
        let v = vec![
            (100u32, 300u32, 0usize, Phase::All),
            (100, 300, 0, Phase::All),
        ];
        assert_eq!(merge_content_key_ranges(v), vec![(100, 300, 0, Phase::All)]);
    }

    /// A later range that partially overlaps a kept one carrying the SAME key is
    /// UNIONED, not dropped — the tail (400..500) must stay covered, or those
    /// encrypted LBAs would fall in no range and pass through as ciphertext.
    #[test]
    fn merge_key_ranges_unions_same_key_overlap() {
        let v = vec![
            (100u32, 400u32, 0usize, Phase::All),
            (200, 500, 0, Phase::All),
        ];
        assert_eq!(merge_content_key_ranges(v), vec![(100, 500, 0, Phase::All)]);
    }

    /// A different-key partial overlap (malformed disc) is dropped rather than
    /// unioned, so one unit key is never stretched over another key's LBAs; the set
    /// stays disjoint for `entry_for`.
    #[test]
    fn merge_key_ranges_drops_conflicting_key_overlap() {
        let v = vec![
            (100u32, 400u32, 0usize, Phase::All),
            (200, 500, 1, Phase::All),
        ];
        assert_eq!(merge_content_key_ranges(v), vec![(100, 400, 0, Phase::All)]);
    }

    /// Adjacent (touching) ranges are BOTH kept — `r.0 >= prev_end` holds when the
    /// next starts exactly at the previous end, so no coverage is lost.
    #[test]
    fn merge_key_ranges_keeps_adjacent() {
        let v = vec![
            (100u32, 200u32, 0usize, Phase::All),
            (200, 300, 1, Phase::All),
        ];
        assert_eq!(
            merge_content_key_ranges(v),
            vec![(100, 200, 0, Phase::All), (200, 300, 1, Phase::All)]
        );
    }

    /// A Windows-form optical device path (`\\.\CdRom0`, `\\.\D:`) must never
    /// fall through to the block default (8192 sectors = 16 MiB, well over the
    /// optical 510-sector cap). It has no forward slash, so the Linux-sysfs
    /// name parse cannot apply; the detector must return the optical default.
    #[test]
    fn windows_device_path_uses_optical_default() {
        for path in ["\\\\.\\CdRom0", "\\\\.\\CdRom15", "\\\\.\\D:", "\\\\.\\E:"] {
            let batch = detect_max_batch_sectors(path);
            assert_eq!(
                batch, DEFAULT_BATCH_SECTORS_OPTICAL,
                "windows path {path:?} must map to the optical default, got {batch}"
            );
            assert!(
                batch <= MAX_BATCH_SECTORS,
                "windows path {path:?} batch {batch} exceeds optical cap {MAX_BATCH_SECTORS}"
            );
        }
    }

    /// `read_aacs_inputs` on a missing/unreadable ISO must surface the real
    /// I/O fault (`E_IO_ERROR`, 5000) carrying the OS errno — NOT `AacsNoKeys`
    /// (7000). Collapsing ENOENT into a key error makes callers that dispatch
    /// on `.code()` tell the user "no keys / check your KEYDB" when the actual
    /// problem is that the ISO file does not exist.
    #[test]
    fn read_aacs_inputs_missing_iso_is_io_error_not_no_keys() {
        let missing = std::path::Path::new("/nonexistent/freemkv/does-not-exist.iso");
        let err = Disc::read_aacs_inputs(missing).expect_err("opening a nonexistent ISO must fail");
        assert_eq!(
            err.code(),
            crate::error::E_IO_ERROR,
            "missing ISO must map to E_IO_ERROR (5000), got {} ({err:?})",
            err.code()
        );
        assert_ne!(
            err.code(),
            crate::error::E_AACS_NO_KEYS,
            "missing ISO must not be reported as AacsNoKeys (7000)"
        );
    }

    /// The sysfs probe only applies on Linux and only to `/`-delimited node
    /// paths. A backslash-form path is never sysfs-probeable on any platform.
    #[test]
    fn windows_path_not_sysfs_probeable() {
        assert!(!sysfs_batch_probe_supported("\\\\.\\CdRom0"));
        assert!(!sysfs_batch_probe_supported("\\\\.\\D:"));
    }

    /// Helper: build a DiscTitle with a single video stream at the given resolution.
    fn title_with_video(codec: Codec, resolution: Resolution) -> DiscTitle {
        DiscTitle {
            playlist: "00800.mpls".into(),
            playlist_id: 800,
            duration_secs: 7200.0,
            size_bytes: 0,
            clips: Vec::new(),
            streams: vec![Stream::Video(VideoStream {
                pid: 0x1011,
                codec,
                resolution,
                frame_rate: FrameRate::F23_976,
                hdr: HdrFormat::Sdr,
                color_space: ColorSpace::Bt709,
                display_aspect: None,
                secondary: false,
                label: String::new(),
                measured_cicp: None,
            })],
            chapters: Vec::new(),
            extents: Vec::new(),
            content_format: ContentFormat::BdTs,
            codec_privates: Vec::new(),
        }
    }

    #[test]
    fn locate_ranges_at_risk_in_vs_out_of_feature() {
        // The honest-"Maybe" behaviour the rendered drilldown must preserve:
        // in-feature damage counts as movie time at risk; out-of-feature damage
        // is still located but reads 0:00. (Ported from autorip's old
        // RipProgress::from_map tests when that logic moved into the library.)
        // bps = size_bytes / duration_secs = 4096 B/s → 4096 B == 1000 ms.
        let mut title = title_with_video(Codec::Hevc, Resolution::R2160p);
        title.duration_secs = 100.0;
        title.size_bytes = 409_600;
        // Feature extent = sectors [10,110) → bytes [20480, 225280).
        title.extents = vec![Extent {
            start_lba: 10,
            sector_count: 100,
        }];

        // In-feature pending range: 4096 B == 1000 ms of movie at risk.
        let in_feat = locate_ranges(&[(40_960, 4096)], &title);
        assert_eq!(in_feat.num_ranges, 1);
        assert!(
            (in_feat.main_at_risk_ms - 1000.0).abs() < 1.0,
            "in-feature damage must count as at-risk movie time, got {}",
            in_feat.main_at_risk_ms
        );

        // Out-of-feature range: located, but zero movie time at risk.
        let out_feat = locate_ranges(&[(2_000_000, 100_000)], &title);
        assert_eq!(out_feat.num_ranges, 1, "still a located range");
        assert_eq!(
            out_feat.main_at_risk_ms, 0.0,
            "out-of-feature damage must not read as movie loss"
        );
    }

    /// Build a DiscTitle with full control over the fields the title
    /// sorter cares about. Used by the canonical-title-order tests.
    fn title_with(
        playlist: &str,
        duration_secs: f64,
        size_bytes: u64,
        n_clips: usize,
    ) -> DiscTitle {
        let mut t = title_with_video(Codec::Hevc, Resolution::R2160p);
        t.playlist = playlist.into();
        t.duration_secs = duration_secs;
        t.size_bytes = size_bytes;
        t.clips = (0..n_clips)
            .map(|i| Clip {
                clip_id: format!("{i:05}"),
                in_time: 0,
                out_time: 1,
                duration_secs: 1.0,
                source_packets: 0,
            })
            .collect();
        t
    }

    /// Regression for branching-UHD title ordering. Mirrors the live
    /// observed *The Amateur (2025)* layout: a 4h13m / 92.4 GB / 253-clip
    /// virtual play-all playlist alongside the real 2h02m / 57.2 GB /
    /// 1-clip main feature. Disc capacity 58.5 GB. After sorting,
    /// titles[0] must be the main feature, not the virtual composite.
    #[test]
    fn canonical_order_pushes_oversize_play_all_behind_real_main() {
        const CAPACITY: u64 = 58_500_000_000; // 58.5 GB
        let mut titles = vec![
            // Title 1 in the raw MPLS order — virtual play-all
            title_with(
                "00020.mpls",
                4.0 * 3600.0 + 13.0 * 60.0,
                92_400_000_000,
                253,
            ),
            // Title 2 — actual movie
            title_with("00800.mpls", 2.0 * 3600.0 + 2.0 * 60.0, 57_200_000_000, 1),
        ];
        titles.sort_by(|a, b| Disc::canonical_title_order(a, b, CAPACITY));
        assert_eq!(
            titles[0].playlist, "00800.mpls",
            "main feature should land at index 0"
        );
        assert_eq!(
            titles[1].playlist, "00020.mpls",
            "virtual play-all should be pushed back"
        );
    }

    /// Non-branching disc: largest title is the movie. With realistic sizes
    /// (bytes track duration for same-codec content) size-first yields the same
    /// ranking as duration — biggest/longest feature, then extra, then menu.
    #[test]
    fn canonical_order_preserves_natural_ranking_on_normal_disc() {
        const CAPACITY: u64 = 60_000_000_000;
        let mut titles = vec![
            title_with("00100.mpls", 600.0, 500_000_000, 1), // 10 min menu (small)
            title_with("00800.mpls", 7320.0, 55_000_000_000, 1), // 2h02m main feature
            title_with("00200.mpls", 1800.0, 2_000_000_000, 1), // 30 min extra
        ];
        titles.sort_by(|a, b| Disc::canonical_title_order(a, b, CAPACITY));
        assert_eq!(
            titles[0].playlist, "00800.mpls",
            "longest valid title still wins"
        );
        assert_eq!(titles[1].playlist, "00200.mpls");
        assert_eq!(titles[2].playlist, "00100.mpls");
    }

    /// Contract pin (owner-flagged): `freemkv -t 1` ALWAYS selects the main
    /// feature. The CLI's `-t 1` maps to `titles[0]`, and the title list is
    /// ordered by `canonical_title_order` (main feature first), so `titles[0]`
    /// IS the movie. Anything but the main feature at index 0 is a
    /// title-ordering bug, not a remux problem. DVD-shaped fixture (DVD-9
    /// capacity; a 1h49m main feature alongside a menu loop and a short extra).
    #[test]
    fn title_index_0_is_main_feature_dvd_the_dash_t_1_contract() {
        const DVD9: u64 = 7_900_000_000; // dual-layer DVD
        let mut titles = vec![
            title_with("VTS_01_menu", 120.0, 200_000_000, 1), // 2m menu/setup loop
            title_with("VTS_02_main", 6540.0, 6_300_000_000, 1), // 1h49m main feature
            title_with("VTS_03_extra", 900.0, 800_000_000, 1), // 15m extra
        ];
        titles.sort_by(|a, b| Disc::canonical_title_order(a, b, DVD9));
        assert_eq!(
            titles[0].playlist, "VTS_02_main",
            "titles[0] (== what `freemkv -t 1` selects) must be the DVD main feature"
        );
    }

    #[test]
    fn detect_format_uhd() {
        let titles = vec![title_with_video(Codec::Hevc, Resolution::R2160p)];
        assert_eq!(Disc::detect_format(&titles), DiscFormat::Uhd);
    }

    #[test]
    fn detect_format_bluray() {
        let titles = vec![title_with_video(Codec::H264, Resolution::R1080p)];
        assert_eq!(Disc::detect_format(&titles), DiscFormat::BluRay);
    }

    #[test]
    fn detect_format_dvd() {
        let titles = vec![title_with_video(Codec::Mpeg2, Resolution::R480i)];
        assert_eq!(Disc::detect_format(&titles), DiscFormat::Dvd);
    }

    #[test]
    fn detect_format_empty() {
        let titles: Vec<DiscTitle> = Vec::new();
        assert_eq!(Disc::detect_format(&titles), DiscFormat::Unknown);
    }

    /// An AACS MKB Type-and-Version record (0x10) carrying `raw_type` — the only
    /// record [`Disc::detect_disc_format`] reads to decide BD/UHD/FMTS.
    fn mkb_type_record(raw_type: u32) -> Vec<u8> {
        let mut v = vec![0x10, 0x00, 0x00, 0x0c]; // record type 0x10, rec_len 12
        v.extend_from_slice(&raw_type.to_be_bytes()); // MKBType @ body offset 0
        v.extend_from_slice(&0u32.to_be_bytes()); // version @ body offset 4
        v
    }

    /// FORMAT derives from the AACS MKB generation, not the tree or filesystem:
    /// 2.1 → FMTS, 2.0 → UHD, 1.0 → BD — all from the MKB Type record.
    #[test]
    fn detect_format_from_mkb_generation() {
        use crate::udf::fixture::*;
        for (raw, expected) in [
            (0x4815_1003u32, DiscFormat::Fmts),
            (0x4814_1003u32, DiscFormat::Uhd),
            (0x0004_1003u32, DiscFormat::BluRay),
        ] {
            let mut disc = MemDisc::new();
            let root = DirSpec {
                name: String::new(),
                icb_lba: 10,
                dir_data_lba: 11,
                files: Vec::new(),
                subdirs: vec![
                    DirSpec {
                        name: "BDMV".into(),
                        icb_lba: 12,
                        dir_data_lba: 13,
                        files: Vec::new(),
                        subdirs: vec![],
                    },
                    DirSpec {
                        name: "AACS".into(),
                        icb_lba: 14,
                        dir_data_lba: 15,
                        files: vec![file_with(
                            "MKB_RO.inf",
                            16,
                            5000,
                            mkb_type_record(raw),
                            true,
                        )],
                        subdirs: vec![],
                    },
                ],
            };
            build_udf_skeleton(&mut disc, 10);
            lay_dir(&mut disc, &root);
            let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
            assert_eq!(
                Disc::detect_disc_format(&mut disc, &udf, &[]),
                expected,
                "MKB type {raw:#010x}"
            );
        }
    }

    /// HD-DVD is a tree-level format — recognized from `HVDVD_TS/`, no MKB.
    #[test]
    fn detect_format_hddvd_from_tree() {
        use crate::udf::fixture::*;
        let mut disc = MemDisc::new();
        let root = DirSpec {
            name: String::new(),
            icb_lba: 10,
            dir_data_lba: 11,
            files: Vec::new(),
            subdirs: vec![DirSpec {
                name: "HVDVD_TS".into(),
                icb_lba: 20,
                dir_data_lba: 21,
                files: Vec::new(),
                subdirs: vec![],
            }],
        };
        build_udf_skeleton(&mut disc, 10);
        lay_dir(&mut disc, &root);
        let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
        assert_eq!(
            Disc::detect_disc_format(&mut disc, &udf, &[]),
            DiscFormat::HdDvd
        );
    }

    /// Title selection is by largest physical size, NOT clip count or duration.
    /// Real-disc shape (Fast Five): a 57 GB / 11-clip feature must outrank both a
    /// small 1-clip bonus reel and a long-but-tiny decoy "play-all" (91 reused
    /// clips, 1h31m, 0.4 GB). The old clip-count-ascending key put the bonus t1.
    #[test]
    fn canonical_title_order_picks_largest_feature() {
        fn title_sized(size_bytes: u64, duration_secs: f64, n_clips: usize) -> DiscTitle {
            DiscTitle {
                playlist: String::new(),
                playlist_id: 0,
                duration_secs,
                size_bytes,
                clips: (0..n_clips)
                    .map(|i| Clip {
                        clip_id: format!("{i:05}"),
                        in_time: 0,
                        out_time: 0,
                        duration_secs: 0.0,
                        source_packets: 0,
                    })
                    .collect(),
                streams: Vec::new(),
                chapters: Vec::new(),
                extents: Vec::new(),
                content_format: ContentFormat::BdTs,
                codec_privates: Vec::new(),
            }
        }
        let capacity = 66_000_000_000u64;
        let feature = title_sized(57_000_000_000, 7860.0, 11); // 2h11m, 11 chapters
        let bonus = title_sized(1_200_000_000, 600.0, 1); // 10m, 1 clip
        let decoy = title_sized(400_000_000, 5460.0, 91); // 1h31m but tiny (reused)
        let mut v = vec![bonus, decoy, feature];
        v.sort_by(|a, b| Disc::canonical_title_order(a, b, capacity));
        assert_eq!(
            v[0].size_bytes, 57_000_000_000,
            "the largest real title is the main feature"
        );
    }

    #[test]
    fn content_format_default_bdts() {
        let t = title_with_video(Codec::H264, Resolution::R1080p);
        assert_eq!(t.content_format, ContentFormat::BdTs);
    }

    #[test]
    fn content_format_dvd_mpegps() {
        let t = DiscTitle {
            content_format: ContentFormat::MpegPs,
            ..title_with_video(Codec::Mpeg2, Resolution::R480i)
        };
        assert_eq!(t.content_format, ContentFormat::MpegPs);
    }

    #[test]
    fn disc_capacity_gb() {
        // Single-layer BD-25: ~12,219,392 sectors
        let disc = Disc {
            volume_id: String::new(),
            meta_title: None,
            format: DiscFormat::BluRay,
            capacity_sectors: 12_219_392,
            capacity_bytes: 12_219_392u64 * 2048,
            layers: 1,
            titles: Vec::new(),
            region: DiscRegion::Free,
            aacs: None,
            css: None,
            encrypted: false,
            aacs_error: None,
            css_error: None,
            content_format: ContentFormat::BdTs,
        };
        let gb = disc.capacity_gb();
        // 12,219,392 * 2048 / 1073741824 = ~23.3 GB
        assert!((gb - 23.3).abs() < 0.1, "expected ~23.3 GB, got {}", gb);

        // Zero sectors
        let disc_zero = Disc {
            capacity_sectors: 0,
            capacity_bytes: 0,
            ..disc
        };
        assert_eq!(disc_zero.capacity_gb(), 0.0);
    }

    #[test]
    fn disc_title_duration_display_edge_cases() {
        let mut t = DiscTitle::empty();

        // 0 seconds
        t.duration_secs = 0.0;
        assert_eq!(t.duration_display(), "0h 00m");

        // 1 second
        t.duration_secs = 1.0;
        assert_eq!(t.duration_display(), "0h 00m");

        // 59 minutes
        t.duration_secs = 59.0 * 60.0;
        assert_eq!(t.duration_display(), "0h 59m");

        // 24 hours
        t.duration_secs = 24.0 * 3600.0;
        assert_eq!(t.duration_display(), "24h 00m");
    }

    fn make_test_disc(sectors: u32, name: &str) -> Disc {
        Disc {
            volume_id: name.into(),
            meta_title: Some(name.into()),
            format: DiscFormat::Uhd,
            capacity_sectors: sectors,
            capacity_bytes: sectors as u64 * 2048,
            layers: 1,
            titles: Vec::new(),
            region: DiscRegion::Free,
            aacs: None,
            css: None,
            encrypted: false,
            aacs_error: None,
            css_error: None,
            content_format: ContentFormat::BdTs,
        }
    }

    #[test]
    fn inject_unit_keys_synthesizes_aacs_state_when_scan_built_none() {
        // Regression (E8005 deferred-mux loop): a keyed AACS disc swept WITHOUT a
        // keydb scans to aacs=None + aacs_error=KeydbLoad, but its UK is persisted
        // in the mapfile. At remux the UK is recovered and injected — that MUST
        // yield usable decrypt keys. Before the fix, inject_unit_keys no-op'd
        // (no aacs to mutate), decrypt_keys stayed None, and the mux deferred
        // forever with "No keys available (E8005)" despite holding the UK.
        let mut disc = make_test_disc(1000, "UHD");
        disc.encrypted = true;
        disc.aacs_error = Some(crate::error::Error::KeydbLoad {
            path: "<no keydb in search paths>".into(),
        });
        assert!(
            matches!(disc.decrypt_keys(), crate::decrypt::DecryptKeys::None),
            "precondition: encrypted disc with no aacs state => no decrypt keys"
        );

        let uk = vec![(0u32, [0x11u8; 16])];
        disc.inject_unit_keys(uk.clone());

        match disc.decrypt_keys() {
            crate::decrypt::DecryptKeys::Aacs {
                unit_keys,
                read_data_key,
                ..
            } => {
                assert_eq!(unit_keys, uk, "injected UK must be the decrypt key");
                assert_eq!(read_data_key, None, "ISO mux needs no bus key");
            }
            _ => panic!("expected Aacs decrypt keys after injecting a UK"),
        }
        assert!(
            disc.aacs_error.is_none(),
            "stale KeydbLoad must be cleared once a UK is in hand"
        );
        assert_eq!(
            disc.aacs.as_ref().unwrap().key_source,
            KeyOrigin::ExternalUk
        );
    }

    #[test]
    fn inject_unit_keys_labels_fmts_as_uhd_family() {
        // FMTS is AACS 2.1 — a UHD-family, bus-encrypted format. Injecting a UK
        // on an FMTS disc must synthesize the UHD version + bus encryption, not
        // mislabel it AACS 1.0 / bus-off (which would break FMTS decryption on
        // the mapfile-recovered-UK path).
        let mut disc = make_test_disc(1000, "FMTS");
        disc.format = DiscFormat::Fmts;
        disc.encrypted = true;
        disc.inject_unit_keys(vec![(0u32, [0x22u8; 16])]);
        let aacs = disc.aacs.as_ref().expect("aacs state synthesized");
        assert_eq!(
            aacs.version,
            crate::aacs::mkb::AACS_MAJOR_UHD,
            "FMTS is AACS 2.x (UHD major), not BD"
        );
        assert!(aacs.bus_encryption, "FMTS is bus-encrypted like UHD");
    }

    /// Build an AacsState carrying the given unit keys (other fields are inert
    /// defaults — these tests only exercise the unit-key/decrypt-keys plumbing).
    fn aacs_with(unit_keys: Vec<(u32, [u8; 16])>) -> AacsState {
        AacsState {
            version: 2,
            bus_encryption: true,
            mkb_version: None,
            disc_hash: String::new(),
            key_source: KeyOrigin::DeviceKey,
            vuk: None,
            unit_keys,
            read_data_key: None,
            volume_id: [0u8; 16],
            uk_ro: Vec::new(),
            mkb: Vec::new(),
        }
    }

    // ── ensure_decryptable: the system-wide decrypt verdict matrix ──────────
    //
    // This is the single gate every copy/mux entry point calls. The cases below
    // are the full truth table: only "decryption needed AND unavailable AND not
    // --raw" may error; every legit non-error case (raw / unencrypted / a
    // resolved key) must proceed.

    fn css_state() -> crate::css::CssState {
        crate::css::CssState {
            title_key: [0u8; 5],
            crack_span: None,
        }
    }

    /// AACS-encrypted disc, decryption requested, no unit key resolved → the
    /// gate must fail with NoDiscKey (this is the headline bug: a pass-through
    /// `DecryptingSectorSource` would otherwise write ciphertext at exit 0).
    #[test]
    fn ensure_decryptable_aacs_no_key_errors() {
        let mut disc = make_test_disc(1000, "UHD");
        disc.encrypted = true;
        disc.aacs = Some(aacs_with(Vec::new())); // present but no unit keys → None
        assert!(matches!(
            disc.decrypt_keys(),
            crate::decrypt::DecryptKeys::None
        ));
        let err = disc
            .ensure_decryptable(false)
            .expect_err("AACS disc, no key, !raw must error");
        assert_eq!(
            err.code(),
            crate::error::Error::NoDiscKey {
                disc_hash: String::new()
            }
            .code()
        );
    }

    /// E7017 vs E7022 split (rc.6 WS1). When key resolution HAD derivation
    /// material (device / processing keys) but no Volume ID was available to
    /// derive the unit key, the captured `aacs_error` is `AacsVidUnavailable`
    /// — the gate must surface THAT (E7017), not the generic `NoDiscKey`
    /// (E7022). When there was no usable key material at all, the reason is
    /// absent and the gate keeps `NoDiscKey` (E7022). Both branches proven here.
    #[test]
    fn ensure_decryptable_aacs_vid_unavailable_vs_no_key() {
        // Branch 1 — derivation material present, but no VID: E7017.
        // The resolver classifies a device-keys-but-zero-VID context as
        // `VidUnavailable`; that reason rides on `aacs_error`.
        let supplied = crate::aacs::provider::SuppliedKey {
            device_keys: vec![crate::aacs::types::DeviceKey {
                key: [0x11; 16],
                node: 1,
                uv: 1,
                u_mask_shift: 0,
            }],
            processing_keys: Vec::new(),
            media_keys: Vec::new(),
            disc_entry: None,
        };
        let provider_refs: [&dyn crate::aacs::provider::KeyProvider; 1] = [&supplied];
        // A minimal but parseable Unit_Key_RO.inf (uk_pos=32, zero unit keys)
        // so resolution proceeds to the path-try logic and fails for lack of a
        // VID — not because the .inf failed to parse.
        let mut uk_ro = vec![0u8; 40];
        uk_ro[0..4].copy_from_slice(&32u32.to_be_bytes()); // uk_pos = 32
        // num_unit_keys = 0 (BE16) at uk_pos -> parses to an empty key file.
        let ctx = crate::aacs::resolve::ResolveContext {
            unit_key_ro: &uk_ro,
            content_cert: None,
            volume_id: &[0u8; 16], // the "no VID" sentinel
            providers: &provider_refs,
            mkb: None,
        };
        assert_eq!(
            crate::aacs::resolve::resolve_keys_with_reason(&ctx, 2).err(),
            Some(crate::aacs::resolve::ResolveFailure::VidUnavailable),
            "device keys + zero VID must classify as VidUnavailable"
        );

        let mut disc_e7017 = make_test_disc(1000, "UHD");
        disc_e7017.encrypted = true;
        disc_e7017.aacs = Some(aacs_with(Vec::new())); // present but no unit keys
        disc_e7017.aacs_error = Some(crate::error::Error::AacsVidUnavailable);
        let err = disc_e7017
            .ensure_decryptable(false)
            .expect_err("AACS disc, material-but-no-VID, !raw must error");
        assert_eq!(
            err.code(),
            crate::error::Error::AacsVidUnavailable.code(),
            "material-but-no-VID must surface E7017 (AacsVidUnavailable), not E7022"
        );

        // Branch 2 — no key material at all: classified NoMaterial, gate E7022.
        let supplied_none = crate::aacs::provider::SuppliedKey {
            device_keys: Vec::new(),
            processing_keys: Vec::new(),
            media_keys: Vec::new(),
            disc_entry: None,
        };
        let provider_refs_none: [&dyn crate::aacs::provider::KeyProvider; 1] = [&supplied_none];
        let ctx_none = crate::aacs::resolve::ResolveContext {
            unit_key_ro: &uk_ro,
            content_cert: None,
            volume_id: &[0u8; 16],
            providers: &provider_refs_none,
            mkb: None,
        };
        assert_eq!(
            crate::aacs::resolve::resolve_keys_with_reason(&ctx_none, 2).err(),
            Some(crate::aacs::resolve::ResolveFailure::NoMaterial),
            "no key material must classify as NoMaterial"
        );

        let mut disc_e7022 = make_test_disc(1000, "UHD");
        disc_e7022.encrypted = true;
        disc_e7022.aacs = Some(aacs_with(Vec::new()));
        disc_e7022.aacs_error = None; // no reason captured → generic no-key
        let err = disc_e7022
            .ensure_decryptable(false)
            .expect_err("AACS disc, no material, !raw must error");
        assert_eq!(
            err.code(),
            crate::error::Error::NoDiscKey {
                disc_hash: String::new()
            }
            .code(),
            "no-material must keep E7022 (NoDiscKey)"
        );
    }

    /// Same AACS-no-key disc under `--raw` (raw=true) must PROCEED — the user
    /// asked for the encrypted image and needs no key.
    #[test]
    fn ensure_decryptable_aacs_no_key_raw_proceeds() {
        let mut disc = make_test_disc(1000, "UHD");
        disc.encrypted = true;
        disc.aacs = Some(aacs_with(Vec::new()));
        assert!(disc.ensure_decryptable(true).is_ok(), "--raw must proceed");
    }

    /// AACS disc WITH a resolved unit key → proceed (decrypt_keys is Aacs).
    #[test]
    fn ensure_decryptable_aacs_with_key_proceeds() {
        let mut disc = make_test_disc(1000, "UHD");
        disc.encrypted = true;
        disc.aacs = Some(aacs_with(vec![(0, [0x11u8; 16])]));
        assert!(disc.ensure_decryptable(false).is_ok());
    }

    /// A genuinely unencrypted disc has `None` keys legitimately — the gate must
    /// NOT false-error. This is the "is the source encrypted?" guard: the answer
    /// is the scan-captured disc state, not the keys.
    #[test]
    fn ensure_decryptable_unencrypted_proceeds() {
        let disc = make_test_disc(1000, "BD"); // aacs/css/css_error all None
        assert!(matches!(
            disc.decrypt_keys(),
            crate::decrypt::DecryptKeys::None
        ));
        assert!(
            disc.ensure_decryptable(false).is_ok(),
            "unencrypted disc with None keys must proceed, not false-error"
        );
    }

    /// CSS scrambled-but-uncracked (the keyless crack failed): `css` is None but
    /// `css_error` is Some — the disc IS encrypted. The gate must fail with
    /// CssKeyMissing rather than read `css.is_none()` as "unencrypted".
    #[test]
    fn ensure_decryptable_css_error_errors() {
        let mut disc = make_test_disc(1000, "DVD");
        disc.encrypted = true;
        disc.css_error = Some(crate::error::Error::CssKeyMissing);
        let err = disc
            .ensure_decryptable(false)
            .expect_err("scrambled-but-uncracked CSS must error");
        assert_eq!(err.code(), crate::error::Error::CssKeyMissing.code());
        // --raw is exempt.
        assert!(disc.ensure_decryptable(true).is_ok());
    }

    /// CSS-keyless-crack SUCCESS: `css` is Some with a title key → proceed.
    #[test]
    fn ensure_decryptable_css_with_key_proceeds() {
        let mut disc = make_test_disc(1000, "DVD");
        disc.encrypted = true;
        disc.css = Some(css_state());
        assert!(disc.ensure_decryptable(false).is_ok());
    }

    /// Per-title gate: a multi-VTS CSS disc whose chosen title's VTS could not
    /// be re-cracked yields `DecryptKeys::None` even though the disc-wide
    /// `decrypt_keys()` is `Css{..}`. `ensure_decryptable_keys` judges the
    /// per-title key and must fail with CssKeyMissing.
    #[test]
    fn ensure_decryptable_keys_css_per_title_none_errors() {
        let mut disc = make_test_disc(1000, "DVD");
        disc.encrypted = true;
        disc.css = Some(css_state());
        let err = disc
            .ensure_decryptable_keys(false, &crate::decrypt::DecryptKeys::None)
            .expect_err("CSS disc, per-title key None, !raw must error");
        assert_eq!(err.code(), crate::error::Error::CssKeyMissing.code());
        // The same None key under --raw proceeds.
        assert!(
            disc.ensure_decryptable_keys(true, &crate::decrypt::DecryptKeys::None)
                .is_ok()
        );
    }

    /// `ensure_decryptable_keys` must never false-error an UNENCRYPTED disc no
    /// matter the key argument (the verdict keys off disc state, not keys).
    #[test]
    fn ensure_decryptable_keys_unencrypted_never_errors() {
        let disc = make_test_disc(1000, "BD");
        assert!(
            disc.ensure_decryptable_keys(false, &crate::decrypt::DecryptKeys::None)
                .is_ok()
        );
    }

    // ── Fix 2/3: a genuinely-clear extra title on a CSS disc never E7023s ──────

    /// Reader that serves clear (unscrambled) sectors for one extent range and
    /// CSS-locked errors elsewhere — enough to drive `decrypt_keys_for_title`'s
    /// per-title re-crack to `Unencrypted` for a clear stub.
    struct ClearStubReader {
        clear_range: (u32, u32),
    }
    impl crate::sector::SectorSource for ClearStubReader {
        fn read_sectors(
            &mut self,
            _lba: u32,
            count: u16,
            buf: &mut [u8],
            _recovery: bool,
        ) -> crate::error::Result<usize> {
            let n = count as usize * 2048;
            buf[..n].fill(0); // clear sectors: scramble flag never set
            let _ = self.clear_range;
            Ok(n)
        }
        fn capacity_sectors(&self) -> u32 {
            self.clear_range.1
        }
    }

    /// Build a multi-VTS CSS disc: `css` cracked from the main feature's span
    /// `[main_lba, main_end)`, plus a clear stub title living in a DISJOINT VTS.
    fn css_disc_with_clear_stub() -> (Disc, usize) {
        let mut disc = make_test_disc(100_000, "DVD");
        disc.format = DiscFormat::Dvd; // make_test_disc defaults to Uhd
        disc.content_format = ContentFormat::MpegPs;
        disc.encrypted = true;
        disc.css = Some(crate::css::CssState {
            title_key: [0u8; 5],
            crack_span: Some((0, 1000)), // main feature VTS span
        });
        // Title 0: the main feature, overlaps the cracked span.
        let mut feature = title_with_video(Codec::Mpeg2, Resolution::R480i);
        feature.extents = vec![Extent {
            start_lba: 0,
            sector_count: 1000,
        }];
        // Title 1: a tiny CLEAR stub in its own VTS, disjoint from the span.
        let mut stub = title_with_video(Codec::Mpeg2, Resolution::R480i);
        stub.extents = vec![Extent {
            start_lba: 50_000,
            sector_count: 7, // a 7-sector menu stub
        }];
        disc.titles = vec![feature, stub];
        (disc, 1) // stub is title index 1
    }

    /// A genuinely-clear extra title (an unencrypted menu stub in its own VTS)
    /// on a CSS DVD must mux without a false E7023. The stub lives in a DISJOINT
    /// VTS (its extents don't overlap the scan's `crack_span`), so
    /// `decrypt_keys_for_title` takes the crack path over the stub's own extents;
    /// the reader serves only clear sectors, so the crack returns `Unencrypted`
    /// → `(None, title_is_clear=true)`. The gate must then PASS the title with no
    /// key — no false E7023.
    #[test]
    fn clear_stub_title_on_css_disc_is_not_a_key_failure() {
        let (disc, stub_idx) = css_disc_with_clear_stub();
        assert_eq!(
            disc.format,
            DiscFormat::Dvd,
            "fixture must exercise the DVD path"
        );
        let mut reader = ClearStubReader {
            clear_range: (0, 100_000),
        };
        let (keys, title_is_clear) = disc.decrypt_keys_for_title(stub_idx, &mut reader, 8);
        assert!(
            matches!(keys, crate::decrypt::DecryptKeys::None),
            "a clear stub in a disjoint VTS cracks to no key"
        );
        assert!(title_is_clear, "the stub's own extents show no scrambling");
        // The gate must PASS a clear title — NO false E7023.
        assert!(
            disc.ensure_title_decryptable(false, &keys, title_is_clear)
                .is_ok(),
            "a genuinely clear extra title must never raise E7023"
        );
    }

    /// Counterpart guard: a scrambled-but-uncrackable title (`title_is_clear ==
    /// false`, `None` keys) on a CSS disc must STILL hard-fail with CssKeyMissing.
    /// Fix 2/3 must not weaken the genuine encrypted-but-uncrackable case.
    #[test]
    fn scrambled_uncracked_title_still_hard_fails() {
        let (disc, _) = css_disc_with_clear_stub();
        let err = disc
            .ensure_title_decryptable(false, &crate::decrypt::DecryptKeys::None, false)
            .expect_err("scrambled-uncracked title (title_is_clear=false) must error");
        assert_eq!(err.code(), crate::error::Error::CssKeyMissing.code());
        // --raw is exempt even for a scrambled-uncracked title.
        assert!(
            disc.ensure_title_decryptable(true, &crate::decrypt::DecryptKeys::None, false)
                .is_ok()
        );
    }

    #[test]
    fn decrypt_keys_none_when_aacs_present_but_unit_keys_empty() {
        // VID-only state (resolved but no Unit Key yet) must read as None, not
        // an empty-but-usable key set — callers treat it as "keys missing".
        let mut disc = make_test_disc(1000, "UHD");
        disc.encrypted = true;
        disc.aacs = Some(aacs_with(Vec::new()));
        assert!(matches!(
            disc.decrypt_keys(),
            crate::decrypt::DecryptKeys::None
        ));
    }

    #[test]
    fn decrypt_with_replaces_existing_aacs_unit_keys_and_marks_external() {
        // When scan DID build an AACS state, decrypt_with must overwrite its
        // unit keys (not append) and mark the source ExternalUk.
        let mut disc = make_test_disc(1000, "UHD");
        disc.encrypted = true;
        disc.aacs = Some(aacs_with(vec![(0, [0x01; 16])]));
        let new = vec![(0u32, [0x77u8; 16]), (1, [0x88; 16])];
        disc.decrypt_with(Key::Unit(new.clone()), &[]).unwrap();
        match disc.decrypt_keys() {
            crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } => {
                assert_eq!(unit_keys, new, "must replace, preserving every CPS unit");
            }
            _ => panic!("expected Aacs decrypt keys"),
        }
        assert_eq!(
            disc.aacs.as_ref().unwrap().key_source,
            KeyOrigin::ExternalUk
        );
    }

    /// Build a minimal valid `Unit_Key_RO.inf` carrying the given encrypted
    /// unit keys at the V20 (64-byte) stride. Header is inert (no titles); only
    /// the key-storage area matters for `parse_unit_key_ro`.
    fn uk_ro_v20(enc_keys: &[[u8; 16]]) -> Vec<u8> {
        let uk_pos = 32usize;
        let keys_start = uk_pos + 48;
        let stride = 64usize;
        let mut data = vec![0u8; keys_start + enc_keys.len().max(1) * stride];
        data[0..4].copy_from_slice(&(uk_pos as u32).to_be_bytes());
        data[uk_pos..uk_pos + 2].copy_from_slice(&(enc_keys.len() as u16).to_be_bytes());
        for (i, k) in enc_keys.iter().enumerate() {
            let off = keys_start + i * stride;
            data[off..off + 16].copy_from_slice(k);
        }
        data
    }

    #[test]
    fn decrypt_with_volume_derives_per_cps_unit_keys() {
        // A Volume key (VUK) is NOT terminal — the lib must decrypt
        // Unit_Key_RO.inf into ONE unit key per CPS unit. Oracle = the lib's
        // own decrypt_unit_key, so this pins the derive-down WIRING (Volume →
        // per-CPS Unit), not the cipher.
        let vuk = [0x5au8; 16];
        let enc0 = [0x12u8; 16];
        let enc1 = [0x34u8; 16];
        let exp0 = crate::aacs::derive::decrypt_unit_key(&vuk, &enc0);
        let exp1 = crate::aacs::derive::decrypt_unit_key(&vuk, &enc1);

        let mut disc = make_test_disc(1000, "UHD");
        disc.encrypted = true;
        let mut a = aacs_with(Vec::new());
        a.uk_ro = uk_ro_v20(&[enc0, enc1]);
        disc.aacs = Some(a);

        disc.decrypt_with(Key::Volume(vuk), &[]).unwrap();
        match disc.decrypt_keys() {
            crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } => {
                assert_eq!(
                    unit_keys,
                    vec![(1u32, exp0), (2u32, exp1)],
                    "VUK must decrypt EACH CPS unit's encrypted key (does not stop at VK)"
                );
            }
            _ => panic!("expected Aacs decrypt keys after Volume-key derive-down"),
        }
        assert_eq!(
            disc.aacs.as_ref().unwrap().key_source,
            KeyOrigin::ExternalUk
        );
    }

    #[test]
    fn decrypt_with_higher_key_without_inputs_errors() {
        // A non-Unit key needs the AACS inputs (Unit_Key_RO.inf) stashed at
        // scan. Without them the lib cannot derive — surfaces AacsNoKeys, not a
        // panic and not a silent keyless "success".
        let mut disc = make_test_disc(1000, "UHD");
        disc.encrypted = true;
        disc.aacs = Some(aacs_with(Vec::new())); // uk_ro empty
        assert!(matches!(
            disc.decrypt_with(Key::Volume([0x11u8; 16]), &[])
                .unwrap_err(),
            crate::error::Error::AacsNoKeys
        ));

        // No AACS state at all → same.
        let mut disc2 = make_test_disc(1000, "UHD");
        disc2.encrypted = true;
        assert!(matches!(
            disc2
                .decrypt_with(Key::Media(vec![[0x22u8; 16]]), &[])
                .unwrap_err(),
            crate::error::Error::AacsNoKeys
        ));
    }

    #[test]
    fn decrypt_with_volume_yielding_no_units_is_rejected() {
        // A key that produces zero unit keys (here: an empty key-storage area)
        // is a rejection, not a silent empty success.
        let mut disc = make_test_disc(1000, "UHD");
        disc.encrypted = true;
        let mut a = aacs_with(Vec::new());
        a.uk_ro = uk_ro_v20(&[]); // num_uk = 0
        disc.aacs = Some(a);
        assert!(matches!(
            disc.decrypt_with(Key::Volume([0x11u8; 16]), &[])
                .unwrap_err(),
            crate::error::Error::AacsKeyRejected
        ));
    }

    #[test]
    fn decrypt_with_unit_key_yields_decrypt_keys() {
        // The public lookup-free entry point: hand libfreemkv a Key::Unit and
        // decrypt_keys() must return usable AACS state (same path as the
        // deferred-mux resume — autorip resolves the UK and passes it in).
        let mut disc = make_test_disc(1000, "UHD");
        disc.encrypted = true;
        let uk = vec![(0u32, [0x44u8; 16])];
        disc.decrypt_with(Key::Unit(uk.clone()), &[]).unwrap();
        match disc.decrypt_keys() {
            crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } => {
                assert_eq!(unit_keys, uk);
            }
            _ => panic!("expected Aacs decrypt keys after decrypt_with(Key::Unit)"),
        }
    }

    #[test]
    fn unit_key_validation_gates_on_real_ciphertext() {
        use crate::aacs::content::ALIGNED_UNIT_LEN;

        // No samples -> nothing to disprove against -> accept (sample-less paths
        // like resume / mapfile must be unaffected).
        assert!(super::aligned_unit_keys_validate(
            &[(0, [0x11u8; 16])],
            None,
            &[],
            ContentFormat::BdTs
        ));

        // A clear unit (TS syncs intact) is not scrambled -> proves nothing ->
        // accept even with an arbitrary key.
        let mut clear = vec![0u8; ALIGNED_UNIT_LEN];
        let mut off = 4;
        while off < ALIGNED_UNIT_LEN {
            clear[off] = 0x47;
            off += 192;
        }
        assert!(crate::aacs::content::is_clean(
            &clear,
            crate::disc::ContentFormat::BdTs
        ));
        assert!(super::aligned_unit_keys_validate(
            &[(0, [0x11u8; 16])],
            None,
            &[clear.clone()],
            ContentFormat::BdTs
        ));

        // A genuinely scrambled unit the RIGHT key restores to clear TS.
        let uk = [0x5au8; 16];
        let enc = encrypt_unit_for_test(&clear, &uk);
        assert!(
            !crate::aacs::content::is_clean(&enc, crate::disc::ContentFormat::BdTs),
            "encrypted unit must read scrambled"
        );

        // Right key -> de-scrambles -> accept (NO false reject of a good key).
        assert!(super::aligned_unit_keys_validate(
            &[(7, uk)],
            None,
            &[enc.clone()],
            ContentFormat::BdTs
        ));
        // Wrong key -> cannot de-scramble a scrambled sample -> reject.
        assert!(!super::aligned_unit_keys_validate(
            &[(7, [0x00u8; 16])],
            None,
            &[enc.clone()],
            ContentFormat::BdTs
        ));
        // Empty key set against a scrambled sample -> reject.
        assert!(!super::aligned_unit_keys_validate(
            &[],
            None,
            &[enc],
            ContentFormat::BdTs
        ));
    }

    #[test]
    fn unit_key_validation_rejects_partial_cps_unit_coverage() {
        // Regression: a multi-CPS-unit disc. CPS unit 0's body is scrambled
        // under uk0; CPS unit 1's body under uk1. A resolved key set that
        // covers only CPS unit 0 used to pass validation (the old gate accepted
        // on the FIRST sample any key decrypted), committing an incomplete set —
        // CPS-unit-1 sectors then passed through as raw encrypted bytes into the
        // ISO/MKV with no error surfaced. The gate must now reject a key set
        // that leaves any scrambled sample uncovered.
        use crate::aacs::content::ALIGNED_UNIT_LEN;

        let mut clear = vec![0u8; ALIGNED_UNIT_LEN];
        let mut off = 4;
        while off < ALIGNED_UNIT_LEN {
            clear[off] = 0x47;
            off += 192;
        }

        let uk0 = [0x11u8; 16];
        let uk1 = [0x22u8; 16];
        let sample0 = encrypt_unit_for_test(&clear, &uk0); // CPS unit 0 body
        let sample1 = encrypt_unit_for_test(&clear, &uk1); // CPS unit 1 body
        assert!(!crate::aacs::content::is_clean(
            &sample0,
            crate::disc::ContentFormat::BdTs
        ));
        assert!(!crate::aacs::content::is_clean(
            &sample1,
            crate::disc::ContentFormat::BdTs
        ));

        let samples = vec![sample0.clone(), sample1.clone()];

        // Partial key set (CPS unit 0 only) against samples from BOTH units ->
        // reject. This is the bug fix: previously this returned true.
        assert!(!super::aligned_unit_keys_validate(
            &[(0, uk0)],
            None,
            &samples,
            ContentFormat::BdTs
        ));

        // Complete key set (both CPS units) -> accept.
        assert!(super::aligned_unit_keys_validate(
            &[(0, uk0), (1, uk1)],
            None,
            &samples,
            ContentFormat::BdTs
        ));

        // Order-independent: covering key present anywhere in the set is fine.
        assert!(super::aligned_unit_keys_validate(
            &[(1, uk1), (0, uk0)],
            None,
            &samples,
            ContentFormat::BdTs
        ));
    }

    /// Inverse of `decrypt_unit` for one 6144-byte unit: produce on-disc
    /// ciphertext that `decrypt_unit(uk)` restores to `clear`. Mirrors the AACS
    /// unit algorithm — ECB-derive the per-unit key, then AES-CBC encrypt the
    /// body with the fixed AACS IV.
    fn encrypt_unit_for_test(clear: &[u8], uk: &[u8; 16]) -> Vec<u8> {
        use crate::aacs::content::ALIGNED_UNIT_LEN;
        use crate::aacs::crypto::AACS_IV;
        use aes::Aes128;
        use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
        let mut unit = clear[..ALIGNED_UNIT_LEN].to_vec();
        // Flag the unit encrypted (CPI bits on byte 0) before key derivation so
        // the recovered plaintext header matches and `decrypt_unit`'s CPI gate
        // attempts the decrypt.
        unit[0] |= 0xC0;
        let mut header = [0u8; 16];
        header.copy_from_slice(&unit[..16]);
        let cipher = Aes128::new(GenericArray::from_slice(uk));
        let mut blk = GenericArray::clone_from_slice(&header);
        cipher.encrypt_block(&mut blk);
        let mut dk = [0u8; 16];
        for i in 0..16 {
            dk[i] = blk[i] ^ header[i];
        }
        let bc = Aes128::new(GenericArray::from_slice(&dk));
        let mut prev = AACS_IV;
        let mut i = 16;
        while i + 16 <= ALIGNED_UNIT_LEN {
            let mut b = [0u8; 16];
            for j in 0..16 {
                b[j] = unit[i + j] ^ prev[j];
            }
            let mut g = GenericArray::clone_from_slice(&b);
            bc.encrypt_block(&mut g);
            for j in 0..16 {
                unit[i + j] = g[j];
            }
            prev.copy_from_slice(&unit[i..i + 16]);
            i += 16;
        }
        unit
    }

    #[test]
    fn inject_unit_keys_is_noop_without_aacs_on_unencrypted_or_css() {
        // Unencrypted disc: nothing to inject into, stays None.
        let mut plain = make_test_disc(1000, "PLAIN");
        plain.inject_unit_keys(vec![(0, [0x22; 16])]);
        assert!(plain.aacs.is_none());
        assert!(matches!(
            plain.decrypt_keys(),
            crate::decrypt::DecryptKeys::None
        ));

        // Encrypted CSS (DVD): an AACS UK must NOT synthesize an AACS state.
        let mut dvd = make_test_disc(1000, "DVD");
        dvd.format = DiscFormat::Dvd;
        dvd.encrypted = true;
        dvd.css = Some(crate::css::CssState {
            title_key: [0u8; 5],
            crack_span: None,
        });
        dvd.inject_unit_keys(vec![(0, [0x33; 16])]);
        assert!(dvd.aacs.is_none(), "CSS disc must not gain an AACS state");
    }

    /// Records the LBAs read; returns all-zero (unscrambled) sectors so any
    /// re-crack attempt finds no key and falls back, while we observe WHETHER
    /// the title's extents were read at all.
    struct RecordingSource {
        reads: std::cell::RefCell<Vec<u32>>,
    }
    impl SectorSource for RecordingSource {
        fn read_sectors(
            &mut self,
            lba: u32,
            count: u16,
            buf: &mut [u8],
            _recovery: bool,
        ) -> Result<usize> {
            self.reads.borrow_mut().push(lba);
            let n = (count as usize * 2048).min(buf.len());
            for b in buf[..n].iter_mut() {
                *b = 0;
            }
            Ok(n)
        }
    }

    fn css_disc_with_two_vts() -> Disc {
        // Title 0 (cracked VTS) at LBA 100..200; title 1 (other VTS) at
        // 5000..5100. The cracked key's span is title 0's extents.
        let mut t0 = title_with_video(Codec::Mpeg2, Resolution::R480p);
        t0.extents = vec![Extent {
            start_lba: 100,
            sector_count: 100,
        }];
        let mut t1 = title_with_video(Codec::Mpeg2, Resolution::R480p);
        t1.playlist = "00801.mpls".into();
        t1.extents = vec![Extent {
            start_lba: 5000,
            sector_count: 100,
        }];
        let mut disc = make_test_disc(6000, "DVD");
        disc.format = DiscFormat::Dvd;
        disc.content_format = ContentFormat::MpegPs;
        disc.encrypted = true;
        disc.titles = vec![t0, t1];
        disc.css = Some(crate::css::CssState {
            title_key: [0xAB; 5],
            crack_span: Some((100, 200)),
        });
        disc
    }

    /// Build a Stevenson-crackable scrambled CSS sector (a periodic run in the
    /// clear header continuing past 0x80), mirroring the css-module fixture.
    fn crackable_css_sector(title_key: &[u8; 5]) -> [u8; 2048] {
        const RUN_START: usize = 0x59;
        const PERIOD: usize = 8;
        let mut sec = [0u8; 2048];
        sec[0x00..0x04].copy_from_slice(&crate::css::PACK_START);
        sec[0x14] = 0x10; // scramble flag
        for (i, b) in sec.iter_mut().enumerate().skip(RUN_START) {
            *b = (0xA0u8.wrapping_add((i % PERIOD) as u8)) ^ 0x5A;
        }
        crate::css::lfsr::scramble_sector(title_key, &mut sec);
        sec
    }

    /// A reader that serves crackable CSS sectors for LBAs in `scrambled`
    /// (half-open), all-zero (clear) elsewhere — records every LBA read.
    struct CssMapReader {
        key: [u8; 5],
        scrambled: (u32, u32),
        reads: std::cell::RefCell<Vec<u32>>,
    }
    impl SectorSource for CssMapReader {
        fn read_sectors(
            &mut self,
            lba: u32,
            count: u16,
            buf: &mut [u8],
            _recovery: bool,
        ) -> Result<usize> {
            self.reads.borrow_mut().push(lba);
            let n = (count as usize * 2048).min(buf.len());
            for s in 0..(n / 2048) {
                let this = lba + s as u32;
                let dst = &mut buf[s * 2048..(s + 1) * 2048];
                if this >= self.scrambled.0 && this < self.scrambled.1 {
                    dst.copy_from_slice(&crackable_css_sector(&self.key));
                } else {
                    dst.fill(0);
                }
            }
            Ok(n)
        }
    }

    fn css_dvd_with_extents(extents: Vec<Extent>) -> Disc {
        let mut disc = make_test_disc(200_000, "DVD");
        disc.format = DiscFormat::Dvd;
        disc.content_format = ContentFormat::MpegPs;
        disc.encrypted = true;
        let mut t = title_with_video(Codec::Mpeg2, Resolution::R480p);
        t.extents = extents;
        disc.titles = vec![t];
        disc
    }

    /// `decrypt_keys_for_title` cracks a scrambled DVD title's key from the
    /// title's OWN extents and hands the mux the validated key — the seed the
    /// descramble needs, since a crib-less sector cannot self-crack and CSS leaves
    /// the pack/PES header clear (an un-seeded mux would emit corrupt PES).
    #[test]
    fn decrypt_keys_for_title_cracks_the_titles_key() {
        let key = [0x11, 0x22, 0x33, 0x44, 0x55];
        let disc = css_dvd_with_extents(vec![Extent {
            start_lba: 100,
            sector_count: 64,
        }]);
        let mut src = CssMapReader {
            key,
            scrambled: (100, 164),
            reads: std::cell::RefCell::new(Vec::new()),
        };
        let (keys, title_is_clear) = disc.decrypt_keys_for_title(0, &mut src, 16);
        assert!(!title_is_clear, "a scrambled title is not clear");
        match keys {
            crate::decrypt::DecryptKeys::Css { title_key } => {
                assert_eq!(title_key, key, "must crack the title's own key")
            }
            _ => panic!("expected Css{{key}} for a scrambled DVD title"),
        }
    }

    /// REGRESSION (the 1.5.1 garbage bug): the crack scans extents in PLAYBACK
    /// ORDER, never largest-cell-first. A title whose LARGEST cell opens with a
    /// long unscrambled run must still crack its key from the smaller,
    /// scrambled-early cell that plays first — largest-first would exhaust the
    /// crack budget in the clear giant and wrongly report the title unencrypted,
    /// which the mux would pass through as scrambled garbage.
    #[test]
    fn decrypt_keys_for_title_scans_playback_order_not_largest_first() {
        let key = [0xDE, 0xAD, 0xBE, 0xEF, 0x01];
        let disc = css_dvd_with_extents(vec![
            // Plays FIRST: small, scrambled from its start.
            Extent {
                start_lba: 100,
                sector_count: 32,
            },
            // A CLEAR cell far larger than the crack budget (would starve a
            // largest-first scan before it reached the scrambled cell above).
            Extent {
                start_lba: 10_000,
                sector_count: 100_000,
            },
        ]);
        let mut src = CssMapReader {
            key,
            scrambled: (100, 132),
            reads: std::cell::RefCell::new(Vec::new()),
        };
        let (keys, _) = disc.decrypt_keys_for_title(0, &mut src, 16);
        match keys {
            crate::decrypt::DecryptKeys::Css { title_key } => assert_eq!(
                title_key, key,
                "must crack from the scrambled cell that plays first, not miss it behind the clear giant"
            ),
            _ => panic!("largest-first regression: the title was read as unencrypted"),
        }
        assert!(
            src.reads.borrow().iter().all(|&l| l < 10_000),
            "the key is found in the first (scrambled) cell — the clear giant must never be scanned: {:?}",
            src.reads.borrow()
        );
    }

    /// A reader whose every read is CSS-locked (`05/6F/03`) — a genuinely
    /// encrypted DVD whose sectors can't be authenticated/cracked.
    struct LockedReader;
    impl SectorSource for LockedReader {
        fn read_sectors(
            &mut self,
            lba: u32,
            _count: u16,
            _buf: &mut [u8],
            _recovery: bool,
        ) -> Result<usize> {
            Err(Error::DiscRead {
                sector: lba as u64,
                status: Some(2),
                sense: Some(crate::scsi::ScsiSense {
                    sense_key: 0x05,
                    asc: 0x6F,
                    ascq: 0x03,
                }),
            })
        }
    }

    /// End-to-end: a scrambled-but-uncrackable DVD title with NO up-front
    /// detection (`self.css == None`) drives `decrypt_keys_for_title` to
    /// `(None, false)`, and the gate MUST hard-fail (CssKeyMissing) rather than
    /// pass it to the muxer — the silent-garbage case the per-title crack catches.
    #[test]
    fn decrypt_keys_for_title_scrambled_uncracked_dvd_hard_fails_even_without_detection() {
        let disc = css_dvd_with_extents(vec![Extent {
            start_lba: 100,
            sector_count: 8,
        }]);
        assert!(disc.css.is_none(), "fixture: no up-front detection");
        let mut reader = LockedReader;
        let (keys, title_is_clear) = disc.decrypt_keys_for_title(0, &mut reader, 8);
        assert!(
            matches!(keys, crate::decrypt::DecryptKeys::None) && !title_is_clear,
            "a locked/uncrackable scrambled title resolves to (None, false)"
        );
        let err = disc
            .ensure_title_decryptable(false, &keys, title_is_clear)
            .expect_err("scrambled-uncracked DVD title must hard-fail without detection");
        assert_eq!(err.code(), crate::error::Error::CssKeyMissing.code());
    }

    /// Fast path: when the scan already cracked a key whose `crack_span` COVERS
    /// this title's VTS, `decrypt_keys_for_title` reuses it and never touches the
    /// reader (no redundant crack, no second bus-auth on a live drive).
    #[test]
    fn decrypt_keys_for_title_reuses_covered_scan_key_without_reading() {
        let disc = css_disc_with_two_vts(); // css=[0xAB;5], crack_span=(100,200)
        let mut src = RecordingSource {
            reads: std::cell::RefCell::new(Vec::new()),
        };
        // Title 0's extents (100..200) overlap the cracked span → reuse.
        let (keys, clear) = disc.decrypt_keys_for_title(0, &mut src, 16);
        assert!(!clear);
        match keys {
            crate::decrypt::DecryptKeys::Css { title_key } => {
                assert_eq!(title_key, [0xAB; 5], "reuse the scan's cracked key")
            }
            _ => panic!("expected the reused Css key"),
        }
        assert!(
            src.reads.borrow().is_empty(),
            "a covered title must NOT re-read/re-crack: {:?}",
            src.reads.borrow()
        );
    }

    /// A title in a DIFFERENT VTS (extents disjoint from `crack_span`) does NOT
    /// reuse the scan key — it cracks its own key from its own extents.
    #[test]
    fn decrypt_keys_for_title_cracks_other_vts_on_no_overlap() {
        let key = [0x77, 0x66, 0x55, 0x44, 0x33];
        let disc = css_disc_with_two_vts(); // title 1 lives at 5000.., span=(100,200)
        let mut src = CssMapReader {
            key,
            scrambled: (5000, 5100),
            reads: std::cell::RefCell::new(Vec::new()),
        };
        let (keys, _) = disc.decrypt_keys_for_title(1, &mut src, 16);
        match keys {
            crate::decrypt::DecryptKeys::Css { title_key } => assert_eq!(
                title_key, key,
                "a disjoint-VTS title cracks its OWN key, not the reused scan key"
            ),
            _ => panic!("expected a freshly-cracked Css key for the other VTS"),
        }
        assert!(
            src.reads.borrow().iter().all(|&l| l >= 5000),
            "must crack from title 1's own extents (>=5000): {:?}",
            src.reads.borrow()
        );
    }

    /// A title whose (realistic) clear front matter — studio logo / rating card —
    /// plays FIRST, then the scrambled feature, still cracks: the single
    /// playback-order scan reads through the small clear prefix and reaches the
    /// scrambled body within its budget. (A clear prefix LARGER than the ~100 MB
    /// crack budget would starve — the accepted bounded-budget limit, identical to
    /// the disc-wide scan; not producible by real DVD front matter.)
    #[test]
    fn decrypt_keys_for_title_cracks_feature_after_clear_front_matter() {
        let key = [0xCA, 0xFE, 0xBA, 0xBE, 0x02];
        // css=None so the crack path runs. ~10 MB of clear front matter plays
        // first (well under the crack budget), then the scrambled feature.
        let disc = css_dvd_with_extents(vec![
            Extent {
                start_lba: 10_000,
                sector_count: 5_000,
            }, // clear front matter (~10 MB), plays first
            Extent {
                start_lba: 100,
                sector_count: 2_000,
            }, // scrambled feature body
        ]);
        let mut src = CssMapReader {
            key,
            scrambled: (100, 2_100),
            reads: std::cell::RefCell::new(Vec::new()),
        };
        let (keys, _) = disc.decrypt_keys_for_title(0, &mut src, 16);
        match keys {
            crate::decrypt::DecryptKeys::Css { title_key } => assert_eq!(
                title_key, key,
                "must crack the scrambled feature after reading through clear front matter"
            ),
            _ => panic!("clear front matter wrongly starved the crack"),
        }
    }

    /// Scrambling that begins well INTO a cell (after a clear prefix), not at its
    /// start, must still be cracked: the single playback-order scan reads through
    /// the clear prefix and reaches the scrambled body within its budget — never a
    /// silent "clear" verdict that would mux the scrambled tail as corrupt PES.
    #[test]
    fn decrypt_keys_for_title_cracks_scrambling_after_a_clear_prefix_in_one_cell() {
        let key = [0x0D, 0xEE, 0x40, 0x00, 0x05];
        // One cell: clear for the first 9000 sectors, then scrambled (well within
        // the crack budget). css=None so the crack path runs.
        let disc = css_dvd_with_extents(vec![Extent {
            start_lba: 100,
            sector_count: 20_000,
        }]);
        let mut src = CssMapReader {
            key,
            scrambled: (100 + 9_000, 100 + 20_000),
            reads: std::cell::RefCell::new(Vec::new()),
        };
        let (keys, _) = disc.decrypt_keys_for_title(0, &mut src, 16);
        match keys {
            crate::decrypt::DecryptKeys::Css { title_key } => assert_eq!(
                title_key, key,
                "the scan must crack scrambling that starts past a clear prefix"
            ),
            _ => panic!("in-cell-deep scrambling was misread as clear (silent-garbage direction)"),
        }
    }

    /// A DVD title with EMPTY extents (an angle/PGC placeholder with no cells)
    /// resolves to `(decrypt_keys(), true)` — clear, no key needed — and the gate
    /// must PASS it. Returning `false` here would trip the DVD scrambled-uncracked
    /// rule and wrongly hard-fail a genuinely-clear empty title.
    #[test]
    fn decrypt_keys_for_title_empty_extents_is_clear_not_hard_fail() {
        let mut disc = css_dvd_with_extents(vec![Extent {
            start_lba: 100,
            sector_count: 8,
        }]);
        disc.titles
            .push(title_with_video(Codec::Mpeg2, Resolution::R480p)); // idx 1: no extents
        let mut reader = LockedReader;
        let (keys, title_is_clear) = disc.decrypt_keys_for_title(1, &mut reader, 8);
        assert!(
            title_is_clear,
            "an empty-extents title is clear (nothing to descramble)"
        );
        assert!(
            disc.ensure_title_decryptable(false, &keys, title_is_clear)
                .is_ok(),
            "an empty-extents DVD title must not hard-fail"
        );
    }

    /// A bonus title that cracked its OWN valid key must NOT be blocked by the
    /// disc-wide `css_error` set when the MAIN feature's scan failed. A usable
    /// per-title key means the title is decryptable regardless of another title's
    /// failure. (Regression for the audit r5 css_error-over-valid-key finding.)
    #[test]
    fn ensure_title_decryptable_valid_key_ignores_disc_wide_css_error() {
        let mut disc = css_dvd_with_extents(vec![Extent {
            start_lba: 100,
            sector_count: 8,
        }]);
        disc.css_error = Some(crate::error::Error::CssKeyMissing); // main feature failed
        let keys = crate::decrypt::DecryptKeys::Css {
            title_key: [0x42; 5], // this bonus title cracked its own key
        };
        assert!(
            disc.ensure_title_decryptable(false, &keys, false).is_ok(),
            "a title with its own valid CSS key must pass despite disc-wide css_error"
        );
    }

    /// bytes_bad_in_title must overlap per-extent, not against a single
    /// bounding box: a bad range in the gap between two extents of the
    /// same title must NOT be counted.
    #[test]
    fn bytes_bad_in_title_ignores_inter_extent_gap() {
        let mut title = title_with_video(Codec::Hevc, Resolution::R2160p);
        // Two extents: sectors [0,10) and [100,110). Gap = [10,100).
        title.extents = vec![
            Extent {
                start_lba: 0,
                sector_count: 10,
            },
            Extent {
                start_lba: 100,
                sector_count: 10,
            },
        ];
        // A bad range entirely inside the gap (sector 50 == byte 50*2048).
        let gap = vec![(50 * 2048, 2048)];
        assert_eq!(
            bytes_bad_in_title(&title, &gap),
            0,
            "bad bytes in the inter-extent gap must not be counted"
        );
        // A bad range overlapping the first extent counts.
        let in_first = vec![(0, 4096)];
        assert_eq!(bytes_bad_in_title(&title, &in_first), 4096);
        // A bad range spanning both extents plus the gap counts only the
        // bytes that fall inside the two extents (10 + 10 sectors).
        let spanning = vec![(0, 110 * 2048)];
        assert_eq!(bytes_bad_in_title(&title, &spanning), 20 * 2048);
    }

    /// 0xA2 is secondary DTS-HD MA (lossless), not lossy HR.
    #[test]
    fn coding_type_a2_is_dts_hd_ma() {
        assert_eq!(Codec::from_coding_type(0xA2), Codec::DtsHdMa);
        assert_eq!(Codec::from_coding_type(0x86), Codec::DtsHdMa);
    }

    /// HDMV coding_type 0x90 = Presentation Graphics (PG / subtitles) → Pgs,
    /// but 0x91 = Interactive Graphics (IG / menus) is NOT a subtitle stream.
    /// It must NOT map to Pgs (whose kind() is Subtitle), else a menu ES would
    /// surface as a bogus PGS subtitle track. 0x91 falls through to Unknown so
    /// the PMT/STN walker drops it.
    #[test]
    fn coding_type_ig_0x91_is_not_pgs_subtitle() {
        assert_eq!(Codec::from_coding_type(0x90), Codec::Pgs);
        assert_eq!(Codec::from_coding_type(0x90).kind(), CodecKind::Subtitle);
        // IG must not be a PGS subtitle.
        assert_eq!(Codec::from_coding_type(0x91), Codec::Unknown(0x91));
        assert_ne!(Codec::from_coding_type(0x91).kind(), CodecKind::Subtitle);
    }

    /// chapter_name emits a bare 1-based ordinal (no localized prose).
    #[test]
    fn chapter_name_is_bare_ordinal() {
        assert_eq!(chapter_name(0), "1");
        assert_eq!(chapter_name(41), "42");
    }
}
