//! The cue tags of an HLS Media Playlist (extensions/channel-lineup.md §7.4, §8.4).
//!
//! A packager that writes a channel's segments, MPEG-2 transport stream segments among them,
//! marks each break of the receipt's schedule in the playlist:
//! `EXT-X-CUE-OUT` with the break's duration, `EXT-X-CUE-OUT-CONT` on each later segment of the
//! break and `EXT-X-CUE-IN` on its return; and, for a break whose cue carries its SCTE-35
//! section, the `EXT-X-DATERANGE` tags of RFC 8216 §4.3.2.7.1. [`render`] writes a window's
//! playlist from the breaks [`bind_cues`] reads from a receipt and its cues; [`verify_playlist`]
//! reads a playlist's window back, renders it again and compares.
//!
//! Pure functions of the bytes they are given: no I/O, no clock and no floating point.

use serde_json::{json, Value};

use super::import::{break_cue, section_reads, Cue};
use super::{duration_ms, hex, hex_bytes, hls_date_time, read_receipt, receipt_verdict, xmltv_format, Reason, MAX_INSTANT};
use crate::strict_json::{self, Json};
use crate::wai_video_channel::{ChannelReceipt, EntryKind};

/// `EXT-X-VERSION`: a decimal `EXTINF` duration needs version 3 (RFC 8216 §7).
pub const VERSION: u8 = 3;
/// The most bytes of a segment's URI (§7.4).
pub const MAX_URI: usize = 2048;
/// The longest segment, in milliseconds (§7.4).
pub const MAX_SEGMENT_MS: u64 = u32::MAX as u64;
/// The instant a window ends before: 10000-01-01T00:00:00Z. A break in the window starts at the
/// latest where the window ends, so every date it writes has four digits of year (§7.4).
pub const WINDOW_ENDS_BEFORE: u64 = 253_402_300_800_000;

/// A break of the receipt's schedule (§7.4): an `ad` entry of a duration above 0, with the
/// section its cue carries, if any.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Break {
    pub id: String,
    pub start_ms: u64,
    pub duration_ms: u64,
    pub section: Option<Vec<u8>>,
}

impl Break {
    pub fn end_ms(&self) -> u64 {
        self.start_ms + self.duration_ms
    }

    /// The `ID` of its date range: its id, `@` and its start in decimal (§7.4).
    pub fn date_range_id(&self) -> String {
        format!("{}@{}", self.id, self.start_ms)
    }

    /// Whether a quoted-string can hold its date range's `ID` (RFC 8216 §4.2).
    fn writable(&self) -> bool {
        !self.id.contains(['"', '\n', '\r'])
    }
}

/// A segment of a window: its URI and its duration in milliseconds.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Segment {
    pub uri: String,
    pub duration_ms: u64,
}

/// What a Media Playlist lists (§7.4): its media sequence number, the instant its first segment
/// begins, its segments in order, and whether it is ended.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Window {
    pub media_sequence: u64,
    pub start_ms: u64,
    pub segments: Vec<Segment>,
    pub ended: bool,
}

impl Window {
    /// Each segment's start and end.
    fn spans(&self) -> Vec<(u64, u64)> {
        let mut at = self.start_ms;
        self.segments
            .iter()
            .map(|s| {
                let span = (at, at.saturating_add(s.duration_ms));
                at = span.1;
                span
            })
            .collect()
    }

    /// Whether it holds to §7.4's bounds.
    pub fn holds(&self) -> bool {
        let uri = |u: &str| (1..=MAX_URI).contains(&u.len()) && u.bytes().all(|c| (0x21..=0x7e).contains(&c)) && !u.starts_with('#');
        self.media_sequence <= MAX_INSTANT
            && !self.segments.is_empty()
            && self.segments.iter().all(|s| uri(&s.uri) && (1..=MAX_SEGMENT_MS).contains(&s.duration_ms))
            && self.segments.iter().try_fold(self.start_ms, |at, s| at.checked_add(s.duration_ms)).is_some_and(|end| end < WINDOW_ENDS_BEFORE)
    }
}

