//! HD-DVD title scanning — `HVDVD_TS/` Enhanced-VOB (`.evo`) enumeration.
//!
//! HD-DVD is a **tree-level peer** of DVD and Blu-ray (not a stream variant like
//! FMTS): its content lives in `HVDVD_TS/` as `.evo` clips — Enhanced VOB, an
//! MPEG **program** stream — each with a small `.map` timemap sidecar, navigated
//! by `.xpl`/`.ifo` playlists in `HVDVD_TS/` and `ADV_OBJ/`. Because it is a
//! different tree with a different playlist format, it gets its OWN scanner
//! (this file), a peer to [`Disc::scan_bluray_titles`] — the two-format design
//! rule: a genuinely different format is a new enumerator, not an extension
//! bolted into the BD path.
//!
//! Title composition — authoritative, from the Advanced-Content playlist. HD-DVD
//! ships a real player playlist at `ADV_OBJ/VPLST000.XPL` (DVD-Forum
//! `HDDVDVideo/Playlist` XML). The scanner parses it (with a real XML parser,
//! `roxmltree`) into one [`DiscTitle`] per `<Title>`: its `<PrimaryAudioVideoClip>`
//! clips in playback order (each an EVO, referenced via its `.MAP` sidecar), the
//! `titleDuration`, the `displayName`, and the `<ChapterList>`. A layer-break
//! split (`FEATURE_1` + `FEATURE_2`, or `feature`/`feature_Divide`) is composed
//! into ONE title with the two parts as clips, carrying each clip's title-time
//! in/out points (45 kHz ticks) so a seamless join can be spliced onto one
//! timeline. Container is [`ContentFormat::MpegPs`], so the existing PS mux path
//! handles it. Per-clip streams are enumerated by demuxing the clip head and
//! building one [`Stream`] per distinct elementary stream (video + DD+ audio),
//! codec sniffed from the ES bytes.
//!
//! When no playlist is present (or it fails to parse), the scanner falls back to
//! the older clip-name heuristic: parse the `HVA*.VTI` clip table, join the
//! `feature*`-named clips into one title, and emit every other clip on its own.
//!
//! Not parsed yet: subtitles (8-bit RLC on `0xBD` sub `0x20..=0x3F`) and per-track
//! audio languages (the XPL carries `<Audio description=...>` but they are not yet
//! wired onto the streams). Extents and size are real (the ripper images the
//! clips).

use super::*;
use crate::mux::ps::{PsDemuxer, dvd_audio_pid};
use crate::sector::SectorSource;
use crate::udf;
use std::collections::BTreeMap;

/// Clip stream-file extension in the HD-DVD `HVDVD_TS/` tree. HD-DVD is a
/// separate tree from BD, so this is a separate constant — deliberately NOT an
/// entry in [`super::bluray`]'s BD-tree `CLIP_STREAM_EXTS`.
const HDDVD_CLIP_EXT: &str = ".evo";

/// Sectors of an `.evo` clip head to demux when probing its elementary streams
/// (~16 MiB). Enough to see the opening video access unit (SPS) plus every
/// interleaved audio sub-stream, without imaging the whole multi-GiB clip.
const EVO_PROBE_SECTORS: u32 = 8192;

/// Cap on the elementary-stream sample retained per stream while probing — a
/// video SPS / audio syncword lands well inside the first few KiB, so 128 KiB
/// is generous while bounding probe memory.
const EVO_ES_SAMPLE_CAP: usize = 128 * 1024;

/// HD-DVD Standard Content navigation file magic (`HVDVD_TS/HVA*.VTI`). The VTI
/// is the DVD-IFO analogue: it holds a fixed-stride clip table naming every
/// `.evo` in authored order.
const HDDVD_VTI_MAGIC: &[u8] = b"ADVANCED-VTS";

/// Byte stride between clip-table entries in the VTI. Each entry holds a
/// NUL-terminated `<name>.EVO` at a constant sub-offset, so every clip name
/// shares one residue modulo this stride — the signal used to isolate the table.
const VTI_CLIP_ENTRY_STRIDE: usize = 0x140;

/// Cap on clip-name hits collected from a VTI. A real clip table holds a few
/// dozen entries; this bounds the scan so a crafted VTI packed with millions of
/// `.EVO` tokens (up to the 64 MiB UDF read cap) can't burn CPU/memory.
const MAX_VTI_HITS: usize = 8192;

/// Parse the clip-name table from an `ADVANCED-VTS` VTI, returning clip
/// filenames in authored (table) order.
///
/// The table is a run of `VTI_CLIP_ENTRY_STRIDE`-spaced records, each carrying a
/// NUL-terminated `<name>.EVO`. Rather than trust the (imprecise) header pointer,
/// this collects every NUL-terminated `*.EVO` name and keeps the largest group
/// sharing one residue modulo the stride — the clip table — in offset order.
/// Returns empty for a non-VTI blob or one with no recognizable table.
fn parse_vti_clip_order(vti: &[u8]) -> Vec<String> {
    if !vti.starts_with(HDDVD_VTI_MAGIC) {
        return Vec::new();
    }
    let is_name_byte = |b: u8| b.is_ascii_graphic();
    // Bucket hits by residue-mod-stride in a SINGLE pass — the clip table shares
    // one residue, so the largest bucket is it (avoids an O(stride*hits) rescan).
    let mut buckets: std::collections::HashMap<usize, Vec<(usize, String)>> =
        std::collections::HashMap::new();
    let mut count = 0usize;
    let mut i = 0usize;
    while i < vti.len() && count < MAX_VTI_HITS {
        if !is_name_byte(vti[i]) {
            i += 1;
            continue;
        }
        let start = i;
        while i < vti.len() && is_name_byte(vti[i]) {
            i += 1;
        }
        let name = &vti[start..i];
        let nul_terminated = i < vti.len() && vti[i] == 0;
        if nul_terminated && name.len() >= 5 && name[name.len() - 4..].eq_ignore_ascii_case(b".EVO")
        {
            buckets
                .entry(start % VTI_CLIP_ENTRY_STRIDE)
                .or_default()
                .push((start, String::from_utf8_lossy(name).into_owned()));
            count += 1;
        }
    }
    // Pick the largest residue bucket (the clip table). On a size tie, break
    // deterministically by the bucket's smallest offset — `HashMap` iteration
    // order is randomized, so `max_by_key` alone could pick a different bucket
    // run-to-run on identical bytes.
    let Some(mut best) = buckets
        .into_values()
        .max_by_key(|g| (g.len(), std::cmp::Reverse(g.iter().map(|(o, _)| *o).min())))
    else {
        return Vec::new();
    };
    best.sort_by_key(|(o, _)| *o);
    best.into_iter().map(|(_, n)| n).collect()
}

/// Whether a clip belongs to the main feature. HD-DVD Standard Content authors
/// the feature as one or more clips whose name begins `feature` (case-insensitive)
/// — `FEATURE_1`/`FEATURE_2` (a layer-break split) or `feature`/`feature_Divide`.
/// The feature is imaged as ONE title by concatenating these in authored order.
fn is_feature_clip(name: &str) -> bool {
    let base = name.rsplit_once('.').map(|(b, _)| b).unwrap_or(name);
    base.to_ascii_lowercase().starts_with("feature")
}

