//! 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;
mod encrypt;
pub mod mapfile;
mod patch;
pub mod read_error;
mod sweep;

use crate::drive::{Drive, extract_scsi_context};
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};

// ─── 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>,
    /// 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,
    /// DVD MPEG-2 Program Stream (VOB)
    MpegPs,
}

/// Disc format.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum DiscFormat {
    /// 4K UHD Blu-ray (HEVC 2160p)
    Uhd,
    /// Standard Blu-ray (1080p/1080i)
    BluRay,
    /// 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,
    /// Whether this is a secondary stream (PiP, Dolby Vision EL)
    pub secondary: bool,
    /// Extra label (e.g. "Dolby Vision EL")
    pub label: String,
}

/// 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,
    /// 96 kHz — high-res BD audio
    S96,
    /// 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,
    Unknown,
}

/// A chapter point within a title.
#[derive(Debug, Clone)]
pub struct Chapter {
    /// Chapter start time in seconds
    pub time_secs: f64,
    /// Chapter name (e.g. "Chapter 1", "Chapter 2")
    pub name: String,
}

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

/// Correct the channel count of a title's TrueHD audio streams by probing the
/// first decrypted access units. The MPLS `audio_format` field declares the
/// BASE layout (often 5.1) even for a 7.1/Atmos TrueHD track; the real count 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. Also regenerates the stream's codec
/// label when it was the basic descriptor for the (now corrected) count —
/// richer editorial labels (e.g. "Dolby Atmos") are left untouched.
pub(crate) fn correct_truehd_channels(reader: &mut dyn SectorSource, title: &mut DiscTitle) {
    use crate::mux::codec::truehd::truehd_channels_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];
    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;
        };
        let Some(count) = truehd_channels_from_stream(payload) else {
            continue;
        };
        let new_ch = AudioChannels::from_count(count);
        if new_ch == AudioChannels::Unknown || new_ch == a.channels {
            continue;
        }
        let was_basic =
            a.label == crate::labels::generate_audio_label(&a.codec, &a.channels, a.secondary);
        a.channels = new_ch;
        if was_basic {
            a.label = crate::labels::generate_audio_label(&a.codec, &new_ch, a.secondary);
        }
    }
}

/// 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;
    }
    let t_start = title.extents.first().map(|e| (e.start_lba as u64) * 2048);
    let t_end = title
        .extents
        .last()
        .map(|e| ((e.start_lba as u64) + (e.sector_count as u64)) * 2048);
    let (Some(ts), Some(te)) = (t_start, t_end) else {
        return 0;
    };
    bad_ranges
        .iter()
        .map(|(pos, size)| {
            let r_start = *pos;
            let r_end = *pos + *size;
            let overlap_start = r_start.max(ts);
            let overlap_end = r_end.min(te);
            overlap_end.saturating_sub(overlap_start)
        })
        .sum()
}

// ─── 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 {
        match ct {
            0x24 => Codec::Hevc,
            0x1B => Codec::H264,
            0xEA => Codec::Vc1,
            0x02 => Codec::Mpeg2,
            0x83 => Codec::TrueHd,
            0x86 => Codec::DtsHdMa,
            0x85 => Codec::DtsHdHr,
            0x82 => Codec::Dts,
            0x81 => Codec::Ac3,
            0x84 | 0xA1 => Codec::Ac3Plus,
            0x80 => Codec::Lpcm,
            0xA2 => Codec::DtsHdHr,
            0x90 | 0x91 => 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,
            _ => 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 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,
            _ => 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,
            _ if af > 0 => AudioChannels::Unknown,
            _ => 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,
            _ => 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::S96 => 96000.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,
            96000 => SampleRate::S96,
            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::Unknown => "",
        }
    }

    const ALL_CS: &[(&'static str, ColorSpace)] = &[
        ("bt709", ColorSpace::Bt709),
        ("bt2020", ColorSpace::Bt2020),
        ("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); }
                }
                f.write_str("")
            }
        }
        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),
        ("96kHz", SampleRate::S96),
        ("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);
            }
        }
        Ok(HdrFormat::Sdr)
    }
}

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()
    }
}

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

/// AACS decryption state for a disc.
#[derive(Debug)]
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>,
}

/// 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,
}

impl KeyOrigin {
    pub fn name(&self) -> &'static str {
        match self {
            KeyOrigin::DeviceKey => "MKB + device key",
            KeyOrigin::ProcessingKey => "MKB + processing key",
            KeyOrigin::KeyDbDerived => "KEYDB (derived)",
            KeyOrigin::KeyDb => "KEYDB",
            KeyOrigin::KeyDbUnitKeys => "KEYDB (unit keys)",
            KeyOrigin::ExternalUk => "external UK",
        }
    }
}

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

/// AACS host credentials for the live-drive authenticated handshake.
///
/// Optional and source-agnostic: an unlocked / LibreDrive drive uses the OEM
/// Volume-ID path and needs none, and an ISO scan has no handshake at all. 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::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 an
    /// unlocked / LibreDrive drive (OEM Volume-ID path) and for ISO scans.
    pub credentials: Option<DriveCredentials>,
}