/// The receipt's breaks (§7.4): its `ad` entries of a duration above 0, in schedule order, each
/// its id, its start and its duration.
pub fn schedule_breaks(r: &ChannelReceipt) -> Vec<(String, u64, u64)> {
    r.schedule.iter().filter(|e| e.kind == EntryKind::Ad && e.duration_ms > 0).map(|e| (e.asset_id.clone(), e.start_ms, e.duration_ms)).collect()
}

/// Read a cues document (§8.4 step 4): one JSON text, an array of objects of exactly `break` (a
/// string), `start_ms` (an integer of at most 2^53 − 1) and `payload` (an even count of lowercase
/// hexadecimal digits), as `channel.import` writes `cues.json`.
pub fn read_cues(b: &[u8]) -> Option<Vec<Cue>> {
    let j = strict_json::read(b).ok()?;
    let mut out = Vec::new();
    for c in j.as_arr()? {
        let m = c.as_obj()?;
        if m.len() != 3 || !["break", "start_ms", "payload"].iter().all(|k| m.iter().any(|(n, _)| n == k)) {
            return None;
        }
        let start_ms = c.get("start_ms").and_then(Json::as_u64).filter(|&t| t <= MAX_INSTANT)?;
        out.push(Cue { break_id: c.get("break")?.as_str()?.to_string(), start_ms, payload: hex_bytes(c.get("payload")?.as_str()?)? });
    }
    Some(out)
}

/// The section a cue's payload carries: `Some(Some(_))` for one of form 1 that reads as §4.5 step
/// 3 reads a section, `Some(None)` for form 0, `None` otherwise.
fn payload_section(p: &[u8]) -> Option<Option<&[u8]>> {
    match p.get(9)? {
        0 => Some(None),
        1 => {
            let n = u16::from_le_bytes([*p.get(10)?, *p.get(11)?]) as usize;
            let s = p.get(12..12 + n)?;
            section_reads(s).then_some(Some(s))
        }
        _ => None,
    }
}

/// §8.4 step 5: the breaks of the receipt, each with the section its cue carries; or the first
/// index whose cue is not its break's, a missing or an extra cue at the first index past the
/// shorter list.
pub fn bind_cues(r: &ChannelReceipt, cues: &[Cue]) -> Result<Vec<Break>, usize> {
    let breaks = schedule_breaks(r);
    let mut out = Vec::with_capacity(breaks.len());
    for i in 0..breaks.len().max(cues.len()) {
        let (Some((id, start, duration)), Some(c)) = (breaks.get(i), cues.get(i)) else { return Err(i) };
        if c.break_id != *id || c.start_ms != *start || *duration > MAX_SEGMENT_MS {
            return Err(i);
        }
        let Some(section) = payload_section(&c.payload) else { return Err(i) };
        if break_cue(id, *duration, section) != c.payload {
            return Err(i);
        }
        out.push(Break { id: id.clone(), start_ms: *start, duration_ms: *duration, section: section.map(<[u8]>::to_vec) });
    }
    Ok(out)
}

/// A duration as a span (§7.4): seconds with three digits after the point.
pub fn span(ms: u64) -> String {
    format!("{}.{:03}", ms / 1000, ms % 1000)
}

/// An instant as a date (§7.4): `YYYY-MM-DDTHH:MM:SS.mmmZ`, in UTC.
pub fn date(ms: u64) -> String {
    let s = xmltv_format(ms);
    format!("{}-{}-{}T{}:{}:{}.{:03}Z", &s[0..4], &s[4..6], &s[6..8], &s[8..10], &s[10..12], &s[12..14], ms % 1000)
}

/// Why a window's tags cannot be written (§7.4), with the index §8.4 states: the segment's for
/// `cue_unaligned`, the break's for `cue_unwritable`, none for `playlist_malformed`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Unwritten {
    pub reason: Reason,
    pub index: Option<usize>,
}

/// The first segment of the window that puts a break in it (§7.4): its last segment before the
/// break, a segment that starts in it, or its return.
fn first_in(b: &Break, spans: &[(u64, u64)]) -> Option<usize> {
    spans.iter().position(|&(s, e)| e == b.start_ms || (b.start_ms <= s && s < b.end_ms()) || s == b.end_ms())
}

