//! HLS media playlist text (RFC 8216, and the second edition's partial segments): a
//! pure function of the ordered segments and the rendering parameters, so anyone
//! holding a playlist's bytes recomputes them, and a receipt over the bytes
//! (`wai.video.manifest`, http-delivery §8.6) binds every line.
//!
//! [`render_hls`] is the original rendering of `wai.video.manifest`: version 6, media
//! sequence 0, a derived target duration, a discontinuity before each ad and
//! `EXT-X-ENDLIST`. [`render_hls_with`] adds what an on-demand, live or low-latency WAI
//! playlist needs (http-delivery §10.4, §11): a media sequence and discontinuity
//! sequence, a fixed target duration, partial segments and their server control,
//! discontinuities and program date-times, `#WAI-LAYER` lines and a preload hint. With
//! [`HlsRender::legacy`] and no extras it gives [`render_hls`]'s bytes exactly.
//!
//! Pure text, no dependency: `http_binding` renders WAI playlists with it, and
//! `wai_video_manifest` seals them.

/// Whether a segment carries programme content or a stitched ad.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SegmentKind {
    Content,
    Ad,
}

impl SegmentKind {
    /// `content` or `ad`.
    pub fn label(self) -> &'static str {
        match self {
            SegmentKind::Content => "content",
            SegmentKind::Ad => "ad",
        }
    }
}

/// One entry in the ordered manifest composition.
///
/// `ad_id.is_some()` iff `kind == Ad`; a manifest receipt's `verify` rejects any other
/// combination, so the monetisation audit (the ad-bearing segments) is a single source
/// of truth rather than a second list that could disagree.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Segment {
    pub index: u64,
    pub kind: SegmentKind,
    /// The segment URI as it appears in the manifest (content or ad).
    pub uri: String,
    pub duration_ms: u64,
    /// Present exactly for ad segments — the ad decision this break resolved to.
    pub ad_id: Option<String>,
}

/// `EXT-X-PLAYLIST-TYPE`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PlaylistType {
    Vod,
    Event,
}

impl PlaylistType {
    /// `VOD` or `EVENT`.
    pub fn label(self) -> &'static str {
        match self {
            PlaylistType::Vod => "VOD",
            PlaylistType::Event => "EVENT",
        }
    }
}

/// The playlist-wide rendering parameters (the header lines and the end).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HlsRender {
    /// `EXT-X-VERSION`.
    pub version: u8,
    /// `EXT-X-MEDIA-SEQUENCE`.
    pub media_sequence: u64,
    /// `EXT-X-DISCONTINUITY-SEQUENCE`, written only when it is not 0.
    pub discontinuity_sequence: u64,
    /// `EXT-X-TARGETDURATION` in seconds; `None` derives it as the longest segment's
    /// duration rounded up. A live playlist fixes it: it must not change between
    /// revisions.
    pub target_duration_s: Option<u64>,
    /// `EXT-X-PART-INF:PART-TARGET`, in milliseconds.
    pub part_target_ms: Option<u64>,
    /// `EXT-X-SERVER-CONTROL:PART-HOLD-BACK`, in milliseconds: required with a part
    /// target, and at least twice it.
    pub part_hold_back_ms: Option<u64>,
    /// `EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES`.
    pub can_block_reload: bool,
    /// `EXT-X-ENDLIST`.
    pub end_list: bool,
    /// `EXT-X-PLAYLIST-TYPE`.
    pub playlist_type: Option<PlaylistType>,
    /// `EXT-X-INDEPENDENT-SEGMENTS`: every segment decodes without the one before it.
    pub independent_segments: bool,
    /// `EXT-X-MAP:URI`, once, before the first segment: a classical playlist's
    /// initialization section (a WAI playlist has none).
    pub map_uri: Option<String>,
}

impl HlsRender {
    /// [`render_hls`]'s parameters: version 6, media sequence 0, a derived target
    /// duration, no parts, and the end of the list.
    pub fn legacy() -> Self {
        HlsRender {
            version: 6,
            media_sequence: 0,
            discontinuity_sequence: 0,
            target_duration_s: None,
            part_target_ms: None,
            part_hold_back_ms: None,
            can_block_reload: false,
            end_list: true,
            playlist_type: None,
            independent_segments: false,
            map_uri: None,
        }
    }
}