/// Sniff a video codec from a program-stream video elementary-stream sample by
/// its MPEG / Annex-B start codes:
///   * `00 00 01 B3` → MPEG-2 (sequence_header)
///   * `00 00 01 0F` → VC-1 (BD/HD-DVD sequence-header BDU)
///   * `00 00 01 [x7]` H.264 SPS NAL (type 7, forbidden_zero_bit clear) → H.264
///
/// Returns `None` when no recognizable start code is present. The scan prefers
/// the unambiguous MPEG-2 / VC-1 sequence headers; H.264 is inferred from an SPS
/// NAL so a stray slice/picture code can't be mistaken for a different codec.
fn sniff_video_codec(es: &[u8]) -> Option<Codec> {
    let mut saw_h264_sps = false;
    let mut i = 0usize;
    while i + 4 <= es.len() {
        if es[i] == 0x00 && es[i + 1] == 0x00 && es[i + 2] == 0x01 {
            let code = es[i + 3];
            match code {
                0xB3 => return Some(Codec::Mpeg2),
                0x0F => return Some(Codec::Vc1),
                // H.264 SPS: mask off nal_ref_idc (bits 6-5); keep the
                // forbidden_zero_bit (must be 0) + nal_unit_type (low 5 bits).
                // 0x07/0x27/0x47/0x67 all decode to a type-7 SPS.
                _ if (code & 0x9F) == 0x07 => saw_h264_sps = true,
                _ => {}
            }
            // Skip the whole consumed `00 00 01 <code>` marker (4 bytes) so the
            // code byte isn't re-read as the start of an overlapping start code.
            i += 4;
        } else {
            i += 1;
        }
    }
    saw_h264_sps.then_some(Codec::H264)
}

/// Sniff an audio codec from a `private_stream_1` sub-stream sample. Today only
/// Dolby Digital Plus (E-AC-3) is recognized — its `0x0B77` syncword — which is
/// what ANCHORMAN / SHAUN carry on sub-ids `0xC0..=0xC7`. Returns `None` for an
/// unrecognized sample so the caller drops the stream rather than mislabeling it.
fn sniff_audio_codec(es: &[u8]) -> Option<Codec> {
    let has_sync = es.windows(2).any(|w| w[0] == 0x0B && w[1] == 0x77);
    has_sync.then_some(Codec::Ac3Plus)
}

/// Demux the head of an `.evo` clip (through the disc's [`SectorSource`]) and
/// build one [`Stream`] per distinct elementary stream found: the video track
/// (mapped to the canonical [`DVD_VIDEO_PID`]) and every DD+ audio sub-stream
/// (mapped via [`dvd_audio_pid`]). Codec is sniffed from the demuxed ES bytes.
///
/// Mirrors the stream construction in `Disc::scan_dvd_titles`; resolution /
/// language / channels use sane HD-DVD defaults (the muxer reads the true pixel
/// dimensions from the H.264 SPS, and E-AC-3 channel counts are not decoded
/// here). Returns an empty vec when the clip cannot be read or carries no
/// recognizable stream (e.g. an AACS-encrypted clip probed as ciphertext).
fn probe_evo_streams(reader: &mut dyn SectorSource, extents: &[Extent]) -> Vec<Stream> {
    let mut demux = PsDemuxer::new();
    let mut video: Vec<u8> = Vec::new();
    // Routing PID of the video track, captured from the first video PES seen:
    // `DVD_VIDEO_PID` for a plain 0xE0-0xEF stream (Anchorman's H.264 on 0xE2),
    // or `0xFD00 | stream_id_extension` for an HD-DVD extended-stream-id video
    // (Shaun's VC-1 on 0xFD ext 0x55). Kept in lockstep with `PsPacket::dvd_pid`
    // so the emitted `Stream` PID matches what the demuxer routes at mux time.
    let mut video_pid: Option<u16> = None;
    // sub_id -> ES sample, ordered so audio tracks surface in sub-id order.
    let mut audio: BTreeMap<u8, Vec<u8>> = BTreeMap::new();

    let mut remaining = EVO_PROBE_SECTORS;
    'outer: for ext in extents {
        let mut lba = ext.start_lba;
        let mut left = ext.sector_count;
        while left > 0 && remaining > 0 {
            // 1 MiB read chunks (512 sectors) keep buffers small.
            let n = left.min(remaining).min(512) as u16;
            let mut buf = vec![0u8; n as usize * crate::consts::SECTOR_BYTES];
            if reader.read_sectors(lba, n, &mut buf, false).is_err() {
                break 'outer;
            }
            for pkt in demux.feed(&buf) {
                collect_es(&pkt, &mut video, &mut video_pid, &mut audio);
            }
            lba += n as u32;
            left -= n as u32;
            remaining -= n as u32;
        }
    }
    for pkt in demux.flush() {
        collect_es(&pkt, &mut video, &mut video_pid, &mut audio);
    }

    let mut streams = Vec::new();
    // Emit the video stream only when the codec was actually identified from the
    // sampled head. Guessing (e.g. defaulting to H.264) would tag a VC-1 — or a
    // still-encrypted — clip with the wrong codec, so the mux applies the wrong
    // parser and produces a corrupt track; dropping it is the honest outcome
    // (matches the audio path below), and a real clear clip always carries its
    // sequence header / SPS at the head, so this never fires on a normal disc.
    if let (Some(pid), Some(codec)) = (video_pid, sniff_video_codec(&video)) {
        streams.push(Stream::Video(VideoStream {
            pid,
            codec,
            // HD-DVD is HD (1080). The muxer reads the true coded dimensions
            // from the H.264/VC-1 bitstream; this is a coarse default only.
            resolution: Resolution::R1080p,
            frame_rate: FrameRate::F23_976,
            hdr: HdrFormat::Sdr,
            color_space: ColorSpace::Bt709,
            display_aspect: None,
            secondary: false,
            label: String::new(),
            measured_cicp: None,
        }));
    }
    for (sub, sample) in &audio {
        let Some(codec) = sniff_audio_codec(sample) else {
            continue;
        };
        let Some(pid) = dvd_audio_pid(*sub) else {
            continue;
        };
        streams.push(Stream::Audio(AudioStream {
            pid,
            codec,
            // DD+ main tracks are 5.1; E-AC-3 channel counts are not decoded at
            // scan time, so this is a default (a 2.0 track is over-stated as
            // 5.1 in the header — the compressed audio itself is unaffected).
            channels: AudioChannels::Surround51,
            language: String::new(),
            sample_rate: SampleRate::S48,
            secondary: false,
            purpose: crate::disc::LabelPurpose::Normal,
            label: String::new(),
        }));
    }
    streams
}

/// Accumulate a demuxed PES packet's elementary-stream bytes into the video /
/// per-audio-sub-id sample buffers (bounded by [`EVO_ES_SAMPLE_CAP`]).
fn collect_es(
    pkt: &crate::mux::ps::PsPacket,
    video: &mut Vec<u8>,
    video_pid: &mut Option<u16>,
    audio: &mut BTreeMap<u8, Vec<u8>>,
) {
    use crate::consts::pes_stream_id::{PRIVATE_STREAM_1, VIDEO, VIDEO_MAX};
    const EXTENDED_STREAM_ID: u8 = 0xFD;
    /// VC-1 video rides extended-stream-id `0xFD` with `stream_id_extension`
    /// `0x55`. HD audio (MLP/TrueHD) can also use `0xFD` with other extensions —
    /// routing those to their own audio tracks is deferred (see the HD-DVD
    /// program-chain follow-up); until then only the VC-1 extension is treated as
    /// video, so an audio `0xFD` sub-stream can never mis-stamp the video PID.
    const VC1_STREAM_ID_EXT: u8 = 0x55;
    // Whether this packet is the VC-1 video sub-stream of the 0xFD extended id.
    let is_vc1_ext =
        pkt.stream_id == EXTENDED_STREAM_ID && pkt.sub_stream_id == Some(VC1_STREAM_ID_EXT);
    match pkt.stream_id {
        // Plain MPEG video (0xE0-0xEF), or the VC-1 sub-stream of the HD-DVD
        // extended-stream-id (0xFD). Both feed the single video ES sample; the
        // routing PID comes from `PsPacket::dvd_pid` so it matches the demuxer.
        VIDEO..=VIDEO_MAX => {
            if video_pid.is_none() {
                *video_pid = pkt.dvd_pid();
            }
            if video.len() < EVO_ES_SAMPLE_CAP {
                video.extend_from_slice(&pkt.data);
            }
        }
        EXTENDED_STREAM_ID if is_vc1_ext => {
            if video_pid.is_none() {
                *video_pid = pkt.dvd_pid();
            }
            if video.len() < EVO_ES_SAMPLE_CAP {
                video.extend_from_slice(&pkt.data);
            }
        }
        PRIVATE_STREAM_1 => {
            if let Some(sub) = pkt.sub_stream_id {
                if (0xC0..=0xC7).contains(&sub) {
                    let slot = audio.entry(sub).or_default();
                    if slot.len() < EVO_ES_SAMPLE_CAP {
                        slot.extend_from_slice(&pkt.data);
                    }
                }
            }
        }
        _ => {}
    }
}