/// The breaks that are in the window, as `(index among the breaks, break)`.
pub fn in_window<'a>(breaks: &'a [Break], w: &Window) -> Vec<(usize, &'a Break)> {
    let spans = w.spans();
    breaks.iter().enumerate().filter(|(_, b)| first_in(b, &spans).is_some()).collect()
}

/// The Media Playlist of a window, its breaks marked (§7.4).
pub fn render(breaks: &[Break], w: &Window) -> Result<String, Unwritten> {
    if !w.holds() {
        return Err(Unwritten { reason: Reason::PlaylistMalformed, index: None });
    }
    let spans = w.spans();
    let inside = |x: u64, (s, e): (u64, u64)| s < x && x < e;
    if let Some(k) = spans.iter().position(|&sp| breaks.iter().any(|b| inside(b.start_ms, sp) || inside(b.end_ms(), sp))) {
        return Err(Unwritten { reason: Reason::CueUnaligned, index: Some(k) });
    }
    if let Some((i, _)) = in_window(breaks, w).into_iter().find(|(_, b)| b.section.is_some() && !b.writable()) {
        return Err(Unwritten { reason: Reason::CueUnwritable, index: Some(i) });
    }
    let target = w.segments.iter().map(|s| s.duration_ms.div_ceil(1000)).max().expect("a segment");
    let mut o = format!(
        "#EXTM3U\n#EXT-X-VERSION:{VERSION}\n#EXT-X-TARGETDURATION:{target}\n#EXT-X-MEDIA-SEQUENCE:{}\n#EXT-X-PROGRAM-DATE-TIME:{}\n",
        w.media_sequence,
        date(w.start_ms)
    );
    for (k, (seg, &(s, _))) in w.segments.iter().zip(&spans).enumerate() {
        for b in breaks {
            let Some(section) = &b.section else { continue };
            let id = b.date_range_id();
            if first_in(b, &spans) == Some(k) {
                o.push_str(&format!(
                    "#EXT-X-DATERANGE:ID=\"{id}\",START-DATE=\"{}\",PLANNED-DURATION={},SCTE35-OUT=0x{}\n",
                    date(b.start_ms),
                    span(b.duration_ms),
                    hex(section).to_uppercase()
                ));
            }
            if s == b.end_ms() {
                o.push_str(&format!("#EXT-X-DATERANGE:ID=\"{id}\",START-DATE=\"{}\",DURATION={}\n", date(b.start_ms), span(b.duration_ms)));
            }
        }
        if breaks.iter().any(|b| b.end_ms() == s) {
            o.push_str("#EXT-X-CUE-IN\n");
        }
        if let Some(b) = breaks.iter().find(|b| b.start_ms == s) {
            o.push_str(&format!("#EXT-X-CUE-OUT:{}\n", span(b.duration_ms)));
        } else if let Some(b) = breaks.iter().find(|b| b.start_ms < s && s < b.end_ms()) {
            o.push_str(&format!("#EXT-X-CUE-OUT-CONT:ElapsedTime={},Duration={}\n", span(s - b.start_ms), span(b.duration_ms)));
        }
        o.push_str(&format!("#EXTINF:{},\n{}\n", span(seg.duration_ms), seg.uri));
    }
    if w.ended {
        o.push_str("#EXT-X-ENDLIST\n");
    }
    Ok(o)
}

/// A decimal integer with no leading zero, at most 2^53 − 1.
fn plain(s: &str) -> Option<u64> {
    (!s.is_empty() && s.bytes().all(|c| c.is_ascii_digit()) && (s == "0" || !s.starts_with('0'))).then(|| s.parse().ok()).flatten().filter(|&n| n <= MAX_INSTANT)
}