/// A `#WAI-LAYER` line (http-delivery §10.4): the layer object a client may fetch to
/// depth `n` over the segment or part it follows. `n` is a locator only; the
/// envelope's `refines.layer` decides.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LayerLine {
    pub n: u8,
    pub uri: String,
}

/// An `EXT-X-PART` line and the layer lines that follow it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PartLine {
    pub duration_ms: u64,
    pub independent: bool,
    pub uri: String,
    pub layers: Vec<LayerLine>,
}

/// What a rendering adds to one segment: a discontinuity, a program date-time, the
/// partial segments that precede its URI, a gap, the layer lines after its `EXTINF`, and
/// a preload hint (only on the playlist's last entry).
///
/// [`render_hls_with`] takes no extras, one per segment, or one per segment and one
/// more: the **open** segment at a live edge, whose parts are listed and whose
/// `EXTINF` and URI are not yet (it carries no layer lines of its own).
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub struct SegmentExtra {
    pub discontinuity: bool,
    /// `EXT-X-GAP`: the segment is absent here, and a sink takes the adjacent classical
    /// segment (a class that takes the piece as floor only).
    pub gap: bool,
    pub program_date_time: Option<String>,
    pub parts: Vec<PartLine>,
    pub layers: Vec<LayerLine>,
    pub preload_hint: Option<String>,
}

/// Why a rendering's inputs are refused (`playlist_malformed`, http-delivery §13): the
/// text would not read back as the lines it was given.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RenderError(pub String);

impl RenderError {
    /// `playlist_malformed`.
    pub fn code(&self) -> &'static str {
        "playlist_malformed"
    }
}

impl std::fmt::Display for RenderError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "playlist_malformed: {}", self.0)
    }
}

impl std::error::Error for RenderError {}

/// Milliseconds as a decimal number of seconds with three fractional digits, the same
/// on every platform.
pub fn seconds(ms: u64) -> String {
    format!("{}.{:03}", ms / 1000, ms % 1000)
}

/// Deterministic HLS rendering: the manifest is a pure function of the ordered
/// segments, so `hash(render(segments))` is a stable identity a verifier can
/// recompute. Ad segments carry an `EXT-X-DISCONTINUITY` — the real SSAI splice
/// marker — so the stitched breaks are visible in the bytes, not only the
/// receipt. Target duration is derived (ceil of the longest segment) rather than
/// supplied, so nothing outside `segments` can move the output.
pub fn render_hls(segments: &[Segment]) -> String {
    render_hls_with(segments, &HlsRender::legacy(), &[])
}

fn layer_lines(out: &mut String, layers: &[LayerLine]) {
    for l in layers {
        out.push_str(&format!("#WAI-LAYER:N={},URI=\"{}\"\n", l.n, l.uri));
    }
}

fn part_lines(out: &mut String, parts: &[PartLine]) {
    for p in parts {
        out.push_str(&format!("#EXT-X-PART:DURATION={}", seconds(p.duration_ms)));
        if p.independent {
            out.push_str(",INDEPENDENT=YES");
        }
        out.push_str(&format!(",URI=\"{}\"\n", p.uri));
        layer_lines(out, &p.layers);
    }
}