/// 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);
        let format = if udf_fs.find_dir("/BDMV").is_some() {
            DiscFormat::BluRay // full scan distinguishes UHD vs BD
        } else if udf_fs.find_dir("/VIDEO_TS").is_some() {
            DiscFormat::Dvd
        } else {
            DiscFormat::Unknown
        };
        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(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 are populated with streams
    ///   - AACS keys are derived (if KEYDB available)
    ///   - content can be read and decrypted transparently
    ///
    /// Scan a disc. 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). Routes through Disc::read_vid,
        // which prefers the per-drive OEM CDB path when the drive is
        // in the extended-access state and falls back to cert-based
        // mutual auth otherwise.
        let (handshake, handshake_error) = Self::do_handshake(session, opts);

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

        // Read UDF filesystem with buffered sector reader
        let (capacity, mut buffered, udf_fs) = Self::read_udf(session)?;

        // 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);
        }

        let mut disc = Self::scan_with(
            &mut buffered,
            capacity,
            handshake,
            handshake_error,
            opts,
            udf_fs,
        )?;

        // CSS key extraction for DVDs (bus auth → disc key → title key).
        // Must be a single auth session — can't call authenticate() separately.
        // Route through the DRM dispatcher: probe a title sector, detect
        // CSS if scrambled, then load via the SCSI auth path.
        if disc.css.is_none()
            && disc.content_format == ContentFormat::MpegPs
            && !disc.titles.is_empty()
        {
            let mut probe_buf = vec![0u8; 2048];
            let auth_lba = disc.titles[0].extents.iter().find_map(|ext| {
                if session
                    .read_sectors(ext.start_lba, 1, &mut probe_buf, true)
                    .is_ok()
                {
                    let probe = crate::drm::DrmProbe {
                        dvd_sample_sector: Some(&probe_buf),
                        content_cert: None,
                        mkb: None,
                    };
                    if crate::drm::DrmScheme::detect(&probe) == Some(crate::drm::DrmScheme::Css) {
                        return Some(ext.start_lba);
                    }
                }
                None
            });

            if let Some(lba) = auth_lba {
                let css_ctx = crate::css::CssContext {
                    drive: Some(session),
                    auth_lba: Some(lba),
                    reader: None,
                    extents: None,
                };
                let mut ctx = crate::drm::DrmContext {
                    aacs: None,
                    css: Some(css_ctx),
                };
                if let Some(crate::drm::ResolvedScheme::Css(state)) =
                    crate::drm::DrmScheme::Css.load(&mut ctx)
                {
                    disc.css = Some(state);
                    disc.encrypted = true;
                }
            }
        }

        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)?;
        Self::scan_with(reader, capacity, None, None, opts, udf_fs)
    }

    /// Read a disc's AACS key-input files from an ISO image: returns
    /// `(Unit_Key_RO.inf, MKB)` raw bytes. For callers that resolve a Unit Key
    /// out-of-band: obtain the key however you like, then scan with
    /// `ScanOptions { unit_key: Some(uk), .. }`. libfreemkv never makes a
    /// network call.
    pub fn read_aacs_inputs(iso_path: &std::path::Path) -> Result<(Vec<u8>, Vec<u8>)> {
        let mut reader = crate::io::file_sector_source::FileSectorSource::open(iso_path)
            .map_err(|_| Error::AacsNoKeys)?;
        let udf_fs = udf::read_filesystem(&mut reader)?;
        let inf = udf_fs
            .read_file(&mut reader, "/AACS/Unit_Key_RO.inf")
            .or_else(|_| udf_fs.read_file(&mut reader, "/AACS/DUPLICATE/Unit_Key_RO.inf"))
            .map_err(|_| Error::AacsNoKeys)?;
        // Prefer MKB_RO, fall back to MKB_RW, then TRIM 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.
        let mut mkb = udf_fs
            .read_file(&mut reader, "/AACS/MKB_RO.inf")
            .or_else(|_| udf_fs.read_file(&mut reader, "/AACS/MKB_RW.inf"))
            .map_err(|_| Error::AacsNoKeys)?;
        let n = crate::aacs::mkb_content_len(&mkb);
        if n > 0 && n < mkb.len() {
            mkb.truncate(n);
        }
        Ok((inf, mkb))
    }

    /// 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 scans with
    /// `ScanOptions { unit_key: Some(uk), .. }`. 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>)> {
        let (_, mut reader, udf_fs) = Self::read_udf(drive)?;
        let inf = udf_fs
            .read_file(&mut reader, "/AACS/Unit_Key_RO.inf")
            .or_else(|_| udf_fs.read_file(&mut reader, "/AACS/DUPLICATE/Unit_Key_RO.inf"))
            .map_err(|_| Error::AacsNoKeys)?;
        // Prefer MKB_RO, fall back to MKB_RW, then TRIM 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.
        let mut mkb = udf_fs
            .read_file(&mut reader, "/AACS/MKB_RO.inf")
            .or_else(|_| udf_fs.read_file(&mut reader, "/AACS/MKB_RW.inf"))
            .map_err(|_| Error::AacsNoKeys)?;
        let n = crate::aacs::mkb_content_len(&mkb);
        if n > 0 && n < mkb.len() {
            mkb.truncate(n);
        }
        Ok((inf, mkb))
    }

    /// 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> {
        // 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 — BD (MPLS playlists) or DVD (IFO title sets)
        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("/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);
        crate::labels::fill_defaults(&mut titles);

        // 5. Derive format, layers, region
        let format = Self::detect_format(&titles);
        let layers = if capacity > 24_000_000 { 2 } else { 1 };
        let region = DiscRegion::Free;

        // 6. CSS detection for DVDs — route through the DRM dispatcher.
        //    Detection from a single probe sector would miss
        //    DVDs whose first sector is unscrambled, so we go straight
        //    to `DrmScheme::Css.load` with the crack-path context; the
        //    crack path scans extents internally and bottoms out at
        //    None on unencrypted media.
        let css = if content_format == ContentFormat::MpegPs && !titles.is_empty() {
            let css_ctx = crate::css::CssContext {
                drive: None,
                auth_lba: None,
                reader: Some(reader),
                extents: Some(&titles[0].extents),
            };
            let mut ctx = crate::drm::DrmContext {
                aacs: None,
                css: Some(css_ctx),
            };
            match crate::drm::DrmScheme::Css.load(&mut ctx) {
                Some(crate::drm::ResolvedScheme::Css(s)) => Some(s),
                _ => None,
            }
        } else {
            None
        };
        let encrypted = encrypted || css.is_some();

        Ok(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,
            content_format,
        })
    }

    // ── 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, fewer clips first. A 1-clip playlist is
    ///    the canonical main feature; multi-clip playlists are either
    ///    chapter-stitched (small count) or virtual composites
    ///    (large count). Fewer wins.
    /// 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 {
        let a_oversize = a.size_bytes > capacity_bytes;
        let b_oversize = b.size_bytes > capacity_bytes;
        a_oversize
            .cmp(&b_oversize)
            .then_with(|| a.clips.len().cmp(&b.clips.len()))
            .then_with(|| b.duration_secs.total_cmp(&a.duration_secs))
            // Same length + clip count = the same feature authored as multiple
            // 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 the richer audio so we never rip a stereo-only variant over
            // the lossless-multichannel main feature.
            .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)
    }

    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]]);
        Ok(lba + 1)
    }
}

/// 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(Debug, 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::keydb::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])>),
}