/// Read a Media Playlist's window (§8.4 step 6), or `None`: `playlist_malformed`.
pub fn read_playlist(b: &[u8]) -> Option<Window> {
    let lines: Vec<&str> = std::str::from_utf8(b).ok()?.strip_suffix('\n')?.split('\n').collect();
    if lines.first() != Some(&"#EXTM3U") {
        return None;
    }
    let (mut sequence, mut first, mut ended, mut segments) = (None, None, false, Vec::new());
    let mut i = 1;
    while i < lines.len() {
        let l = lines[i];
        if let Some(v) = l.strip_prefix("#EXT-X-MEDIA-SEQUENCE:") {
            if sequence.replace(plain(v)?).is_some() {
                return None;
            }
        } else if let Some(v) = l.strip_prefix("#EXT-X-PROGRAM-DATE-TIME:") {
            if first.is_none() && segments.is_empty() {
                first = Some(hls_date_time(v).ok()?);
            }
        } else if l == "#EXT-X-ENDLIST" {
            ended = true;
        } else if let Some(v) = l.strip_prefix("#EXTINF:") {
            let d = duration_ms(v.strip_suffix(',')?).ok()?;
            let uri = lines.get(i + 1)?;
            segments.push(Segment { uri: uri.to_string(), duration_ms: d });
            i += 1;
        }
        i += 1;
    }
    let w = Window { media_sequence: sequence?, start_ms: first?, segments, ended };
    w.holds().then_some(w)
}

/// A break a window holds, as a playlist's verification states it (§8.4).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Marked {
    pub id: String,
    pub start_ms: u64,
    pub duration_ms: u64,
    /// Whether it has a date range: whether its cue carries a section.
    pub date_range: bool,
}

/// What a playlist's verification states (§8.4).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PlaylistCheck {
    /// `Ok` when verified, otherwise the reason.
    pub verdict: Result<(), Reason>,
    /// `receipt`, `cues` or `playlist`.
    pub part: Option<&'static str>,
    /// The receipt's identity, when it reads.
    pub receipt: Option<[u8; 32]>,
    /// The window, when the playlist reads, and the breaks in it.
    pub window: Option<(Window, Vec<Marked>)>,
    /// For `cues_mismatch` the cue's index, for `cue_unaligned` the segment's, for
    /// `cue_unwritable` the break's.
    pub index: Option<usize>,
}

/// Verify a playlist's cue tags against a receipt and its cues (§8.4).
pub fn verify_playlist(receipt: &[u8], cues: &[u8], playlist: &[u8]) -> PlaylistCheck {
    let mut v = PlaylistCheck { verdict: Ok(()), part: None, receipt: None, window: None, index: None };
    let fail = |mut v: PlaylistCheck, part: &'static str, r: Reason, index: Option<usize>| {
        v.verdict = Err(r);
        v.part = Some(part);
        v.index = index;
        v
    };
    let Some(r) = read_receipt(receipt) else { return fail(v, "receipt", Reason::ChannelReceiptMalformed, None) };
    v.receipt = Some(r.receipt_hash());
    if let Err(e) = receipt_verdict(&r) {
        return fail(v, "receipt", e, None);
    }
    let Some(cues) = read_cues(cues) else { return fail(v, "cues", Reason::CuesMalformed, None) };
    let breaks = match bind_cues(&r, &cues) {
        Ok(b) => b,
        Err(i) => return fail(v, "cues", Reason::CuesMismatch, Some(i)),
    };
    let Some(w) = read_playlist(playlist) else { return fail(v, "playlist", Reason::PlaylistMalformed, None) };
    let marked = in_window(&breaks, &w).into_iter().map(|(_, b)| Marked { id: b.id.clone(), start_ms: b.start_ms, duration_ms: b.duration_ms, date_range: b.section.is_some() }).collect();
    v.window = Some((w.clone(), marked));
    match render(&breaks, &w) {
        Err(u) => fail(v, "playlist", u.reason, u.index),
        Ok(text) if text.as_bytes() != playlist => fail(v, "playlist", Reason::PlaylistMismatch, None),
        Ok(_) => v,
    }
}