/// The playlist `segments` render to under `r`, with `extras` (none, one per segment,
/// or one per segment and the open segment's). Each segment is written as: its
/// discontinuity (an ad's, or its extra's; one line), its program date-time, its
/// parts (each `EXT-X-PART` followed by its layer lines), its `EXT-X-GAP`, its
/// `EXTINF`, its layer lines, and its URI. The open segment follows with its
/// discontinuity, program date-time and parts; then the preload hint and
/// `EXT-X-ENDLIST`. The header is `EXTM3U`, `EXT-X-VERSION`, `EXT-X-TARGETDURATION`,
/// `EXT-X-PART-INF`, `EXT-X-SERVER-CONTROL`, `EXT-X-MEDIA-SEQUENCE`, and, those that
/// apply, `EXT-X-DISCONTINUITY-SEQUENCE` (when not 0), `EXT-X-PLAYLIST-TYPE`,
/// `EXT-X-INDEPENDENT-SEGMENTS` and `EXT-X-MAP`. Durations have three fractional digits.
///
/// Rendering never fails; [`check_render`] says whether the inputs read back as
/// themselves, and a WAI playlist is published or sealed only when they do.
pub fn render_hls_with(segments: &[Segment], r: &HlsRender, extras: &[SegmentExtra]) -> String {
    let target = r.target_duration_s.unwrap_or_else(|| segments.iter().map(|s| s.duration_ms.div_ceil(1000)).max().unwrap_or(0));
    let mut out = String::new();
    out.push_str(&format!("#EXTM3U\n#EXT-X-VERSION:{}\n", r.version));
    out.push_str(&format!("#EXT-X-TARGETDURATION:{target}\n"));
    if let Some(pt) = r.part_target_ms {
        out.push_str(&format!("#EXT-X-PART-INF:PART-TARGET={}\n", seconds(pt)));
    }
    let mut control: Vec<String> = Vec::new();
    if r.can_block_reload {
        control.push("CAN-BLOCK-RELOAD=YES".into());
    }
    if let Some(h) = r.part_hold_back_ms {
        control.push(format!("PART-HOLD-BACK={}", seconds(h)));
    }
    if !control.is_empty() {
        out.push_str(&format!("#EXT-X-SERVER-CONTROL:{}\n", control.join(",")));
    }
    out.push_str(&format!("#EXT-X-MEDIA-SEQUENCE:{}\n", r.media_sequence));
    if r.discontinuity_sequence != 0 {
        out.push_str(&format!("#EXT-X-DISCONTINUITY-SEQUENCE:{}\n", r.discontinuity_sequence));
    }
    if let Some(t) = r.playlist_type {
        out.push_str(&format!("#EXT-X-PLAYLIST-TYPE:{}\n", t.label()));
    }
    if r.independent_segments {
        out.push_str("#EXT-X-INDEPENDENT-SEGMENTS\n");
    }
    if let Some(m) = &r.map_uri {
        out.push_str(&format!("#EXT-X-MAP:URI=\"{m}\"\n"));
    }
    let none = SegmentExtra::default();
    for (i, s) in segments.iter().enumerate() {
        let e = extras.get(i).unwrap_or(&none);
        if s.kind == SegmentKind::Ad || e.discontinuity {
            out.push_str("#EXT-X-DISCONTINUITY\n");
        }
        if let Some(pdt) = &e.program_date_time {
            out.push_str(&format!("#EXT-X-PROGRAM-DATE-TIME:{pdt}\n"));
        }
        part_lines(&mut out, &e.parts);
        if e.gap {
            out.push_str("#EXT-X-GAP\n");
        }
        // Fixed 3-dp seconds so the byte output is stable across platforms.
        out.push_str(&format!("#EXTINF:{},\n", seconds(s.duration_ms)));
        layer_lines(&mut out, &e.layers);
        out.push_str(&s.uri);
        out.push('\n');
    }
    if let Some(open) = extras.get(segments.len()) {
        if open.discontinuity {
            out.push_str("#EXT-X-DISCONTINUITY\n");
        }
        if let Some(pdt) = &open.program_date_time {
            out.push_str(&format!("#EXT-X-PROGRAM-DATE-TIME:{pdt}\n"));
        }
        part_lines(&mut out, &open.parts);
    }
    if let Some(hint) = extras.last().and_then(|e| e.preload_hint.as_ref()) {
        out.push_str(&format!("#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"{hint}\"\n"));
    }
    if r.end_list {
        out.push_str("#EXT-X-ENDLIST\n");
    }
    out
}

fn uri_ok(u: &str, quoted: bool) -> bool {
    !u.is_empty() && !u.starts_with('#') && !u.bytes().any(|b| b == b'\r' || b == b'\n' || (quoted && b == b'"'))
}

fn check_layers(layers: &[LayerLine], at: &str) -> Result<(), RenderError> {
    let mut last = 0u8;
    for l in layers {
        if l.n == 0 || l.n <= last {
            return Err(RenderError(format!("{at}: layer numbers are 1 to 255 and strictly increasing")));
        }
        if !uri_ok(&l.uri, true) {
            return Err(RenderError(format!("{at}: a layer URI is empty or holds a quote, CR or LF")));
        }
        last = l.n;
    }
    Ok(())
}