/// True if `unit_keys` can de-scramble at least one of the supplied content
/// `samples` — 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 only returns `false` when a scrambled sample exists and NO unit
/// key restores it to clear MPEG-TS — i.e. the key is provably wrong for this
/// disc. Reuses the ecosystem's single `is_aacs_scrambled` 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>],
) -> bool {
    use crate::aacs::decrypt::{ALIGNED_UNIT_LEN, decrypt_unit_full, is_aacs_scrambled};
    let scrambled: Vec<&[u8]> = samples
        .iter()
        .map(|s| s.as_slice())
        .filter(|s| s.len() >= ALIGNED_UNIT_LEN && is_aacs_scrambled(s))
        .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];
    for sample in scrambled {
        for (_, k) in unit_keys {
            probe.copy_from_slice(&sample[..ALIGNED_UNIT_LEN]);
            if decrypt_unit_full(&mut probe, k, read_data_key) {
                return true;
            }
        }
    }
    false
}

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,
            }
        } else if let Some(ref css) = self.css {
            crate::decrypt::DecryptKeys::Css {
                title_key: css.title_key,
            }
        } else {
            crate::decrypt::DecryptKeys::None
        }
    }

    /// 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 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() {
            self.aacs = Some(AacsState {
                version: if self.format == DiscFormat::Uhd { 2 } else { 1 },
                bus_encryption: self.format == DiscFormat::Uhd,
                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,
            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::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::KeyProvider; 1] = [&supplied];
            let ctx = crate::aacs::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.
            let resolved = match version_u8 {
                1 => crate::aacs::resolve_keys_v1(&ctx),
                _ => crate::aacs::resolve_keys_v2(&ctx)
                    .or_else(|| crate::aacs::resolve_keys_v21(&ctx)),
            }
            .ok_or(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) {
            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(())
    }

    /// Copy disc sectors to an ISO image file.
    ///
    /// NOT a stream operation. Copies sectors byte-for-byte producing a valid
    /// ISO/UDF image. Records progress in a ddrescue-format mapfile at
    /// `path + ".mapfile"` — flushed every block for crash-safe resume.
    ///
    /// Auto-detects the pass based on mapfile state:
    /// - **No mapfile** → Pass 1 (sweep): sequential read of the entire disc,
    ///   ECC-aligned batches, damage-jump on contiguous failures, marks bad
    ///   blocks as NonTrimmed. No drive-level recovery — fast.
    /// - **Mapfile with bad ranges** → Pass N (patch): re-reads only bad ranges
    ///   sector-by-sector with full drive-level recovery. Marks recovered
    ///   sectors as Finished, failed as Unreadable (terminal).
    /// - **Mapfile clean** → no-op: all sectors are Finished.
    ///
    /// Without `multipass`: aborts on the first read error (legacy single-pass).
    pub fn copy(
        &self,
        reader: &mut dyn SectorSource,
        path: &std::path::Path,
        opts: &CopyOptions,
    ) -> Result<CopyResult> {
        if opts.multipass {
            let mf_path = self.mapfile_for(path);
            if mf_path.exists() {
                let map =
                    mapfile::Mapfile::load(&mf_path).map_err(|e| Error::IoError { source: e })?;
                let stats = map.stats();
                let disc_size = self.capacity_bytes;
                let covers_disc = map.total_size() == disc_size;
                let bad_bytes = stats.bytes_pending + stats.bytes_unreadable;
                tracing::info!(
                    "copy dispatch: disc={} map={} covers={} good={} nontried={} pending={} unreadable={}",
                    disc_size,
                    map.total_size(),
                    covers_disc,
                    stats.bytes_good,
                    stats.bytes_nontried,
                    stats.bytes_pending,
                    stats.bytes_unreadable,
                );
                if covers_disc && bad_bytes == 0 {
                    return Ok(CopyResult {
                        bytes_total: disc_size,
                        bytes_good: stats.bytes_good,
                        bytes_unreadable: stats.bytes_unreadable,
                        bytes_pending: 0,
                        recovered_this_pass: 0,
                        complete: true,
                        halted: false,
                    });
                }
                if !covers_disc {
                    tracing::info!("copy dispatch: → sweep (covers_disc={})", covers_disc,);
                    return self.sweep_internal(reader, path, opts, true);
                }
                if stats.bytes_retryable > 0 {
                    tracing::info!(
                        "copy dispatch: → patch (retryable={})",
                        stats.bytes_retryable,
                    );
                    return self.patch_internal(reader, path, opts);
                }
                tracing::info!("copy dispatch: → sweep (resume)");
                return self.sweep_internal(reader, path, opts, true);
            }
        }
        self.sweep_internal(reader, path, opts, false)
    }

    fn sweep_internal(
        &self,
        reader: &mut dyn SectorSource,
        path: &std::path::Path,
        opts: &CopyOptions,
        resume: bool,
    ) -> Result<CopyResult> {
        let sweep_opts = SweepOptions {
            decrypt: opts.decrypt,
            resume,
            batch_sectors: None,
            skip_on_error: opts.multipass,
            progress: opts.progress,
            halt: opts.halt.clone(),
            vid: opts.vid,
            unit_keys: opts.unit_keys.clone(),
        };
        self.sweep(reader, path, &sweep_opts)
    }

    fn patch_internal(
        &self,
        reader: &mut dyn SectorSource,
        path: &std::path::Path,
        opts: &CopyOptions,
    ) -> Result<CopyResult> {
        let patch_opts = PatchOptions {
            decrypt: opts.decrypt,
            // 0.18.13: adaptive batching. patch() reads at 32 sectors
            // when the drive is healthy, drops to 1 on failure to
            // probe each sector individually, then climbs back after
            // 16 consecutive clean singles. Walks NonTrimmed regions
            // ~32x faster in clean stretches without sacrificing any
            // per-sector recovery quality — the drop-to-1 retry from
            // the same position guarantees every sector in a failed
            // batch is individually probed. See Disc::patch body.
            block_sectors: Some(32),
            full_recovery: true,
            reverse: true,
            wedged_threshold: 50,
            progress: opts.progress,
            halt: opts.halt.clone(),
        };
        let pr = self.patch(reader, path, &patch_opts)?;
        tracing::info!(
            target: "freemkv::disc",
            phase = "patch_done",
            blocks_attempted = pr.blocks_attempted,
            blocks_read_ok = pr.blocks_read_ok,
            blocks_read_failed = pr.blocks_read_failed,
            bytes_recovered = pr.bytes_recovered_this_pass,
            halted = pr.halted,
            wedged_exit = pr.wedged_exit,
            "Patch completed"
        );
        Ok(CopyResult {
            bytes_total: pr.bytes_total,
            bytes_good: pr.bytes_good,
            bytes_unreadable: pr.bytes_unreadable,
            bytes_pending: pr.bytes_pending,
            recovered_this_pass: pr.bytes_recovered_this_pass,
            complete: pr.bytes_pending == 0,
            halted: pr.halted,
        })
    }

    /// Pass 1 of a multipass rip: walk the disc forward, write
    /// every readable sector into `path`, and record the result
    /// in the sidecar mapfile. With `skip_on_error: true`, a bad
    /// sector zero-fills + marks `NonTrimmed` and the sweep keeps
    /// going (jumping ahead through dense damage); without it,
    /// the first read failure aborts.
    ///
    /// 0.18: this is one of the two flat verbs the library exposes
    /// for rip orchestration. Multipass + retry decisions are the
    /// caller's job — see [`PatchOptions`] for the retry primitive.
    pub fn sweep(
        &self,
        reader: &mut dyn SectorSource,
        path: &std::path::Path,
        opts: &SweepOptions,
    ) -> Result<CopyResult> {
        use crate::io::{DEFAULT_PIPELINE_DEPTH, Pipeline};
        use crate::sector::{DecryptingSectorSource, SectorSource};
        use sweep::{ProgressSnapshot, SweepSink, WorkItem, try_recv_progress};

        let total_bytes = self.capacity_sectors as u64 * 2048;
        let keys = if opts.decrypt {
            self.decrypt_keys()
        } else {
            crate::decrypt::DecryptKeys::None
        };

        // Wrap the producer-side reader once so every read_sectors call
        // yields plaintext. `DecryptKeys::None` makes the decorator a
        // pass-through, so the wrapping is cheap when --raw / unencrypted
        // discs are being swept and we keep the pipeline shape uniform.
        // Replaces the inline `decrypt::decrypt_sectors` calls that used
        // to live in this loop and in the bisect inner loop below.
        let mut reader = DecryptingSectorSource::new(reader, keys);
        let reader = &mut reader;

        // Mapfile: load if resuming, else wipe + recreate.
        let mapfile_path = self.mapfile_for(path);
        if !opts.resume {
            let _ = std::fs::remove_file(&mapfile_path);
        }
        let mut map = mapfile::Mapfile::open_or_create(
            &mapfile_path,
            total_bytes,
            concat!("libfreemkv v", env!("CARGO_PKG_VERSION")),
        )
        .map_err(|e| Error::IoError { source: e })?;

        // Persist the disc's decryption state into the mapfile header so it
        // survives to deferred-mux / resume. ddrescue-safe (comment lines);
        // does not touch the ISO payload. KEYS XOR VID: a keyed disc writes its
        // unit keys (the final answer — deferred-mux decrypts directly, no key
        // service); an unresolved disc writes only the VID (the retry marker).
        if !opts.unit_keys.is_empty() {
            map.set_unit_keys(&opts.unit_keys);
        } else if let Some(vid) = opts.vid {
            map.set_vid(vid);
        }

        // ISO file: if resuming and mapfile has Finished ranges, open existing;
        // otherwise create fresh and pre-size to total_bytes (sparse holes for
        // non-tried regions).
        let is_regular = std::fs::metadata(path)
            .map(|m| m.file_type().is_file())
            .unwrap_or(false);
        let file = if opts.resume
            && std::fs::metadata(path)
                .map(|m| m.len() > 0)
                .unwrap_or(false)
        {
            std::fs::OpenOptions::new()
                .write(true)
                .open(path)
                .map_err(|e| Error::IoError { source: e })?
        } else {
            let f = std::fs::File::create(path).map_err(|e| Error::IoError { source: e })?;
            if is_regular {
                f.set_len(total_bytes)
                    .map_err(|e| Error::IoError { source: e })?;
            }
            f
        };

        // Wrap the raw `File` in our bounded-cache `WritebackFile`
        // (drains dirty pages continuously instead of bursting; see
        // `crate::io`). The `WritebackFile` moves into the consumer
        // thread.
        let file = crate::io::WritebackFile::new(file).map_err(|e| Error::IoError { source: e })?;
        let batch: u16 = match opts.batch_sectors {
            Some(b) => b,
            None if opts.skip_on_error => ecc_sectors(self.format),
            None => DEFAULT_BATCH_SECTORS_OPTICAL,
        };

        // Pre-compute the list of NonTried regions before handing the
        // mapfile to the consumer thread. Each region is processed by
        // the producer in order; the consumer mutates the mapfile per
        // work-item. Any regions left as NonTrimmed/Unreadable after
        // sweep finishes are the patch pass's job.
        let regions: Vec<(u64, u64)> = map.ranges_with(&[mapfile::SectorStatus::NonTried]);

        // Spawn the consumer. It owns WritebackFile + Mapfile; the producer
        // (this thread) keeps `reader`, `read_ctx`, halt + set_speed.
        // The thread name is preserved from the 0.17.x sweep_pipeline so it
        // stays identifiable in stack traces / `top -H`.
        let (sink, prog_rx) = SweepSink::new(file, map, is_regular);
        let pipe: Pipeline<WorkItem, sweep::ConsumerSummary> =
            Pipeline::spawn_named("freemkv-sweep-consumer", DEFAULT_PIPELINE_DEPTH, sink)?;

        // Translate `Pipeline::send` failure (consumer gone) into the
        // same `Error` shape the 0.17.x `send_or_abort` produced, so
        // the producer-error semantics are unchanged.
        fn consumer_gone() -> Error {
            Error::IoError {
                source: std::io::Error::other("sweep consumer terminated unexpectedly"),
            }
        }

        let mut buf = vec![0u8; batch as usize * 2048];
        let mut bytes_done = 0u64;
        let mut halt_requested = false;
        let copy_t0 = std::time::Instant::now();
        let mut iter_count: u64 = 0;
        let mut read_ok_count: u64 = 0;
        let mut read_err_count: u64 = 0;
        let mut last_log_iter: u64 = 0;
        let mut read_ctx = read_error::ReadCtx::for_sweep(batch);
        let mut in_damage_zone = false;
        const DAMAGE_ZONE_EXIT_THRESHOLD: u64 = 16;
        let mut cached_snapshot: Option<ProgressSnapshot> = None;
        let mut producer_err: Option<Error> = None;

        tracing::trace!(
            target: "freemkv::disc",
            phase = "copy_start",
            total_bytes,
            batch,
            skip_on_error = opts.skip_on_error,
            regions = regions.len(),
            "Disc::sweep entered (producer/consumer)"
        );

        'outer: for (region_pos, region_size) in regions {
            let region_end = region_pos + region_size;
            let mut pos = region_pos;
            tracing::trace!(
                target: "freemkv::disc",
                phase = "region_enter",
                region_pos,
                region_size,
                region_end,
                "entering NonTried region"
            );

            while pos < region_end {
                if let Some(ref h) = opts.halt {
                    if h.load(std::sync::atomic::Ordering::Relaxed) {
                        halt_requested = true;
                        break 'outer;
                    }
                }

                let block_bytes = (region_end - pos).min(batch as u64 * 2048);
                let block_lba = (pos / 2048) as u32;
                let block_count = (block_bytes / 2048) as u16;
                let recovery = !opts.skip_on_error;

                let read_result = reader.read_sectors(
                    block_lba,
                    block_count,
                    &mut buf[..block_bytes as usize],
                    recovery,
                );

                match read_result {
                    Ok(_) => {
                        read_ok_count += 1;
                        read_ctx.on_success();

                        if read_ctx.consecutive_good >= DAMAGE_ZONE_EXIT_THRESHOLD {
                            read_ctx.jump_multiplier = 1;
                            if in_damage_zone {
                                in_damage_zone = false;
                                reader.set_speed(0xFFFF);
                                tracing::debug!(
                                    target: "freemkv::disc",
                                    phase = "damage_exit",
                                    lba = block_lba,
                                    "Exited damage zone; restoring max read speed"
                                );
                            }
                        }
                        read_ctx.bridge_degradation_count = 0;

                        // Plaintext: the wrapped reader (DecryptingSectorSource)
                        // applied AACS / CSS in-place during read_sectors above.
                        // The consumer thread sees decrypted bytes; the
                        // pre-0.18 inline decrypt_sectors call lived here.

                        // Move the batch into the channel via fresh
                        // owned Vec. The producer's `buf` is reused
                        // for the next read.
                        let send_buf = buf[..block_bytes as usize].to_vec();
                        if pipe.send(WorkItem::Good { pos, buf: send_buf }).is_err() {
                            producer_err = Some(consumer_gone());
                            break 'outer;
                        }
                        bytes_done = bytes_done.saturating_add(block_bytes);
                        pos += block_bytes;
                    }
                    Err(err) if !opts.skip_on_error => {
                        let (status, sense) = extract_scsi_context(&err);
                        producer_err = Some(Error::DiscRead {
                            sector: block_lba as u64,
                            status: Some(status),
                            sense,
                        });
                        break 'outer;
                    }
                    Err(err) => {
                        read_err_count += 1;
                        let action = read_error::handle_read_error(&err, &mut read_ctx);

                        match action {
                            read_error::ReadAction::Retry { pause_secs } => {
                                sleep_secs_or_halt(pause_secs, opts.halt.as_ref());
                            }
                            read_error::ReadAction::Bisect => {
                                read_ctx.bisecting = true;
                                let saved_batch = read_ctx.batch;
                                read_ctx.batch = 1;
                                let mut bisect_aborted = false;
                                for sector_offset in 0..block_count {
                                    if let Some(ref h) = opts.halt {
                                        if h.load(std::sync::atomic::Ordering::Relaxed) {
                                            halt_requested = true;
                                            bisect_aborted = true;
                                            break;
                                        }
                                    }
                                    let sector_lba = block_lba + (sector_offset as u32);
                                    let mut sector_buf = [0u8; 2048];
                                    let write_pos = pos + (sector_offset as u64 * 2048);
                                    match reader.read_sectors(
                                        sector_lba,
                                        1,
                                        &mut sector_buf[..],
                                        true,
                                    ) {
                                        Ok(_) => {
                                            read_ctx.on_success();
                                            // Plaintext via the wrapping
                                            // DecryptingSectorSource — same
                                            // decrypt path the batch read takes.
                                            if pipe
                                                .send(WorkItem::BisectGood {
                                                    pos: write_pos,
                                                    buf: Box::new(sector_buf),
                                                })
                                                .is_err()
                                            {
                                                producer_err = Some(consumer_gone());
                                                bisect_aborted = true;
                                                break;
                                            }
                                        }
                                        Err(inner_err) => {
                                            let _ = read_error::handle_read_error(
                                                &inner_err,
                                                &mut read_ctx,
                                            );
                                            if pipe
                                                .send(WorkItem::BisectBad { pos: write_pos })
                                                .is_err()
                                            {
                                                producer_err = Some(consumer_gone());
                                                bisect_aborted = true;
                                                break;
                                            }
                                        }
                                    }
                                }
                                read_ctx.bisecting = false;
                                read_ctx.batch = saved_batch;
                                if bisect_aborted {
                                    break 'outer;
                                }
                                bytes_done = bytes_done.saturating_add(block_bytes);
                                pos += block_bytes;
                            }
                            read_error::ReadAction::SkipBlock { pause_secs } => {
                                if pipe
                                    .send(WorkItem::SkipFill {
                                        pos,
                                        len: block_bytes,
                                    })
                                    .is_err()
                                {
                                    producer_err = Some(consumer_gone());
                                    break 'outer;
                                }
                                bytes_done = bytes_done.saturating_add(block_bytes);
                                sleep_secs_or_halt(pause_secs, opts.halt.as_ref());
                                pos += block_bytes;
                            }
                            read_error::ReadAction::JumpAhead {
                                sectors,
                                pause_secs,
                            } => {
                                if pipe
                                    .send(WorkItem::SkipFill {
                                        pos,
                                        len: block_bytes,
                                    })
                                    .is_err()
                                {
                                    producer_err = Some(consumer_gone());
                                    break 'outer;
                                }
                                bytes_done = bytes_done.saturating_add(block_bytes);

                                if !in_damage_zone {
                                    in_damage_zone = true;
                                    reader.set_speed(0x0000);
                                    tracing::debug!(
                                        target: "freemkv::disc",
                                        phase = "damage_enter",
                                        lba = block_lba,
                                        "Entered damage zone; dropping to minimum read speed"
                                    );
                                }

                                let jump_pos = (pos + block_bytes + sectors * 2048).min(region_end);
                                let gap_start = pos + block_bytes;
                                let gap_bytes = jump_pos.saturating_sub(gap_start);
                                if gap_bytes > 0 {
                                    if pipe
                                        .send(WorkItem::GapFill {
                                            pos: gap_start,
                                            len: gap_bytes,
                                        })
                                        .is_err()
                                    {
                                        producer_err = Some(consumer_gone());
                                        break 'outer;
                                    }
                                    bytes_done = bytes_done.saturating_add(gap_bytes);
                                }
                                tracing::warn!(
                                    target: "freemkv::disc",
                                    phase = "damage_jump",
                                    from_lba = block_lba,
                                    to_lba = (jump_pos / 2048) as u32,
                                    jump_mb = gap_bytes / 1_048_576,
                                    "damage-jump"
                                );
                                pos = jump_pos;
                                sleep_secs_or_halt(pause_secs, opts.halt.as_ref());
                            }
                            read_error::ReadAction::AbortPass => {
                                let (status, sense) = extract_scsi_context(&err);
                                producer_err = Some(Error::DiscRead {
                                    sector: block_lba as u64,
                                    status: Some(status),
                                    sense,
                                });
                                break 'outer;
                            }
                        }
                    }
                }

                iter_count += 1;

                // Drain any consumer-side stats snapshot.
                if let Some(snap) = try_recv_progress(&prog_rx) {
                    cached_snapshot = Some(snap);
                }

                if iter_count - last_log_iter >= 100 {
                    last_log_iter = iter_count;
                    if let Some(ref snap) = cached_snapshot {
                        tracing::trace!(
                            target: "freemkv::disc",
                            phase = "iter_progress",
                            iter_count,
                            read_ok_count,
                            read_err_count,
                            pos,
                            region_end,
                            bytes_good = snap.stats.bytes_good,
                            bytes_pending = snap.stats.bytes_pending,
                            copy_elapsed_ms = copy_t0.elapsed().as_millis() as u64,
                            "Disc::sweep inner iter"
                        );
                    }
                    // Throttled stats refresh request — best-effort
                    // try_send so a busy consumer doesn't stall the
                    // producer; the cached snapshot stays current
                    // enough for one more iteration.
                    let _ = pipe.try_send(WorkItem::StatsRequest);
                }

                if let Some(reporter) = opts.progress {
                    // Use the latest consumer snapshot if we have
                    // one; otherwise synthesise a producer-side
                    // placeholder. On a fresh sweep, before the
                    // first stats round-trip lands, this means
                    // bytes_good ≈ bytes_done (producer's notion of
                    // good-so-far) and the bad-range list is empty —
                    // close enough for an early UI tick; the next
                    // real snapshot replaces it.
                    let main_title = self.titles.first();
                    let main_title_bad = match &cached_snapshot {
                        Some(snap) => self
                            .titles
                            .first()
                            .map(|t| bytes_bad_in_title(t, &snap.bad_ranges))
                            .unwrap_or(0),
                        None => 0,
                    };
                    // The consumer's snapshot is the source of truth for
                    // bytes_unreadable / bytes_pending (the producer doesn't
                    // see them), but its bytes_good lags producer-side
                    // `bytes_done` whenever the consumer is behind on draining
                    // the work channel. Take the max so the user-visible
                    // counter never regresses below what the producer has
                    // already sent — Anomaly B in the 0.18.1 prod test was
                    // this regression: a stale early snapshot pinned the
                    // display to 0 GB while bytes_done was already advancing.
                    let (bytes_good, bytes_unreadable, bytes_pending) = match &cached_snapshot {
                        Some(snap) => (
                            snap.stats.bytes_good.max(bytes_done),
                            snap.stats.bytes_unreadable,
                            snap.stats.bytes_pending,
                        ),
                        None => (bytes_done, 0u64, total_bytes.saturating_sub(bytes_done)),
                    };
                    let pp = crate::progress::PassProgress {
                        kind: crate::progress::PassKind::Sweep,
                        work_done: pos,
                        work_total: total_bytes,
                        bytes_good_total: bytes_good,
                        bytes_unreadable_total: bytes_unreadable,
                        bytes_pending_total: bytes_pending,
                        bytes_total_disc: total_bytes,
                        disc_duration_secs: main_title.map(|t| t.duration_secs),
                        bytes_bad_in_main_title: main_title_bad,
                        main_title_duration_secs: main_title.map(|t| t.duration_secs),
                        main_title_size_bytes: main_title.map(|t| t.size_bytes),
                    };
                    if !reporter.report(&pp) {
                        halt_requested = true;
                        break 'outer;
                    }
                }
            }
        }

        // Producer side is done. Drop the channel and let the
        // consumer drain whatever's still in flight, then run its
        // close() (drain writeback, fsync, mapfile.flush) and return
        // the final stats. On consumer panic `pipe.finish` returns
        // the wrapped panic message via Error::IoError — same shape
        // the previous `consumer_handle.join().map_err(...)` produced.
        let summary = pipe.finish();

        // Producer-side error wins over consumer-side (the read failure
        // is what motivated quitting; the consumer's flush error, if
        // any, is downstream).
        if let Some(e) = producer_err {
            // Drop the consumer's result if we already have a producer
            // error, but propagate consumer-panic on top of nothing
            // since that's strictly informative.
            let _ = summary;
            return Err(e);
        }
        let summary = summary?;

        let stats = summary.stats;
        tracing::debug!(
            target: "freemkv::disc",
            phase = "sweep_done",
            iter_count,
            read_ok_count,
            read_err_count,
            bytes_good = stats.bytes_good,
            bytes_pending = stats.bytes_pending,
            halted = halt_requested,
            copy_elapsed_ms = copy_t0.elapsed().as_millis() as u64,
            "Disc::sweep returning"
        );

        // End-of-pass diagnostic summary (added 2026-05-10 alongside
        // the per-error timing instrumentation in read_error.rs).
        // One INFO line per sweep that lets a post-mortem analyst tell
        // at a glance how much damage the disc + drive saw, without
        // grepping through the per-error WARN log. The PassSummary
        // counters come from `ReadCtx`'s accumulated state.
        let pass_sum = read_ctx.pass_summary();
        tracing::info!(
            target: "freemkv::disc",
            phase = "pass1_summary",
            total_reads_ok = pass_sum.total_reads_ok,
            total_errors = pass_sum.total_errors,
            zones_entered = pass_sum.zones_entered,
            jumps_taken = pass_sum.jumps_taken,
            bytes_good = stats.bytes_good,
            bytes_pending = stats.bytes_pending,
            copy_elapsed_ms = copy_t0.elapsed().as_millis() as u64,
            "Pass 1 complete"
        );
        Ok(CopyResult {
            bytes_total: total_bytes,
            bytes_good: stats.bytes_good,
            bytes_unreadable: stats.bytes_unreadable,
            bytes_pending: stats.bytes_pending,
            recovered_this_pass: 0,
            complete: stats.bytes_pending == 0 && !halt_requested,
            halted: halt_requested,
        })
    }
}