// ─────────────────────── Advanced-Content playlist (XPL) ──────────────────
//
// HD-DVD Advanced Content ships an authoritative playlist at
// `ADV_OBJ/VPLST000.XPL` (DVD-Forum `HDDVDVideo/Playlist` XML). It is the real
// player playlist: a `<TitleSet>` of `<Title>`s, each naming its
// `<PrimaryAudioVideoClip>` clips (an EVO, via its `.MAP` sidecar) in playback
// order with title-time in/out points, a `titleDuration`, a `displayName`, and a
// `<ChapterList>`. Parsing it gives authoritative title composition — clips,
// duration, name, chapters — instead of the `feature*` clip-name heuristic, plus
// the per-clip title-time offsets needed to splice a layer-break split
// (`FEATURE_1` + `FEATURE_2`) onto one continuous timeline. Parsed with a real
// XML parser (`roxmltree`), not a hand-rolled scanner — the XPL is genuine XML.

/// One clip reference inside an XPL `<Title>`: the resolved `.evo` name (lower
/// case) and the clip's placement on the title timeline, in seconds.
struct XplClip {
    evo: String,
    begin_secs: f64,
    end_secs: f64,
}

/// One `<Title>` from the XPL: number, display name, total duration, its clips
/// in playback order, and chapter start times (seconds).
struct XplTitle {
    number: u16,
    name: String,
    duration_secs: f64,
    clips: Vec<XplClip>,
    chapters: Vec<f64>,
}

/// Parse an `HH:MM:SS:FF` (or `MM:SS:FF`) timecode at `tick_base` frames/sec into
/// seconds. `None` on a malformed field.
fn parse_timecode(s: &str, tick_base: u32) -> Option<f64> {
    let n: Vec<u32> = s
        .split(':')
        .map(|p| p.trim().parse::<u32>())
        .collect::<std::result::Result<Vec<u32>, _>>()
        .ok()?;
    let tb = tick_base.max(1) as f64;
    let (h, m, sec, f) = match n.as_slice() {
        [h, m, s, f] => (*h, *m, *s, *f),
        [m, s, f] => (0, *m, *s, *f),
        _ => return None,
    };
    Some(h as f64 * 3600.0 + m as f64 * 60.0 + sec as f64 + f as f64 / tb)
}

/// `tickBase="60fps"` → 60. Defaults to 60 when absent/unparseable.
fn parse_tick_base(s: &str) -> u32 {
    let digits: String = s.chars().take_while(|c| c.is_ascii_digit()).collect();
    digits.parse().unwrap_or(60)
}

/// `<PrimaryAudioVideoClip src="file:///.../FEATURE_1.MAP">` → `feature_1.evo`:
/// take the basename, drop the extension, normalise to a lower-case `.evo` name
/// (the playlist references the `.MAP` sidecar; the A/V is the same-stem `.EVO`).
fn evo_from_src(src: &str) -> Option<String> {
    let base = src.rsplit(['/', '\\']).next().unwrap_or(src);
    let stem = base.rsplit_once('.').map(|(s, _)| s).unwrap_or(base);
    if stem.is_empty() {
        return None;
    }
    Some(format!("{}.evo", stem.to_ascii_lowercase()))
}

/// Parse the Advanced-Content playlist into its titles. Elements are matched by
/// LOCAL name (the document is in the `HDDVDVideo/Playlist` default namespace).
/// Returns empty for a non-XML / non-playlist blob so the caller falls back to
/// the clip-name heuristic.
fn parse_xpl_titles(xpl: &[u8]) -> Vec<XplTitle> {
    let text = String::from_utf8_lossy(xpl);
    let Ok(doc) = roxmltree::Document::parse(&text) else {
        return Vec::new();
    };
    let local = |n: &roxmltree::Node, name: &str| n.tag_name().name() == name;

    // tickBase lives on <TitleSet> (default 60fps).
    let tick_base = doc
        .descendants()
        .find(|n| local(n, "TitleSet"))
        .and_then(|n| n.attribute("tickBase"))
        .map(parse_tick_base)
        .unwrap_or(60);

    let mut titles = Vec::new();
    for tnode in doc.descendants().filter(|n| local(n, "Title")) {
        let number = tnode
            .attribute("titleNumber")
            .and_then(|s| s.trim().parse::<u16>().ok())
            .unwrap_or(0);
        let name = tnode
            .attribute("displayName")
            .or_else(|| tnode.attribute("id"))
            .unwrap_or("")
            .to_string();
        let duration_secs = tnode
            .attribute("titleDuration")
            .and_then(|s| parse_timecode(s, tick_base))
            .unwrap_or(0.0);

        let mut clips = Vec::new();
        for c in tnode
            .descendants()
            .filter(|n| local(n, "PrimaryAudioVideoClip"))
        {
            let Some(evo) = c.attribute("src").and_then(evo_from_src) else {
                continue;
            };
            let begin_secs = c
                .attribute("titleTimeBegin")
                .and_then(|s| parse_timecode(s, tick_base))
                .unwrap_or(0.0);
            let end_secs = c
                .attribute("titleTimeEnd")
                .and_then(|s| parse_timecode(s, tick_base))
                .unwrap_or(begin_secs);
            clips.push(XplClip {
                evo,
                begin_secs,
                end_secs,
            });
        }
        if clips.is_empty() {
            continue;
        }

        let chapters = tnode
            .descendants()
            .filter(|n| local(n, "Chapter"))
            .filter_map(|ch| {
                ch.attribute("titleTimeBegin")
                    .and_then(|s| parse_timecode(s, tick_base))
            })
            .collect();

        titles.push(XplTitle {
            number,
            name,
            duration_secs,
            clips,
            chapters,
        });
    }
    titles
}

/// Read the Advanced-Content playlist `ADV_OBJ/VPLST*.XPL`, if present.
fn read_adv_obj_xpl(reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs) -> Option<Vec<u8>> {
    let dir = udf_fs.find_dir("/ADV_OBJ")?;
    let name = dir.entries.iter().find_map(|e| {
        let lower = e.name.to_ascii_lowercase();
        (!e.is_dir && lower.starts_with("vplst") && lower.ends_with(".xpl")).then(|| e.name.clone())
    })?;
    udf_fs.read_file(reader, &format!("/ADV_OBJ/{name}")).ok()
}