/// A window as the corpus states it: `media_sequence`, `start_ms`, `segments` (each `uri` and
/// `duration_ms`) and `ended`.
pub fn window_json(w: &Window) -> Value {
    json!({
        "media_sequence": w.media_sequence,
        "start_ms": w.start_ms,
        "segments": w.segments.iter().map(|s| json!({"uri": s.uri, "duration_ms": s.duration_ms})).collect::<Vec<_>>(),
        "ended": w.ended,
    })
}

/// A window from [`window_json`]'s form, or `None`.
pub fn window_of(v: &Value) -> Option<Window> {
    let segments = v["segments"].as_array()?.iter().map(|s| Some(Segment { uri: s["uri"].as_str()?.to_string(), duration_ms: s["duration_ms"].as_u64()? })).collect::<Option<Vec<_>>>()?;
    Some(Window { media_sequence: v["media_sequence"].as_u64()?, start_ms: v["start_ms"].as_u64()?, segments, ended: v["ended"].as_bool()? })
}

/// A playlist's verification as JSON (§8.4; the corpus's `facts`).
pub fn playlist_check_json(v: &PlaylistCheck) -> Value {
    json!({
        "verdict": v.verdict.err().map_or("verified", Reason::code),
        "part": v.part,
        "receipt": v.receipt.map(|h| hex(&h)),
        "window": v.window.as_ref().map(|(w, marked)| json!({
            "media_sequence": w.media_sequence,
            "start_ms": w.start_ms,
            "segments": w.segments.len(),
            "ended": w.ended,
            "breaks": marked.iter().map(|m| json!({"break": m.id, "start_ms": m.start_ms, "duration_ms": m.duration_ms, "scte35": m.date_range})).collect::<Vec<_>>(),
        })),
        "index": v.index,
    })
}

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

    fn brk(id: &str, start_ms: u64, duration_ms: u64, section: bool) -> Break {
        Break { id: id.into(), start_ms, duration_ms, section: section.then(|| vec![0xFC, 0x30, 0x00]) }
    }

    fn win(seq: u64, start_ms: u64, n: usize) -> Window {
        Window { media_sequence: seq, start_ms, segments: (0..n).map(|k| Segment { uri: format!("s{}.ts", seq + k as u64), duration_ms: 10_000 }).collect(), ended: false }
    }

    /// The lines before each segment's `EXTINF`, by the segment's URI.
    fn cue_lines(text: &str) -> Vec<(String, Vec<String>, Vec<String>)> {
        let mut out = Vec::new();
        let (mut daterange, mut cue) = (Vec::new(), Vec::new());
        let mut lines = text.lines().skip(5);
        while let Some(l) = lines.next() {
            if l.starts_with("#EXTINF:") {
                out.push((lines.next().expect("a URI").to_string(), std::mem::take(&mut daterange), std::mem::take(&mut cue)));
            } else if l.starts_with("#EXT-X-DATERANGE:") {
                daterange.push(l.to_string());
            } else if l.starts_with("#EXT-X-CUE") {
                cue.push(l.to_string());
            }
        }
        out
    }

    #[test]
    fn back_to_back_breaks_close_the_first_then_open_the_second() {
        let breaks = [brk("a", 20_000, 20_000, true), brk("b", 40_000, 10_000, true)];
        let text = render(&breaks, &win(0, 0, 6)).expect("aligned");
        let at_40 = &cue_lines(&text)[4];
        assert_eq!(at_40.0, "s4.ts");
        assert_eq!(at_40.2, ["#EXT-X-CUE-IN", "#EXT-X-CUE-OUT:10.000"]);
        assert!(at_40.1[0].contains("ID=\"a@20000\"") && at_40.1[0].contains("DURATION=20.000"), "{:?}", at_40.1);
        // b's range was announced with the last segment before it, s3, which is in a.
        let at_30 = &cue_lines(&text)[3];
        assert_eq!(at_30.2, ["#EXT-X-CUE-OUT-CONT:ElapsedTime=10.000,Duration=20.000"]);
        assert!(at_30.1[0].contains("ID=\"b@40000\"") && at_30.1[0].contains("PLANNED-DURATION=10.000"));
    }

    #[test]
    fn a_segment_carries_the_same_cue_lines_in_every_window_that_holds_it() {
        let breaks = [brk("a", 50_000, 40_000, true)];
        let all = cue_lines(&render(&breaks, &win(0, 0, 12)).expect("aligned"));
        for first in 0..12 {
            let part = cue_lines(&render(&breaks, &win(first as u64, first as u64 * 10_000, 12 - first)).expect("aligned"));
            for (k, seg) in part.iter().enumerate() {
                assert_eq!(seg.2, all[first + k].2, "window from {first}, segment {k}");
            }
            // The range is in every window holding s4 (its last segment before it) to s9 (its
            // return), and its out tag is on the first of those the window holds.
            let outs: Vec<usize> = part.iter().enumerate().filter(|(_, s)| s.1.iter().any(|l| l.contains("SCTE35-OUT"))).map(|(k, _)| k).collect();
            let want: Vec<usize> = if first <= 4 { vec![4 - first] } else if first <= 9 { vec![0] } else { vec![] };
            assert_eq!(outs, want, "window from {first}");
        }
    }

    #[test]
    fn a_break_with_no_section_has_no_date_range_and_its_id_is_never_written() {
        let breaks = [brk("quote\"d", 10_000, 10_000, false)];
        let text = render(&breaks, &win(0, 0, 3)).expect("no ID is written");
        assert!(!text.contains("DATERANGE"));
        assert_eq!(cue_lines(&text)[1].2, ["#EXT-X-CUE-OUT:10.000"]);
        assert_eq!(cue_lines(&text)[2].2, ["#EXT-X-CUE-IN"]);
        let with = [brk("quote\"d", 10_000, 10_000, true)];
        assert_eq!(render(&with, &win(0, 0, 3)), Err(Unwritten { reason: Reason::CueUnwritable, index: Some(0) }));
        // Outside the window, the same break is not written, so it does not refuse.
        assert!(render(&with, &win(5, 50_000, 2)).is_ok());
    }

    #[test]
    fn a_rendering_reads_back_to_its_window() {
        let breaks = [brk("a", 20_000, 20_000, true)];
        let mut w = win(7, 1_793_296_800_000, 6);
        w.ended = true;
        let text = render(&[Break { start_ms: w.start_ms + 20_000, ..breaks[0].clone() }], &w).expect("aligned");
        assert_eq!(read_playlist(text.as_bytes()), Some(w));
    }

    #[test]
    fn a_window_past_its_bounds_is_malformed() {
        let mut w = win(0, WINDOW_ENDS_BEFORE - 10_001, 1);
        assert!(w.holds());
        w.segments[0].duration_ms += 1;
        assert!(!w.holds());
        let mut w = win(0, 0, 1);
        w.segments[0].uri = "#seg.ts".into();
        assert_eq!(render(&[], &w).map_err(|u| u.reason), Err(Reason::PlaylistMalformed));
    }

    #[test]
    fn a_break_at_the_last_instant_is_dated_in_the_year_9999() {
        let last = WINDOW_ENDS_BEFORE - 1;
        let breaks = [brk("a", last, 1, true), brk("b", WINDOW_ENDS_BEFORE, 30_000, true)];
        // The window ends where a starts, so a is in it and b is not.
        let text = render(&breaks, &win(0, last - 10_000, 1)).expect("in its bounds");
        assert!(text.contains("#EXT-X-PROGRAM-DATE-TIME:9999-12-31T23:59:49.999Z\n"), "{text}");
        assert!(text.contains("ID=\"a@253402300799999\",START-DATE=\"9999-12-31T23:59:59.999Z\",PLANNED-DURATION=0.001,"), "{text}");
        assert!(!text.contains("b@"), "{text}");
        // With a segment of a millisecond after it, the window would hold b's last segment before
        // it, and b's date has five digits of year: the window is past its bounds.
        let mut w = win(0, last - 10_000, 2);
        w.segments[1].duration_ms = 1;
        assert_eq!(render(&breaks, &w), Err(Unwritten { reason: Reason::PlaylistMalformed, index: None }));
        assert_eq!(read_playlist(text.as_bytes()).map(|w| w.start_ms), Some(last - 10_000));
    }
}