#[derive(Default)]
pub struct CopyOptions<'a> {
    pub decrypt: bool,
    pub multipass: bool,
    pub progress: Option<&'a dyn crate::progress::Progress>,
    pub halt: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
    /// AACS Volume ID (16 bytes) to persist into the mapfile during
    /// Pass 1 so it survives to deferred-mux / resume. `None` for
    /// unencrypted / non-AACS discs. Caller wires this from
    /// `Disc::aacs.volume_id`.
    ///
    /// Persisted ONLY when `unit_keys` is empty (the disc didn't resolve a
    /// key): the VID is the "still unresolved, retry-able" marker.
    pub vid: Option<[u8; 16]>,
    /// Resolved AACS unit keys `(CPS unit, key)` to persist into the mapfile
    /// during Pass 1. When non-empty these are written (the final answer, so
    /// deferred-mux/resume decrypts directly) and the VID is NOT — keys XOR VID.
    /// Caller wires this from `Disc::aacs.unit_keys`.
    pub unit_keys: Vec<(u32, [u8; 16])>,
}

#[derive(Debug, Clone, Copy)]
pub struct CopyResult {
    pub bytes_total: u64,
    pub bytes_good: u64,
    pub bytes_unreadable: u64,
    pub bytes_pending: u64,
    pub recovered_this_pass: u64,
    pub complete: bool,
    pub halted: bool,
}