/// Compose [`DiscTitle`]s from parsed XPL titles: resolve each title's clips to
/// physical extents, concatenate them in playback order, carry the title-time
/// in/out points onto each [`Clip`] (45 kHz ticks — the offset that splices a
/// layer-break split onto one timeline), and attach the duration, name, and
/// chapters. A title whose clips resolve to no on-disc extents is skipped.
fn compose_xpl_titles(
    reader: &mut dyn SectorSource,
    xpl_titles: &[XplTitle],
    clip_extents: &BTreeMap<String, (String, u64, Vec<Extent>)>,
) -> Vec<DiscTitle> {
    let mut titles = Vec::new();
    for t in xpl_titles {
        let mut extents = Vec::new();
        let mut size_bytes = 0u64;
        let mut parts = Vec::new();
        for c in &t.clips {
            let Some((orig, size, exts)) = clip_extents.get(&c.evo) else {
                continue;
            };
            extents.extend_from_slice(exts);
            size_bytes += *size;
            parts.push(Clip {
                clip_id: orig
                    .rsplit_once('.')
                    .map(|(b, _)| b)
                    .unwrap_or(orig)
                    .to_string(),
                in_time: (c.begin_secs * 45000.0).clamp(0.0, u32::MAX as f64) as u32,
                out_time: (c.end_secs * 45000.0).clamp(0.0, u32::MAX as f64) as u32,
                duration_secs: (c.end_secs - c.begin_secs).max(0.0),
                source_packets: 0,
            });
        }
        if parts.is_empty() {
            continue;
        }
        let streams = probe_evo_streams(reader, &extents);
        let chapters = t
            .chapters
            .iter()
            .enumerate()
            .map(|(i, &ts)| Chapter {
                time_secs: ts.max(0.0),
                name: super::chapter_name(i),
            })
            .collect();
        titles.push(DiscTitle {
            // Language-neutral identifier (no user-facing English in the library):
            // matches the UDF `TITLE_*` volume-label style. Apps localize display.
            playlist: if t.name.is_empty() {
                format!("TITLE_{}", t.number)
            } else {
                t.name.clone()
            },
            playlist_id: t.number,
            duration_secs: t.duration_secs,
            size_bytes,
            clips: parts,
            streams,
            chapters,
            extents,
            content_format: ContentFormat::MpegPs,
            codec_privates: Vec::new(),
        });
    }
    titles
}