/// Whether `segments`, `r` and `extras` render to a playlist that reads back as them
/// (`playlist_malformed` otherwise):
///
/// - `extras` is empty, one per segment, or one more (the open segment, which carries
///   no layer lines, needs a part before a discontinuity or a date-time, and is not
///   in a playlist that ends);
/// - a preload hint is on the last extra only, and not in a playlist that ends;
/// - parts need a part target, and a part target needs a part hold-back of at least
///   twice it (the HLS second edition requires both);
/// - every URI is non-empty, does not begin with `#`, and holds no CR or LF, and a
///   quoted one (a part's, a layer's, a hint's, the map's) no `"`;
/// - the open segment has no gap, and a gap segment carries no parts or layer lines
///   (it offers no object there, so nothing refines or divides one);
/// - a program date-time is ASCII letters, digits and `:.+-` only;
/// - layer numbers are 1 to 255 and strictly increasing after each segment or part;
/// - the version is at least 1.
pub fn check_render(segments: &[Segment], r: &HlsRender, extras: &[SegmentExtra]) -> Result<(), RenderError> {
    let n = segments.len();
    if !(extras.is_empty() || extras.len() == n || extras.len() == n + 1) {
        return Err(RenderError(format!("{} extras for {n} segments", extras.len())));
    }
    if r.version == 0 {
        return Err(RenderError("version 0".into()));
    }
    if r.map_uri.as_deref().is_some_and(|m| !uri_ok(m, true)) {
        return Err(RenderError("the map URI is empty or holds a quote, CR or LF".into()));
    }
    if let Some(pt) = r.part_target_ms {
        match r.part_hold_back_ms {
            Some(h) if pt > 0 && h >= pt.saturating_mul(2) => {}
            _ => return Err(RenderError("a part target needs a part hold-back of at least twice it".into())),
        }
    }
    for (i, s) in segments.iter().enumerate() {
        if !uri_ok(&s.uri, false) {
            return Err(RenderError(format!("segment {i}: its URI is empty, begins with # or holds CR or LF")));
        }
    }
    for (i, e) in extras.iter().enumerate() {
        let at = if i == n { "the open segment".to_string() } else { format!("segment {i}") };
        if let Some(p) = &e.program_date_time {
            if p.is_empty() || !p.bytes().all(|b| b.is_ascii_alphanumeric() || b":.+-".contains(&b)) {
                return Err(RenderError(format!("{at}: a program date-time outside letters, digits and :.+-")));
            }
        }
        if !e.parts.is_empty() && r.part_target_ms.is_none() {
            return Err(RenderError(format!("{at}: parts with no part target")));
        }
        for p in &e.parts {
            if !uri_ok(&p.uri, true) {
                return Err(RenderError(format!("{at}: a part URI is empty or holds a quote, CR or LF")));
            }
            check_layers(&p.layers, &at)?;
        }
        check_layers(&e.layers, &at)?;
        if i < n && e.gap && (!e.parts.is_empty() || !e.layers.is_empty()) {
            return Err(RenderError(format!("{at}: a gap carries parts or layer lines")));
        }
        if let Some(h) = &e.preload_hint {
            if i + 1 != extras.len() || r.end_list {
                return Err(RenderError(format!("{at}: a preload hint is on the last entry of a playlist that does not end")));
            }
            if !uri_ok(h, true) {
                return Err(RenderError(format!("{at}: the hint URI is empty or holds a quote, CR or LF")));
            }
        }
        if i == n {
            if r.end_list {
                return Err(RenderError("an open segment in a playlist that ends".into()));
            }
            if !e.layers.is_empty() || e.gap {
                return Err(RenderError("the open segment carries layer lines or a gap of its own".into()));
            }
            if e.parts.is_empty() && (e.discontinuity || e.program_date_time.is_some()) {
                return Err(RenderError("the open segment's discontinuity or date-time comes before a part".into()));
            }
            if e.parts.is_empty() && e.preload_hint.is_none() {
                return Err(RenderError("an open segment with no part and no hint".into()));
            }
        }
    }
    Ok(())
}