/// Options for [`Disc::sweep`] (Pass 1 / forward sequential pass).
pub struct SweepOptions<'a> {
    pub decrypt: bool,
    pub resume: bool,
    pub batch_sectors: Option<u16>,
    pub skip_on_error: bool,
    pub progress: Option<&'a dyn crate::progress::Progress>,
    pub halt: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
    /// AACS Volume ID (16 bytes) persisted into the mapfile when the
    /// sweep creates / opens it. `None` for unencrypted discs. Written ONLY
    /// when `unit_keys` is empty (keys XOR VID — the VID is the retry marker).
    pub vid: Option<[u8; 16]>,
    /// Resolved AACS unit keys persisted into the mapfile when the sweep
    /// creates / opens it. When non-empty these win over `vid`.
    pub unit_keys: Vec<(u32, [u8; 16])>,
}

/// Options for [`Disc::patch`] (Pass N retry pass over bad ranges).
pub struct PatchOptions<'a> {
    pub decrypt: bool,
    pub block_sectors: Option<u16>,
    pub full_recovery: bool,
    pub reverse: bool,
    pub wedged_threshold: u64,
    pub progress: Option<&'a dyn crate::progress::Progress>,
    pub halt: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
}

/// Result returned by [`Disc::patch`].
pub struct PatchOutcome {
    pub bytes_total: u64,
    pub bytes_good: u64,
    pub bytes_unreadable: u64,
    pub bytes_pending: u64,
    pub bytes_recovered_this_pass: u64,
    pub halted: bool,
    pub blocks_attempted: u64,
    pub blocks_read_ok: u64,
    pub blocks_read_failed: u64,
    pub wedged_exit: bool,
    pub wedged_threshold: u64,
}