impl Disc {
    /// Scan HD-DVD titles from the `HVDVD_TS/` `.evo` clips.
    ///
    /// The main feature is authored as one or more `.evo` clips (a layer-break
    /// split — `FEATURE_1`/`FEATURE_2` or `feature`/`feature_Divide`). The
    /// `HVA*.VTI` navigation file names every clip in authored order; this parses
    /// it to concatenate the feature clips into ONE title (so the largest-title
    /// pick gets the whole movie, not just part 1), emitting every other clip as
    /// its own title. Falls back to one title per clip when the VTI is absent or
    /// unparseable, so a disc with no readable navigation still enumerates.
    /// `chapters`/duration are left empty pending deeper VTI parsing.
    pub(super) fn scan_hddvd_titles(
        reader: &mut dyn SectorSource,
        udf_fs: &udf::UdfFs,
    ) -> Vec<DiscTitle> {
        let Some(ts_dir) = udf_fs.find_dir("/HVDVD_TS") else {
            return Vec::new();
        };
        // Snapshot clips (name, size) and the VTI navigation file. The `ts_dir`
        // borrow must end before the `udf_fs` reads below re-borrow it.
        let mut clips: Vec<(String, u64)> = Vec::new();
        let mut vti_name: Option<String> = None;
        for e in &ts_dir.entries {
            if e.is_dir {
                continue;
            }
            let lower = e.name.to_ascii_lowercase();
            if lower.ends_with(HDDVD_CLIP_EXT) {
                clips.push((e.name.clone(), e.size));
            } else if lower.ends_with(".vti") && vti_name.is_none() {
                vti_name = Some(e.name.clone());
            }
        }

        // Authored clip order from the VTI clip table (empty if no VTI).
        let order: Vec<String> = vti_name
            .and_then(|n| udf_fs.read_file(reader, &format!("/HVDVD_TS/{n}")).ok())
            .map(|b| parse_vti_clip_order(&b))
            .unwrap_or_default();

        // Resolve each clip's physical extents once, keyed by lower-case name.
        let mut clip_extents: BTreeMap<String, (String, u64, Vec<Extent>)> = BTreeMap::new();
        for (name, size) in &clips {
            let mut extents = Vec::new();
            if let Ok(file_exts) = udf_fs.file_extents(reader, &format!("/HVDVD_TS/{name}")) {
                for (lba, sectors) in file_exts {
                    if sectors > 0 && lba > 0 {
                        extents.push(Extent {
                            start_lba: lba,
                            sector_count: sectors,
                        });
                    }
                }
            }
            if !extents.is_empty() {
                clip_extents.insert(name.to_ascii_lowercase(), (name.clone(), *size, extents));
            }
        }

        // Authoritative composition from the Advanced-Content playlist
        // (`ADV_OBJ/VPLST*.XPL`): real per-title clip lists, durations, names,
        // chapters, and title-time offsets. This is the primary path; the
        // clip-name heuristic below is the fallback when the playlist is absent
        // or unparseable (or resolves to no on-disc clips).
        if let Some(xpl) = read_adv_obj_xpl(reader, udf_fs) {
            let composed = compose_xpl_titles(reader, &parse_xpl_titles(&xpl), &clip_extents);
            if !composed.is_empty() {
                return composed;
            }
        }

        // Feature clips, in authored order, that actually resolved to extents.
        let feature: Vec<String> = order
            .iter()
            .filter(|n| is_feature_clip(n))
            .filter(|n| clip_extents.contains_key(&n.to_ascii_lowercase()))
            .cloned()
            .collect();
        let feature_set: std::collections::HashSet<String> =
            feature.iter().map(|n| n.to_ascii_lowercase()).collect();

        let mut titles = Vec::new();
        let mut next_id = 0u16;

        // The composed feature title: concatenate its parts' extents in authored
        // order. Streams are probed from the head (the first part). One `Clip` per
        // part records the composition.
        if !feature.is_empty() {
            let mut extents = Vec::new();
            let mut size_bytes = 0u64;
            let mut parts = Vec::new();
            for n in &feature {
                if let Some((orig, size, exts)) = clip_extents.get(&n.to_ascii_lowercase()) {
                    extents.extend_from_slice(exts);
                    size_bytes += *size;
                    parts.push(Clip {
                        clip_id: orig
                            .rsplit_once('.')
                            .map(|(b, _)| b)
                            .unwrap_or(orig)
                            .to_string(),
                        in_time: 0,
                        out_time: 0,
                        duration_secs: 0.0,
                        source_packets: 0,
                    });
                }
            }
            let streams = probe_evo_streams(reader, &extents);
            titles.push(DiscTitle {
                playlist: "FEATURE".to_string(),
                playlist_id: next_id,
                duration_secs: 0.0,
                size_bytes,
                clips: parts,
                streams,
                chapters: Vec::new(),
                extents,
                content_format: ContentFormat::MpegPs,
                codec_privates: Vec::new(),
            });
            next_id = next_id.saturating_add(1);
        }

        // Every remaining clip is its own title (unchanged behaviour). Iterated in
        // directory order; when there is no VTI/feature this emits ALL clips.
        for (name, _size) in &clips {
            let key = name.to_ascii_lowercase();
            if feature_set.contains(&key) {
                continue;
            }
            let Some((orig, size, extents)) = clip_extents.get(&key) else {
                continue;
            };
            // Probe the clip head for its elementary streams so the mux path
            // builds a non-empty `pid_to_track` and actually routes packets.
            let streams = probe_evo_streams(reader, extents);
            let clip_id = orig
                .rsplit_once('.')
                .map(|(base, _)| base.to_string())
                .unwrap_or_else(|| orig.clone());
            titles.push(DiscTitle {
                playlist: orig.clone(),
                playlist_id: next_id,
                duration_secs: 0.0,
                size_bytes: *size,
                clips: vec![Clip {
                    clip_id,
                    in_time: 0,
                    out_time: 0,
                    duration_secs: 0.0,
                    source_packets: 0,
                }],
                streams,
                chapters: Vec::new(),
                extents: extents.clone(),
                content_format: ContentFormat::MpegPs,
                codec_privates: Vec::new(),
            });
            next_id = next_id.saturating_add(1);
        }
        titles
    }
}

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

    /// Build a UDF with an `HVDVD_TS/` tree holding the listed `.evo` clips
    /// (name, sector count, data LBA).
    fn make_hddvd_fs(disc: &mut MemDisc, evos: &[(&str, u32, u32)]) -> crate::udf::UdfFs {
        let mut files = Vec::new();
        let mut icb = 100u32;
        for (name, sectors, data_lba) in evos {
            files.push(file(name, icb, *data_lba, sectors * 2048, true));
            icb += 1;
        }
        let root = DirSpec {
            name: String::new(),
            icb_lba: 10,
            dir_data_lba: 11,
            files: Vec::new(),
            subdirs: vec![DirSpec {
                name: "HVDVD_TS".to_string(),
                icb_lba: 20,
                dir_data_lba: 21,
                files,
                subdirs: vec![],
            }],
        };
        build_udf_skeleton(disc, 10);
        lay_dir(disc, &root);
        crate::udf::read_filesystem(disc).expect("fs")
    }

    /// HD-DVD's own enumerator yields one title per `.evo`, MpegPs container,
    /// with real physical extents (mirrors the BD `.m2ts` extent path).
    #[test]
    fn scan_hddvd_titles_enumerates_evo_extents() {
        let mut disc = MemDisc::new();
        let udf = make_hddvd_fs(
            &mut disc,
            &[("FEATURE.EVO", 2000, 5000), ("BLOOP.EVO", 300, 9000)],
        );
        let titles = Disc::scan_hddvd_titles(&mut disc, &udf);
        assert_eq!(titles.len(), 2, "one title per .evo clip");
        for t in &titles {
            assert_eq!(
                t.content_format,
                ContentFormat::MpegPs,
                "EVO is a program stream"
            );
            assert_eq!(t.extents.len(), 1);
        }
        let feature = titles.iter().find(|t| t.playlist == "FEATURE.EVO").unwrap();
        assert_eq!(feature.extents[0].start_lba, PART_START + 5000);
        assert_eq!(feature.extents[0].sector_count, 2000);
        assert_eq!(
            feature.clips[0].clip_id, "FEATURE",
            "clip_id drops the extension"
        );
    }

    // ── VTI playlist parsing + feature composition ────────────────────────

    /// Build a synthetic `ADVANCED-VTS` VTI whose clip table lists `clips` in
    /// order — one fixed-stride entry each, NUL-terminated name at `entry+0x42`.
    fn synthetic_vti(clips: &[&str]) -> Vec<u8> {
        let table_start = 0x200usize;
        let mut v = vec![0u8; table_start + clips.len() * VTI_CLIP_ENTRY_STRIDE];
        v[..HDDVD_VTI_MAGIC.len()].copy_from_slice(HDDVD_VTI_MAGIC);
        for (i, name) in clips.iter().enumerate() {
            let off = table_start + i * VTI_CLIP_ENTRY_STRIDE + 0x42;
            v[off..off + name.len()].copy_from_slice(name.as_bytes());
            // The byte after the name stays 0 (NUL terminator).
        }
        v
    }

    #[test]
    fn parse_vti_clip_order_reads_table_in_authored_order() {
        let vti = synthetic_vti(&[
            "DELOGO.EVO",
            "FEATURE_1.EVO",
            "FEATURE_2.EVO",
            "TRAILER.EVO",
        ]);
        let order = parse_vti_clip_order(&vti);
        assert_eq!(
            order,
            vec![
                "DELOGO.EVO".to_string(),
                "FEATURE_1.EVO".to_string(),
                "FEATURE_2.EVO".to_string(),
                "TRAILER.EVO".to_string(),
            ]
        );
        // A non-VTI blob yields nothing.
        assert!(parse_vti_clip_order(b"not a vti").is_empty());
    }

    #[test]
    fn parse_vti_clip_order_caps_hits_on_a_crafted_vti() {
        // A crafted VTI packed with far more than MAX_VTI_HITS `.EVO` tokens must
        // not scan/collect them all (a CPU/memory amplification on a routine
        // scan). The result is capped, and parsing stays fast.
        let mut vti = Vec::with_capacity(1_000_000);
        vti.extend_from_slice(HDDVD_VTI_MAGIC);
        // ~160k tokens of the form "X.EVO\0" — well over the 8192 cap.
        for _ in 0..(MAX_VTI_HITS * 20) {
            vti.extend_from_slice(b"X.EVO\0");
        }
        let out = parse_vti_clip_order(&vti);
        assert!(
            out.len() <= MAX_VTI_HITS,
            "collected hits capped at MAX_VTI_HITS, got {}",
            out.len()
        );
    }

    #[test]
    fn parse_vti_clip_order_is_deterministic_on_a_bucket_size_tie() {
        // Two residue buckets of EQUAL size must resolve to the SAME winner every
        // call — `HashMap` iteration is randomized, so a `max_by_key` without a
        // deterministic tie-break could pick a different bucket run-to-run on
        // identical bytes. Build a VTI whose stray `.EVO` names tie the real
        // table's bucket count, then assert the result is stable across calls.
        let mut vti = vec![0u8; 0x600];
        vti[..HDDVD_VTI_MAGIC.len()].copy_from_slice(HDDVD_VTI_MAGIC);
        let put = |v: &mut Vec<u8>, off: usize, name: &str| {
            v[off..off + name.len()].copy_from_slice(name.as_bytes());
        };
        // Bucket A (residue 0x42): two names at stride 0x140.
        put(&mut vti, 0x142, "A1.EVO");
        put(&mut vti, 0x282, "A2.EVO");
        // Bucket B (residue 0x50): two names — same count, different residue.
        put(&mut vti, 0x150, "B1.EVO");
        put(&mut vti, 0x290, "B2.EVO");

        let first = parse_vti_clip_order(&vti);
        for _ in 0..20 {
            assert_eq!(
                parse_vti_clip_order(&vti),
                first,
                "tie-break must be deterministic across repeated calls"
            );
        }
        assert!(!first.is_empty());
    }

    #[test]
    fn is_feature_clip_matches_the_feature_naming_variants() {
        // Layer-break split (Shaun / Anchorman) and the divide form (Harry Potter).
        assert!(is_feature_clip("FEATURE_1.EVO"));
        assert!(is_feature_clip("FEATURE_2.EVO"));
        assert!(is_feature_clip("feature.EVO"));
        assert!(is_feature_clip("feature_Divide.EVO"));
        // Extras are not the feature.
        assert!(!is_feature_clip("TRAILER.EVO"));
        assert!(!is_feature_clip("DLS_01.EVO"));
        assert!(!is_feature_clip("EPK.EVO"));
    }

    #[test]
    fn scan_hddvd_composes_split_feature_into_one_title() {
        // A disc whose feature is FEATURE_1 + FEATURE_2 (a layer-break split), plus
        // a TRAILER extra. The VTI names them in authored order; the scan must
        // JOIN the two feature parts into one title (so the largest-title pick is
        // the whole movie) and keep the trailer as its own title.
        let mut disc = MemDisc::new();
        let vti = synthetic_vti(&["FEATURE_1.EVO", "FEATURE_2.EVO", "TRAILER.EVO"]);
        let files = vec![
            file("FEATURE_1.EVO", 100, 5000, 10 * 2048, true),
            file("FEATURE_2.EVO", 101, 8000, 6 * 2048, true),
            file("TRAILER.EVO", 102, 12000, 2 * 2048, true),
            file_with("HVA00001.VTI", 103, 15000, vti, true),
        ];
        let root = DirSpec {
            name: String::new(),
            icb_lba: 10,
            dir_data_lba: 11,
            files: Vec::new(),
            subdirs: vec![DirSpec {
                name: "HVDVD_TS".to_string(),
                icb_lba: 20,
                dir_data_lba: 21,
                files,
                subdirs: vec![],
            }],
        };
        build_udf_skeleton(&mut disc, 10);
        lay_dir(&mut disc, &root);
        let udf = crate::udf::read_filesystem(&mut disc).expect("fs");

        let titles = Disc::scan_hddvd_titles(&mut disc, &udf);
        // One composed FEATURE title + the trailer.
        assert_eq!(titles.len(), 2, "feature parts merged, trailer separate");
        let feat = titles
            .iter()
            .find(|t| t.playlist == "FEATURE")
            .expect("composed feature title");
        assert_eq!(feat.clips.len(), 2, "both feature parts recorded");
        assert_eq!(feat.size_bytes, (10 + 6) * 2048, "part sizes summed");
        // Extents concatenated in authored order: FEATURE_1 (lba 5000) then
        // FEATURE_2 (lba 8000) — the movie plays through in order.
        assert_eq!(feat.extents.len(), 2);
        assert_eq!(feat.extents[0].start_lba, PART_START + 5000);
        assert_eq!(feat.extents[0].sector_count, 10);
        assert_eq!(feat.extents[1].start_lba, PART_START + 8000);
        assert_eq!(feat.extents[1].sector_count, 6);
        // The largest title is the whole feature, not just part 1.
        let largest = titles.iter().max_by_key(|t| t.size_bytes).unwrap();
        assert_eq!(largest.playlist, "FEATURE");
        assert!(titles.iter().any(|t| t.playlist == "TRAILER.EVO"));
    }

    // ── codec sniffing ────────────────────────────────────────────────────

    #[test]
    fn sniff_video_codec_recognizes_h264_vc1_mpeg2() {
        // H.264 SPS NAL (type 7). 0x67/0x27/0x47 all decode to type 7.
        assert_eq!(
            sniff_video_codec(&[0x00, 0x00, 0x01, 0x67, 0x42, 0x00, 0x1E]),
            Some(Codec::H264)
        );
        assert_eq!(
            sniff_video_codec(&[0x11, 0x00, 0x00, 0x01, 0x27, 0x64]),
            Some(Codec::H264)
        );
        // VC-1 sequence-header BDU (0x0F).
        assert_eq!(
            sniff_video_codec(&[0x00, 0x00, 0x01, 0x0F, 0xC0]),
            Some(Codec::Vc1)
        );
        // MPEG-2 sequence_header (0xB3).
        assert_eq!(
            sniff_video_codec(&[0x00, 0x00, 0x01, 0xB3, 0x2D]),
            Some(Codec::Mpeg2)
        );
        // A slice/picture-only sample (no SPS/sequence) is indeterminate.
        assert_eq!(sniff_video_codec(&[0x00, 0x00, 0x01, 0x61, 0x9A]), None);
        assert_eq!(sniff_video_codec(&[0xDE, 0xAD, 0xBE, 0xEF]), None);

        // Overlap regression: a picture_start_code (0x00) whose payload begins
        // with 00 00 must advance a full 4 bytes so the code byte isn't re-read
        // as the start of a new marker. Here the picture is followed by a real
        // MPEG-2 sequence header — the scan must reach it cleanly and return
        // Mpeg2 (and, critically, not be confused by the 1-byte overlap).
        assert_eq!(
            sniff_video_codec(&[0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0xB3, 0x2D]),
            Some(Codec::Mpeg2)
        );
        // A lone picture_start_code with a 00-heavy payload and no following real
        // start code stays indeterminate (the overlap must not fabricate one).
        assert_eq!(
            sniff_video_codec(&[0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00]),
            None
        );
    }

    #[test]
    fn sniff_audio_codec_recognizes_eac3_syncword() {
        assert_eq!(
            sniff_audio_codec(&[0x00, 0x0B, 0x77, 0x12, 0x34]),
            Some(Codec::Ac3Plus)
        );
        assert_eq!(sniff_audio_codec(&[0x00, 0x01, 0x02, 0x03]), None);
    }

    // ── EVO head probe → streams ──────────────────────────────────────────

    /// A minimal bounded PES: `00 00 01 [id] [len:2] 80 00 00 [payload]`.
    fn pes(stream_id: u8, payload: &[u8]) -> Vec<u8> {
        let mut v = vec![0x00, 0x00, 0x01, stream_id];
        let len = (3 + payload.len()) as u16; // flags1+flags2+hdl + payload
        v.extend_from_slice(&len.to_be_bytes());
        v.extend_from_slice(&[0x80, 0x00, 0x00]);
        v.extend_from_slice(payload);
        v
    }

    /// Synthetic EVO program-stream: pack header, a video PES (H.264 SPS+IDR on
    /// stream_id 0xE2, exactly as ANCHORMAN carries it), two DD+ audio PES
    /// (sub-ids 0xC0/0xC1, each with the 4-byte sub-header + E-AC-3 syncword),
    /// then program-end.
    fn synthetic_evo() -> Vec<u8> {
        let mut d = Vec::new();
        // MPEG-2 pack header (14 bytes, stuffing 0).
        d.extend_from_slice(&[
            0x00, 0x00, 0x01, 0xBA, 0x44, 0x00, 0x04, 0x00, 0x04, 0x01, 0x01, 0x89, 0xC3, 0xF8,
        ]);
        // Video PES on stream_id 0xE2 (Anchorman's H.264 sub-id in the 0xE0-0xEF
        // range): SPS (type 7) + IDR (type 5) Annex-B.
        let video_es = [
            0x00, 0x00, 0x01, 0x67, 0x42, 0x00, 0x1E, 0xAB, 0xCD, // SPS
            0x00, 0x00, 0x01, 0x65, 0x88, 0x00, // IDR slice
        ];
        d.extend_from_slice(&pes(0xE2, &video_es));
        // DD+ audio PES: sub-id + 4-byte sub-header (num_frames + ptr) folded in
        // — the demuxer strips 4 bytes, leaving the E-AC-3 syncword.
        for sub in [0xC0u8, 0xC1] {
            let audio_payload = [
                sub, 0x01, 0x00, 0x00, // sub-id + num_frames(1) + ptr(2)
                0x0B, 0x77, 0xDE, 0xAD, // E-AC-3 syncword + body
            ];
            d.extend_from_slice(&pes(0xBD, &audio_payload));
        }
        d.extend_from_slice(&[0x00, 0x00, 0x01, 0xB9]); // program end
        d
    }

    /// Build a UDF whose `HVDVD_TS/FEATURE.EVO` holds the given raw bytes.
    fn make_hddvd_fs_with_evo(disc: &mut MemDisc, evo: &[u8]) -> crate::udf::UdfFs {
        let root = DirSpec {
            name: String::new(),
            icb_lba: 10,
            dir_data_lba: 11,
            files: Vec::new(),
            subdirs: vec![DirSpec {
                name: "HVDVD_TS".to_string(),
                icb_lba: 20,
                dir_data_lba: 21,
                files: vec![file_with("FEATURE.EVO", 100, 5000, evo.to_vec(), true)],
                subdirs: vec![],
            }],
        };
        build_udf_skeleton(disc, 10);
        lay_dir(disc, &root);
        crate::udf::read_filesystem(disc).expect("fs")
    }

    /// End-to-end: scanning an `.evo` whose head carries an H.264 video PES and
    /// two DD+ audio PES yields a title with the video track (canonical
    /// DVD_VIDEO_PID) and both DD+ tracks (0xBDC0 / 0xBDC1) — the non-empty
    /// `streams` the mux path needs to route packets (the historical blocker).
    #[test]
    fn scan_hddvd_titles_probes_streams_from_evo_head() {
        let mut disc = MemDisc::new();
        let udf = make_hddvd_fs_with_evo(&mut disc, &synthetic_evo());
        let titles = Disc::scan_hddvd_titles(&mut disc, &udf);
        assert_eq!(titles.len(), 1);
        let t = &titles[0];

        let video: Vec<_> = t
            .streams
            .iter()
            .filter_map(|s| match s {
                Stream::Video(v) => Some(v),
                _ => None,
            })
            .collect();
        assert_eq!(video.len(), 1, "one video track probed");
        assert_eq!(video[0].codec, Codec::H264, "SPS sniffed as H.264");
        assert_eq!(
            video[0].pid,
            crate::mux::ps::DVD_VIDEO_PID,
            "video routes to canonical PID"
        );

        let audio: Vec<_> = t
            .streams
            .iter()
            .filter_map(|s| match s {
                Stream::Audio(a) => Some(a),
                _ => None,
            })
            .collect();
        assert_eq!(audio.len(), 2, "both DD+ sub-streams probed");
        assert!(audio.iter().all(|a| a.codec == Codec::Ac3Plus));
        let pids: Vec<u16> = audio.iter().map(|a| a.pid).collect();
        assert_eq!(pids, vec![0xBDC0, 0xBDC1], "DD+ PIDs 0xBDC0/0xBDC1");
    }

    /// A clip whose head carries no recognizable stream (unreadable /
    /// ciphertext) leaves `streams` empty rather than fabricating one — the
    /// title still enumerates (extents are real).
    #[test]
    fn scan_hddvd_titles_empty_streams_when_head_unrecognized() {
        let mut disc = MemDisc::new();
        // 4 KiB of junk with no PS start codes.
        let junk = vec![0x55u8; 4096];
        let udf = make_hddvd_fs_with_evo(&mut disc, &junk);
        let titles = Disc::scan_hddvd_titles(&mut disc, &udf);
        assert_eq!(titles.len(), 1);
        assert!(
            titles[0].streams.is_empty(),
            "no recognizable stream → empty, not fabricated"
        );
    }

    /// A PES on the HD-DVD extended-stream-id (0xFD) carrying the given
    /// `stream_id_extension` in a minimal PES extension: flags1=0x80, flags2=0x01
    /// (PES_extension only), header_data_length=3, optional bytes
    /// `[ext_flags=0x01][field_len=0x81][ext]` — exactly the shape SHAUN's VC-1
    /// video PES uses (ext 0x55).
    fn pes_extended(stream_id_extension: u8, payload: &[u8]) -> Vec<u8> {
        let mut v = vec![0x00, 0x00, 0x01, 0xFD];
        let opt = [0x01u8, 0x81, stream_id_extension];
        let len = (3 + opt.len() + payload.len()) as u16; // flags1+flags2+hdl + opt + payload
        v.extend_from_slice(&len.to_be_bytes());
        v.extend_from_slice(&[0x80, 0x01, opt.len() as u8]);
        v.extend_from_slice(&opt);
        v.extend_from_slice(payload);
        v
    }

    /// Synthetic EVO carrying VC-1 video on the extended-stream-id 0xFD (ext
    /// 0x55), as SHAUN OF THE DEAD does, plus one DD+ audio PES.
    fn synthetic_evo_vc1() -> Vec<u8> {
        let mut d = Vec::new();
        d.extend_from_slice(&[
            0x00, 0x00, 0x01, 0xBA, 0x44, 0x00, 0x04, 0x00, 0x04, 0x01, 0x01, 0x89, 0xC3, 0xF8,
        ]);
        // VC-1 sequence header (00 00 01 0F) + a frame BDU (00 00 01 0D).
        let video_es = [
            0x00, 0x00, 0x01, 0x0F, 0xC5, 0x00, 0x00, // sequence header BDU
            0x00, 0x00, 0x01, 0x0D, 0x12, 0x34, // frame BDU
        ];
        d.extend_from_slice(&pes_extended(0x55, &video_es));
        let audio_payload = [0xC0u8, 0x01, 0x00, 0x00, 0x0B, 0x77, 0xDE, 0xAD];
        d.extend_from_slice(&pes(0xBD, &audio_payload));
        d.extend_from_slice(&[0x00, 0x00, 0x01, 0xB9]);
        d
    }

    /// Build a bare PsPacket for the collect_es routing test.
    fn ps_pkt(stream_id: u8, sub: Option<u8>, data: Vec<u8>) -> crate::mux::ps::PsPacket {
        crate::mux::ps::PsPacket {
            stream_id,
            sub_stream_id: sub,
            pts: None,
            dts: None,
            data,
            source: None,
        }
    }

    #[test]
    fn collect_es_routes_only_vc1_0xfd_to_video() {
        use crate::mux::ps::hddvd_extended_pid;
        // The 0xFD guard: only the VC-1 extension (0x55) is video. An HD-audio
        // 0xFD sub-stream (e.g. 0x72) that arrives FIRST must NOT stamp video_pid
        // with its PID or pollute the video sample — else the real video track is
        // lost. (Routing 0xFD audio to its own track is deferred.)
        let mut video = Vec::new();
        let mut video_pid: Option<u16> = None;
        let mut audio = BTreeMap::new();
        // Audio-on-0xFD (ext 0x72) first — must be ignored by the video path.
        collect_es(
            &ps_pkt(0xFD, Some(0x72), vec![0xAA; 32]),
            &mut video,
            &mut video_pid,
            &mut audio,
        );
        assert!(
            video.is_empty(),
            "0xFD audio sub-stream not routed to video"
        );
        assert_eq!(video_pid, None, "0xFD audio did not stamp the video PID");
        // Then the real VC-1 video (ext 0x55).
        collect_es(
            &ps_pkt(0xFD, Some(0x55), vec![0xBB; 32]),
            &mut video,
            &mut video_pid,
            &mut audio,
        );
        assert_eq!(
            video_pid,
            Some(hddvd_extended_pid(0x55)),
            "video PID stamped from the VC-1 0xFD sub-stream (0xFD55)"
        );
        assert_eq!(video.len(), 32, "VC-1 0xFD payload accumulated as video");
    }

    /// End-to-end: an `.evo` whose video rides the extended-stream-id 0xFD yields
    /// a VC-1 video track routed to `0xFD00 | ext` (0xFD55) — the PID the demuxer
    /// derives from the same stream_id_extension, so mux-time routing lines up.
    #[test]
    fn scan_hddvd_titles_probes_vc1_on_extended_stream_id() {
        let mut disc = MemDisc::new();
        let udf = make_hddvd_fs_with_evo(&mut disc, &synthetic_evo_vc1());
        let titles = Disc::scan_hddvd_titles(&mut disc, &udf);
        assert_eq!(titles.len(), 1);

        let video: Vec<_> = titles[0]
            .streams
            .iter()
            .filter_map(|s| match s {
                Stream::Video(v) => Some(v),
                _ => None,
            })
            .collect();
        assert_eq!(video.len(), 1, "one video track probed");
        assert_eq!(video[0].codec, Codec::Vc1, "VC-1 sequence header sniffed");
        assert_eq!(
            video[0].pid,
            crate::mux::ps::hddvd_extended_pid(0x55),
            "VC-1 routes to the extended-stream-id PID 0xFD55"
        );
    }

    // ─────────────────────── Advanced-Content playlist (XPL) ──────────────

    /// A minimal but faithful VPLST000.XPL: the DVD-Forum default namespace, an
    /// XML comment, a `<TitleSet tickBase="60fps">`, a MainMovie title whose main
    /// feature is a two-clip layer-break split (FEATURE_1 + FEATURE_2, seamless)
    /// with two chapters, and a separate deleted-scene title. Attribute order
    /// varies (as it does across real discs).
    const SYNTH_XPL: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<Playlist majorVersion="1" minorVersion="0" xmlns="http://www.dvdforum.org/2005/HDDVDVideo/Playlist">
  <!-- Authored with TOSHIBA AdvMain -->
  <TitleSet timeBase="60fps" tickBase="60fps" defaultLanguage="en">
    <Title titleNumber="2" titleDuration="01:37:20:00" id="MainMovie" displayName="Main Movie">
      <PrimaryAudioVideoClip titleTimeBegin="00:00:00:00" titleTimeEnd="00:48:29:50" src="file:///dvddisc/HVDVD_TS/FEATURE_1.MAP" dataSource="Disc">
        <Video track="1" mediaAttr="2"/>
        <Audio track="1" streamNumber="1" description="English DD+"/>
      </PrimaryAudioVideoClip>
      <PrimaryAudioVideoClip titleTimeBegin="00:48:29:50" clipTimeBegin="00:00:00:00" titleTimeEnd="01:37:20:00" src="file:///dvddisc/HVDVD_TS/FEATURE_2.MAP" seamless="true">
        <Video track="1" mediaAttr="2"/>
      </PrimaryAudioVideoClip>
      <ChapterList>
        <Chapter displayName="Chapter  1" titleTimeBegin="00:00:00:00" />
        <Chapter displayName="Chapter  2" titleTimeBegin="00:03:40:30" />
      </ChapterList>
    </Title>
    <Title titleNumber="7" titleDuration="00:00:44:29" id="Deleted5" displayName="Deleted Scenes - Veronica Past">
      <PrimaryAudioVideoClip titleTimeBegin="00:00:00:00" titleTimeEnd="00:00:44:29" src="file:///dvddisc/HVDVD_TS/DEL5_VERONICAPAST.MAP" />
    </Title>
  </TitleSet>