/// Sleep `secs` seconds, but break early if `halt` flips to true.
/// Used by Pass 1's wedge-avoidance inter-error pause so halt
/// remains responsive regardless of how long the pause is.
/// Polling granularity 100 ms — bounded latency on halt regardless
/// of pause length.
pub(crate) fn sleep_secs_or_halt(
    secs: u64,
    halt: Option<&std::sync::Arc<std::sync::atomic::AtomicBool>>,
) {
    if secs == 0 {
        return;
    }
    let Some(h) = halt else {
        std::thread::sleep(std::time::Duration::from_secs(secs));
        return;
    };
    let total = std::time::Duration::from_secs(secs);
    let slice = std::time::Duration::from_millis(100);
    let start = std::time::Instant::now();
    while start.elapsed() < total {
        if h.load(std::sync::atomic::Ordering::Relaxed) {
            return;
        }
        let remaining = total.saturating_sub(start.elapsed());
        std::thread::sleep(remaining.min(slice));
    }
}

pub 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 `/tmp/{volume_id_or_title}.mapfile`.
    /// 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::path::PathBuf::from(format!("/tmp/{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;

pub(crate) fn ecc_sectors(format: DiscFormat) -> u16 {
    match format {
        DiscFormat::Uhd | DiscFormat::BluRay => 32,
        DiscFormat::Dvd => 16,
        DiscFormat::Unknown => 32,
    }
}

/// Coarse damage tier for a finished or in-progress rip. Maps the
/// observable signals (bad sector count + lost wallclock playback time)
/// onto a small discrete classification so UIs can render a colored badge
/// and operators can decide whether to rescan / replug / accept.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DamageSeverity {
    /// No bad sectors at all.
    Clean,
    /// 1–50 bad sectors AND <1 sec lost. Likely unnoticeable.
    Cosmetic,
    /// 51–500 sectors OR 1–30 sec lost. Visible artifacts possible.
    Moderate,
    /// 500+ sectors OR 30+ sec lost. Significant damage; consider rescan
    /// or different drive.
    Serious,
}

/// Classify damage severity from raw counters. `bad_sectors` is the
/// number of sectors marked unreadable (or NonTrimmed pending Pass 2);
/// `lost_ms` is the cumulative wallclock playback time those sectors
/// represent (computed from the title's bytes-per-sec).
pub fn classify_damage(bad_sectors: u64, lost_ms: f64) -> DamageSeverity {
    if bad_sectors == 0 {
        return DamageSeverity::Clean;
    }
    if bad_sectors >= 500 || lost_ms >= 30_000.0 {
        return DamageSeverity::Serious;
    }
    if bad_sectors >= 51 || lost_ms >= 1_000.0 {
        return DamageSeverity::Moderate;
    }
    DamageSeverity::Cosmetic
}

#[cfg(test)]
mod severity_tests {
    use super::*;
    #[test]
    fn clean_when_no_damage() {
        assert_eq!(classify_damage(0, 0.0), DamageSeverity::Clean);
    }
    #[test]
    fn cosmetic_for_a_handful() {
        assert_eq!(classify_damage(1, 5.0), DamageSeverity::Cosmetic);
        assert_eq!(classify_damage(50, 999.0), DamageSeverity::Cosmetic);
    }
    #[test]
    fn moderate_threshold_by_sectors() {
        assert_eq!(classify_damage(51, 0.0), DamageSeverity::Moderate);
    }
    #[test]
    fn moderate_threshold_by_time() {
        assert_eq!(classify_damage(10, 1_000.0), DamageSeverity::Moderate);
    }
    #[test]
    fn serious_threshold_by_sectors() {
        assert_eq!(classify_damage(500, 0.0), DamageSeverity::Serious);
    }
    #[test]
    fn serious_threshold_by_time() {
        assert_eq!(classify_damage(10, 30_000.0), DamageSeverity::Serious);
    }
}