</Playlist>"#;

    #[test]
    fn parse_timecode_hhmmssff_at_60fps() {
        // 01:37:20:00 = 5840 s exactly.
        assert!((parse_timecode("01:37:20:00", 60).unwrap() - 5840.0).abs() < 1e-6);
        // 00:48:29:50 = 48m29s + 50/60 frames.
        let want = 48.0 * 60.0 + 29.0 + 50.0 / 60.0;
        assert!((parse_timecode("00:48:29:50", 60).unwrap() - want).abs() < 1e-6);
        // MM:SS:FF short form (hours omitted).
        assert!((parse_timecode("02:05:15", 60).unwrap() - (125.0 + 15.0 / 60.0)).abs() < 1e-6);
        assert_eq!(parse_timecode("garbage", 60), None);
        assert_eq!(parse_timecode("", 60), None);
    }

    #[test]
    fn evo_from_src_maps_map_sidecar_to_evo() {
        assert_eq!(
            evo_from_src("file:///dvddisc/HVDVD_TS/FEATURE_1.MAP").as_deref(),
            Some("feature_1.evo")
        );
        // Already an EVO, or lowercase feature — normalise to lower `.evo`.
        assert_eq!(evo_from_src("feature.EVO").as_deref(), Some("feature.evo"));
        assert_eq!(
            evo_from_src("file:///x/feature_Divide.MAP").as_deref(),
            Some("feature_divide.evo")
        );
        // Empty stem → None (defensive against a malformed src).
        assert_eq!(evo_from_src("file:///x/").as_deref(), None);
    }

    #[test]
    fn parse_xpl_titles_reads_titles_clips_chapters_durations() {
        let titles = parse_xpl_titles(SYNTH_XPL.as_bytes());
        assert_eq!(titles.len(), 2, "MainMovie + one deleted-scene title");

        let mm = &titles[0];
        assert_eq!(mm.number, 2);
        assert_eq!(mm.name, "Main Movie");
        assert!(
            (mm.duration_secs - 5840.0).abs() < 1e-6,
            "97:20 from titleDuration"
        );
        // The layer-break split is ONE title with TWO clips, contiguous timeline.
        assert_eq!(mm.clips.len(), 2);
        assert_eq!(mm.clips[0].evo, "feature_1.evo");
        assert_eq!(mm.clips[1].evo, "feature_2.evo");
        assert!(
            (mm.clips[0].end_secs - mm.clips[1].begin_secs).abs() < 1e-6,
            "FEATURE_2 begins exactly where FEATURE_1 ends (seamless join)"
        );
        assert!(
            mm.clips[1].begin_secs > 0.0,
            "second clip carries a title-time offset"
        );
        assert_eq!(mm.chapters.len(), 2);
        assert!((mm.chapters[1] - (3.0 * 60.0 + 40.0 + 30.0 / 60.0)).abs() < 1e-6);

        let del = &titles[1];
        assert_eq!(del.name, "Deleted Scenes - Veronica Past");
        assert_eq!(del.clips.len(), 1);
        assert_eq!(del.clips[0].evo, "del5_veronicapast.evo");
    }

    #[test]
    fn parse_xpl_titles_returns_empty_on_non_xml() {
        assert!(parse_xpl_titles(b"not xml at all").is_empty());
        assert!(parse_xpl_titles(&[0xFF, 0x00, 0x01, 0x02]).is_empty());
        assert!(parse_xpl_titles(b"<Playlist></Playlist>").is_empty());
    }

    /// Build a UDF with `HVDVD_TS/` `.evo` clips plus an `ADV_OBJ/VPLST000.XPL`
    /// carrying `xpl`, so `scan_hddvd_titles` takes the playlist path.
    fn make_hddvd_fs_xpl(
        disc: &mut MemDisc,
        evos: &[(&str, u32, u32)],
        xpl: &[u8],
    ) -> crate::udf::UdfFs {
        let mut hv_files = Vec::new();
        let mut icb = 100u32;
        for (name, sectors, data_lba) in evos {
            hv_files.push(file(name, icb, *data_lba, sectors * 2048, true));
            icb += 1;
        }
        let root = DirSpec {
            name: String::new(),
            icb_lba: 10,
            dir_data_lba: 11,
            files: Vec::new(),
            subdirs: vec![
                DirSpec {
                    name: "HVDVD_TS".to_string(),
                    icb_lba: 20,
                    dir_data_lba: 21,
                    files: hv_files,
                    subdirs: vec![],
                },
                DirSpec {
                    name: "ADV_OBJ".to_string(),
                    icb_lba: 30,
                    dir_data_lba: 31,
                    files: vec![file_with("VPLST000.XPL", 40, 4000, xpl.to_vec(), true)],
                    subdirs: vec![],
                },
            ],
        };
        build_udf_skeleton(disc, 10);
        lay_dir(disc, &root);
        crate::udf::read_filesystem(disc).expect("fs")
    }

    /// The authoritative path: when a VPLST000.XPL is present, titles come from
    /// the playlist — the layer-break split (FEATURE_1 + FEATURE_2) is composed
    /// into ONE title with the real duration, name, chapters, and per-clip
    /// title-time offsets — not the clip-name heuristic.
    #[test]
    fn scan_hddvd_composes_titles_from_xpl_playlist() {
        let mut disc = MemDisc::new();
        let udf = make_hddvd_fs_xpl(
            &mut disc,
            &[
                ("FEATURE_1.EVO", 2000, 5000),
                ("FEATURE_2.EVO", 1800, 9000),
                ("DEL5_VERONICAPAST.EVO", 100, 12000),
            ],
            SYNTH_XPL.as_bytes(),
        );
        let titles = Disc::scan_hddvd_titles(&mut disc, &udf);
        assert_eq!(
            titles.len(),
            2,
            "the two playlist titles that resolve to clips"
        );

        let mm = titles
            .iter()
            .find(|t| t.playlist == "Main Movie")
            .expect("MainMovie composed from the playlist");
        assert_eq!(mm.playlist_id, 2);
        assert!(
            (mm.duration_secs - 5840.0).abs() < 1.0,
            "97:20 duration from titleDuration, not 0/unknown"
        );
        assert_eq!(
            mm.clips.len(),
            2,
            "layer-break split kept as ONE title, two clips"
        );
        // FEATURE_2's clip carries the 48:29 offset (45 kHz ticks) — the datum
        // that splices it onto FEATURE_1's timeline instead of restarting at 0.
        assert!(
            mm.clips[1].in_time > 100_000_000,
            "FEATURE_2 offset onto the title timeline (48:29 * 45000), got {}",
            mm.clips[1].in_time
        );
        assert_eq!(mm.chapters.len(), 2);
        assert_eq!(mm.chapters[0].name, "1", "bare ordinal chapter name");
        // Both feature halves are in ONE title's extents.
        assert!(!mm.extents.is_empty());
    }
}