/// Detect the maximum transfer size in sectors for a device.
pub fn detect_max_batch_sectors(device_path: &str) -> u16 {
    let dev_name = device_path.rsplit('/').next().unwrap_or("");
    if dev_name.is_empty() {
        return DEFAULT_BATCH_SECTORS_OPTICAL;
    }

    // Check if optical drive (0x05 = CD/DVD)
    let is_optical = (|| -> bool {
        use std::path::Path;
        let scsi_device_dir = "/sys/class/scsi_device/".to_string();
        if let Ok(entries) = std::fs::read_dir(&scsi_device_dir) {
            for entry in entries.flatten() {
                let device_type_path = entry.path().join("device/type");
                if Path::new(&device_type_path).exists() {
                    if let Ok(content) = std::fs::read_to_string(&device_type_path) {
                        // Type 0x05 (decimal 5) = CD/DVD drive
                        if content.trim().parse::<u32>() == Ok(5) {
                            return true;
                        }
                    }
                }
            }
        }
        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) 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::*;

    /// 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,
                secondary: false,
                label: String::new(),
            })],
            chapters: Vec::new(),
            extents: Vec::new(),
            content_format: ContentFormat::BdTs,
            codec_privates: Vec::new(),
        }
    }

    /// 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: longest 1-clip title is the movie. Sort
    /// must not change behaviour — the existing "duration descending"
    /// expectation holds when no titles overflow capacity.
    #[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, 5_000_000_000, 1), // 10 min menu
            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");
    }

    /// Tiebreak: equal duration + equal capacity-validity → fewer
    /// clips wins. A chapter-stitched 3-clip movie should beat a
    /// 50-clip virtual composite of the same duration.
    #[test]
    fn canonical_order_fewer_clips_wins_tiebreak() {
        const CAPACITY: u64 = 100_000_000_000;
        let mut titles = vec![
            title_with("00050.mpls", 7200.0, 50_000_000_000, 50),
            title_with("00800.mpls", 7200.0, 50_000_000_000, 3),
        ];
        titles.sort_by(|a, b| Disc::canonical_title_order(a, b, CAPACITY));
        assert_eq!(titles[0].playlist, "00800.mpls");
        assert_eq!(titles[1].playlist, "00050.mpls");
    }

    #[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);
    }

    #[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,
            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");
    }

    struct MockReader {
        total_sectors: u32,
        bad_sectors: std::collections::HashSet<u32>,
    }

    impl crate::sector::SectorSource for MockReader {
        fn read_sectors(
            &mut self,
            lba: u32,
            count: u16,
            buf: &mut [u8],
            _recovery: bool,
        ) -> crate::error::Result<usize> {
            let n = count as usize * 2048;
            for i in 0..count {
                if self.bad_sectors.contains(&(lba + i as u32)) {
                    return Err(crate::error::Error::DiscRead {
                        sector: (lba + i as u32) as u64,
                        status: Some(0x02),
                        sense: Some(crate::scsi::ScsiSense {
                            sense_key: 0x02,
                            asc: 0x04,
                            ascq: 0x3E,
                        }),
                    });
                }
            }
            buf[..n].fill(0xAA);
            Ok(n)
        }

        fn capacity_sectors(&self) -> u32 {
            self.total_sectors
        }
    }

    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,
            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
        );
    }

    /// 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(),
        }
    }

    #[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::decrypt_unit_key(&vuk, &enc0);
        let exp1 = crate::aacs::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::decrypt::{ALIGNED_UNIT_LEN, is_aacs_scrambled};

        // 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,
            &[]
        ));

        // 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!(!is_aacs_scrambled(&clear));
        assert!(super::aligned_unit_keys_validate(
            &[(0, [0x11u8; 16])],
            None,
            &[clear.clone()]
        ));

        // A genuinely scrambled unit the RIGHT key restores to clear TS.
        let uk = [0x5au8; 16];
        let enc = encrypt_unit_for_test(&clear, &uk);
        assert!(
            is_aacs_scrambled(&enc),
            "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()]
        ));
        // Wrong key -> cannot de-scramble a scrambled sample -> reject.
        assert!(!super::aligned_unit_keys_validate(
            &[(7, [0x00u8; 16])],
            None,
            &[enc.clone()]
        ));
        // Empty key set against a scrambled sample -> reject.
        assert!(!super::aligned_unit_keys_validate(&[], None, &[enc]));
    }

    /// 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::decrypt::{AACS_IV, ALIGNED_UNIT_LEN};
        use aes::Aes128;
        use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
        let mut unit = clear[..ALIGNED_UNIT_LEN].to_vec();
        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],
        });
        dvd.inject_unit_keys(vec![(0, [0x33; 16])]);
        assert!(dvd.aacs.is_none(), "CSS disc must not gain an AACS state");
    }

    #[test]
    fn sweep_to_dev_null_no_enodev() {
        let tmp = tempfile::tempdir().unwrap();
        let iso_path = tmp.path().join("test.iso");
        let sectors: u32 = 1000;
        let bad: std::collections::HashSet<u32> = [500u32, 501, 502].into_iter().collect();
        let mut reader = MockReader {
            total_sectors: sectors,
            bad_sectors: bad,
        };
        let disc = make_test_disc(sectors, "T1");
        let opts = CopyOptions {
            decrypt: false,
            multipass: true,
            progress: None,
            halt: None,
            vid: None,
            unit_keys: Vec::new(),
        };
        let result = disc.copy(&mut reader, &iso_path, &opts);
        assert!(
            result.is_ok(),
            "sweep to regular file should succeed: {:?}",
            result.err()
        );
    }

    #[test]
    fn sweep_to_dev_null_real() {
        let _cleanup = CleanupGuard(std::path::PathBuf::from("/tmp/T2.mapfile"));
        let sectors: u32 = 1000;
        let bad: std::collections::HashSet<u32> = [500u32, 501, 502].into_iter().collect();
        let mut reader = MockReader {
            total_sectors: sectors,
            bad_sectors: bad,
        };
        let disc = make_test_disc(sectors, "T2");
        let opts = CopyOptions {
            decrypt: false,
            multipass: true,
            progress: None,
            halt: None,
            vid: None,
            unit_keys: Vec::new(),
        };
        let result = disc.copy(&mut reader, std::path::Path::new("/dev/null"), &opts);
        assert!(
            result.is_ok(),
            "sweep to /dev/null should not fail with ENODEV: {:?}",
            result.err()
        );
    }

    struct CleanupGuard(std::path::PathBuf);
    impl Drop for CleanupGuard {
        fn drop(&mut self) {
            let _ = std::fs::remove_file(&self.0);
        }
    }

    #[test]
    fn sweep_dev_null_full_good() {
        let _cleanup = CleanupGuard(std::path::PathBuf::from("/tmp/T3.mapfile"));
        let sectors: u32 = 2000;
        let mut reader = MockReader {
            total_sectors: sectors,
            bad_sectors: std::collections::HashSet::new(),
        };
        let disc = make_test_disc(sectors, "T3");
        let opts = CopyOptions {
            decrypt: false,
            multipass: false,
            progress: None,
            halt: None,
            vid: None,
            unit_keys: Vec::new(),
        };
        let result = disc.copy(&mut reader, std::path::Path::new("/dev/null"), &opts);
        assert!(
            result.is_ok(),
            "full-good sweep to /dev/null should succeed: {:?}",
            result.err()
        );
        let r = result.unwrap();
        assert!(r.complete, "should be complete");
        assert_eq!(r.bytes_good, sectors as u64 * 2048);
    }

    #[test]
    fn patch_dev_null_after_sweep() {
        let tmp = tempfile::tempdir().unwrap();
        let iso_path = tmp.path().join("test.iso");
        let sectors: u32 = 500;
        let bad: std::collections::HashSet<u32> = [100u32, 200, 300].into_iter().collect();
        let mut reader = MockReader {
            total_sectors: sectors,
            bad_sectors: bad.clone(),
        };
        let disc = make_test_disc(sectors, "T4");

        let sweep_opts = CopyOptions {
            decrypt: false,
            multipass: true,
            progress: None,
            halt: None,
            vid: None,
            unit_keys: Vec::new(),
        };
        let sweep_result = disc.copy(&mut reader, &iso_path, &sweep_opts);
        assert!(
            sweep_result.is_ok(),
            "sweep should succeed: {:?}",
            sweep_result.err()
        );

        let mut reader2 = MockReader {
            total_sectors: sectors,
            bad_sectors: std::collections::HashSet::new(),
        };
        let patch_opts = CopyOptions {
            decrypt: false,
            multipass: true,
            progress: None,
            halt: None,
            vid: None,
            unit_keys: Vec::new(),
        };
        let patch_result = disc.copy(&mut reader2, &iso_path, &patch_opts);
        assert!(
            patch_result.is_ok(),
            "patch should succeed: {:?}",
            patch_result.err()
        );
        let pr = patch_result.unwrap();
        assert!(
            pr.complete,
            "patch should complete: bytes_pending={}",
            pr.bytes_pending
        );
    }

    #[test]
    fn patch_dev_null_direct() {
        let tmp = tempfile::tempdir().unwrap();
        let iso_path = tmp.path().join("test.iso");
        let sectors: u32 = 500;
        let bad: std::collections::HashSet<u32> = [100u32, 200, 300].into_iter().collect();
        let mut reader = MockReader {
            total_sectors: sectors,
            bad_sectors: bad.clone(),
        };
        let disc = make_test_disc(sectors, "T5");

        let sweep_opts = CopyOptions {
            decrypt: false,
            multipass: true,
            progress: None,
            halt: None,
            vid: None,
            unit_keys: Vec::new(),
        };
        let _sweep_result = disc.copy(&mut reader, &iso_path, &sweep_opts).unwrap();

        let mut reader2 = MockReader {
            total_sectors: sectors,
            bad_sectors: std::collections::HashSet::new(),
        };
        let patch_opts = CopyOptions {
            decrypt: false,
            multipass: true,
            progress: None,
            halt: None,
            vid: None,
            unit_keys: Vec::new(),
        };
        let patch_result = disc.copy(&mut reader2, std::path::Path::new("/dev/null"), &patch_opts);
        assert!(
            patch_result.is_ok(),
            "patch to /dev/null should succeed: {:?}",
            patch_result.err()
        );
    }

    /// Synthetic regression test for the 0.18 SweepSink + Pipeline
    /// migration. ~100 batches of clean reads (6000 sectors at the
    /// default 60-sector single-pass batch size); verifies all bytes
    /// land in the ISO and the consumer's final stats match the input.
    /// The throughput regression check (vs 0.17.13) is a separate
    /// manual / live-drive concern; here we only assert correctness.
    #[test]
    fn sweep_pipeline_full_good_100_batches() {
        let tmp = tempfile::tempdir().unwrap();
        let iso_path = tmp.path().join("test.iso");
        // 6000 sectors / 60-sector default batch = exactly 100
        // produce/consume cycles through the pipeline.
        let sectors: u32 = 6000;
        let mut reader = MockReader {
            total_sectors: sectors,
            bad_sectors: std::collections::HashSet::new(),
        };
        let disc = make_test_disc(sectors, "TPipeline100");
        let opts = CopyOptions {
            decrypt: false,
            multipass: false,
            progress: None,
            halt: None,
            vid: None,
            unit_keys: Vec::new(),
        };
        let result = disc.copy(&mut reader, &iso_path, &opts);
        let r = result.expect("100-batch clean sweep should succeed");
        assert!(r.complete, "complete=true expected");
        assert!(!r.halted, "halted=false expected");
        assert_eq!(
            r.bytes_good,
            sectors as u64 * 2048,
            "all sectors must be marked good after a 100% clean sweep"
        );
        assert_eq!(
            r.bytes_pending, 0,
            "no pending bytes expected after a clean sweep"
        );
        // The ISO file must end up the right size — the consumer
        // wrote everything before fsync.
        let meta = std::fs::metadata(&iso_path).unwrap();
        assert_eq!(meta.len(), sectors as u64 * 2048);
    }
}
