// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0

///|
/// Read a playlist.
///
/// A master playlist and a media playlist are told apart by what the file
/// contains, not by a declaration: the presence of a variant stream or a
/// rendition makes it a master. Anything the release does not model is kept in
/// `unknown` and written back, so a playlist using a tag from a later revision
/// survives a round trip.
pub fn Playlist::parse(text : StringView) -> Playlist raise PlaylistError {
  Playlist::parse_lines(split_lines(text))
}

///|
/// Split on LF, CRLF or a lone CR, and drop the byte order mark a text editor
/// may have put in front.
fn split_lines(text : StringView) -> Array[StringView] {
  let mut body = text
  if body.length() > 0 && body[0] == '\u{FEFF}' {
    body = body[1:]
  }
  let out : Array[StringView] = []
  let n = body.length()
  let mut start = 0
  let mut i = 0
  while i < n {
    let c = body[i]
    if c == '\n' || c == '\r' {
      out.push(body[start:i])
      if c == '\r' && i + 1 < n && body[i + 1] == '\n' {
        i = i + 1
      }
      start = i + 1
    }
    i = i + 1
  }
  if start < n {
    out.push(body[start:])
  }
  out
}

///|
/// One attribute: its name, its value without quotes, and the text it was read
/// from. The third is what an attribute this release does not model is written
/// back as, so that a document with an extension attribute survives a rewrite
/// without this package having to guess how that attribute is quoted.
priv struct Attr {
  name : String
  value : String
  text : String
}

///|
/// The attribute list of a tag, `NAME=value,NAME="value"`.
fn parse_attrs(text : StringView) -> Array[Attr] {
  let out : Array[Attr] = []
  let n = text.length()
  let mut i = 0
  while i < n {
    while i < n && (text[i] == ',' || text[i] == ' ' || text[i] == '\t') {
      i = i + 1
    }
    if i >= n {
      break
    }
    let name_start = i
    while i < n && text[i] != '=' && text[i] != ',' {
      i = i + 1
    }
    let name = text[name_start:i].trim().to_owned()
    if i >= n || text[i] != '=' {
      if name.length() > 0 {
        out.push({ name, value: "", text: name, })
      }
      continue
    }
    i = i + 1
    let value : String = if i < n && text[i] == '"' {
      i = i + 1
      let value_start = i
      while i < n && text[i] != '"' {
        i = i + 1
      }
      let value = text[value_start:i].to_owned()
      if i < n {
        i = i + 1
      }
      value
    } else {
      let value_start = i
      while i < n && text[i] != ',' {
        i = i + 1
      }
      text[value_start:i].trim().to_owned()
    }
    out.push({ name, value, text: text[name_start:i].trim().to_owned(), })
  }
  out
}

///|
/// An attribute list, with the line number to blame when one of them is wrong.
priv struct Attrs {
  pairs : Array[Attr]
  line : Int32
  raw : String
}

///|
fn Attrs::of(text : StringView, line : Int32) -> Attrs {
  { pairs: parse_attrs(text), line, raw: text.trim().to_owned(), }
}

///|
fn Attrs::find(self : Attrs, name : StringView) -> String? {
  for pair in self.pairs {
    if pair.name[:] == name {
      return Some(pair.value)
    }
  }
  None
}

///|
fn Attrs::has(self : Attrs, name : StringView) -> Bool {
  self.find(name) != None
}

///|
fn Attrs::yes(self : Attrs, name : StringView) -> Bool {
  match self.find(name) {
    Some(value) => value == "YES"
    None => false
  }
}

///|
fn Attrs::int(self : Attrs, name : StringView) -> Int32? raise PlaylistError {
  match self.find(name) {
    None => None
    Some(value) =>
      Some(@string.parse_int(value)) catch {
        _ =>
          raise Malformed(
            at=self.line,
            want="a decimal integer in " + name.to_owned(),
            got=value,
          )
      }
  }
}

///|
fn Attrs::int64(self : Attrs, name : StringView) -> Int64? raise PlaylistError {
  match self.find(name) {
    None => None
    Some(value) =>
      Some(@string.parse_int64(value)) catch {
        _ =>
          raise Malformed(
            at=self.line,
            want="a decimal integer in " + name.to_owned(),
            got=value,
          )
      }
  }
}

///|
fn Attrs::double(
  self : Attrs,
  name : StringView,
) -> Double? raise PlaylistError {
  match self.find(name) {
    None => None
    Some(value) =>
      Some(@string.parse_double(value)) catch {
        _ =>
          raise Malformed(
            at=self.line,
            want="a decimal number in " + name.to_owned(),
            got=value,
          )
      }
  }
}

///|
fn Attrs::resolution(
  self : Attrs,
  name : StringView,
) -> Resolution? raise PlaylistError {
  match self.find(name) {
    None => None
    Some(value) =>
      match split_once(value[:], 'x') {
        None =>
          raise Malformed(
            at=self.line,
            want="WIDTHxHEIGHT in " + name.to_owned(),
            got=value,
          )
        Some((width, height)) =>
          Some(
            Resolution::new(@string.parse_int(width), @string.parse_int(height)),
          ) catch {
            _ =>
              raise Malformed(
                at=self.line,
                want="WIDTHxHEIGHT in " + name.to_owned(),
                got=value,
              )
          }
      }
  }
}

///|
/// The part of `text` before the first `sep`, and the part after it.
fn split_once(text : StringView, sep : Int32) -> (StringView, StringView)? {
  for i = 0; i < text.length(); i = i + 1 {
    if text[i].to_int() == sep {
      return Some((text[:i], text[i + 1:]))
    }
  }
  None
}

///|
/// What follows a tag name, for the tags whose body is not an attribute list.
fn tag_body(line : StringView, tag : StringView) -> StringView {
  line[tag.length():]
}

///|
/// The value of a tag that carries one bare value, `#EXT-X-VERSION:3`.
fn tag_value(line : StringView, tag : StringView) -> StringView? {
  if line.length() > tag.length() && line.has_prefix(tag) {
    Some(line[tag.length():].trim())
  } else {
    None
  }
}

///|
/// A number in a tag that carries one bare value.
fn bare_int(
  line : StringView,
  tag : StringView,
  at : Int32,
) -> Int32 raise PlaylistError {
  match tag_value(line, tag) {
    None => raise Malformed(at~, want=tag.to_owned(), got=line.to_owned())
    Some(value) =>
      @string.parse_int(value) catch {
        _ =>
          raise Malformed(
            at~,
            want="a decimal integer after " + tag.to_owned(),
            got=value.to_owned(),
          )
      }
  }
}

///|
/// The bytes a `ByteRange` was written from, `n@o`.
fn parse_byte_range(
  text : StringView,
  line : Int32,
) -> ByteRange raise PlaylistError {
  match split_once(text.trim(), 0x40) {
    None =>
      ByteRange::new(@string.parse_int64(text.trim())) catch {
        _ =>
          raise Malformed(at=line, want="LENGTH[@OFFSET]", got=text.to_owned())
      }
    Some((length, offset)) =>
      ByteRange::new(
        @string.parse_int64(length),
        offset=@string.parse_int64(offset),
      ) catch {
        _ =>
          raise Malformed(at=line, want="LENGTH[@OFFSET]", got=text.to_owned())
      }
  }
}

///|
/// What the parser has read so far. Both kinds are gathered as the file is
/// read; which one it was is decided once there is nothing left to read.
priv struct Builder {
  mut version : Int32?
  mut independent_segments : Bool
  mut start : Start?
  defines : Array[Define]
  session_data : Array[SessionData]
  session_keys : Array[Key]
  renditions : Array[Rendition]
  variants : Array[Variant]
  i_frame_streams : Array[IFrameStream]
  mut target_duration : Int32?
  mut media_sequence : Int64
  mut discontinuity_sequence : Int64
  mut playlist_type : PlaylistType?
  mut i_frames_only : Bool
  mut allow_cache : Bool?
  mut end_list : Bool
  mut server_control : ServerControl?
  mut part_inf : Double?
  mut skip : Int32?
  date_ranges : Array[DateRange]
  segments : Array[Segment]
  mut preload_hint : PreloadHint?
  rendition_reports : Array[RenditionReport]
  unknown : Array[String]
  // State that a tag sets and the next segment consumes.
  mut key : Key?
  mut map : InitSection?
  mut discontinuity : Bool
  mut program_date_time : String?
  mut gap : Bool
  mut byte_range : ByteRange?
  mut parts : Array[Part]
  mut duration : Double?
  mut title : String?
  mut pending_variant : Variant?
}

///|
fn Builder::new() -> Builder {
  {
    version: None,
    independent_segments: false,
    start: None,
    defines: [],
    session_data: [],
    session_keys: [],
    renditions: [],
    variants: [],
    i_frame_streams: [],
    target_duration: None,
    media_sequence: 0L,
    discontinuity_sequence: 0L,
    playlist_type: None,
    i_frames_only: false,
    allow_cache: None,
    end_list: false,
    server_control: None,
    part_inf: None,
    skip: None,
    date_ranges: [],
    segments: [],
    preload_hint: None,
    rendition_reports: [],
    unknown: [],
    key: None,
    map: None,
    discontinuity: false,
    program_date_time: None,
    gap: false,
    byte_range: None,
    parts: [],
    duration: None,
    title: None,
    pending_variant: None,
  }
}

///|
/// Whether the file has said anything only a master playlist may say.
fn Builder::looks_master(self : Builder) -> Bool {
  self.variants.length() > 0 ||
  self.i_frame_streams.length() > 0 ||
  self.renditions.length() > 0 ||
  self.session_data.length() > 0 ||
  self.session_keys.length() > 0
}

///|
fn Playlist::parse_lines(
  lines : Array[StringView],
) -> Playlist raise PlaylistError {
  if lines.length() == 0 || lines[0].trim() != "#EXTM3U" {
    let got = if lines.length() == 0 { "" } else { lines[0].trim().to_owned() }
    raise Malformed(at=1, want="#EXTM3U", got~)
  }
  let b = Builder::new()
  for i in 1.. Unit raise PlaylistError {
  let uri = line.to_owned()
  match self.pending_variant {
    Some(variant) => {
      self.variants.push({ ..variant, uri, })
      self.pending_variant = None
    }
    None =>
      match self.duration {
        None =>
          raise Malformed(at~, want="#EXTINF before a segment URI", got=uri)
        Some(duration) => {
          self.segments.push({
            uri,
            duration,
            title: self.title,
            byte_range: self.byte_range,
            discontinuity: self.discontinuity,
            program_date_time: self.program_date_time,
            gap: self.gap,
            key: self.key,
            map: self.map,
            parts: self.parts,
          })
          self.duration = None
          self.title = None
          self.byte_range = None
          self.discontinuity = false
          self.program_date_time = None
          self.gap = false
          self.parts = []
        }
      }
  }
}

///|
fn Builder::take_extinf(
  self : Builder,
  line : StringView,
  at : Int32,
) -> Unit raise PlaylistError {
  let body = tag_body(line, "#EXTINF:").trim()
  let parsed : (StringView, String?) = match split_once(body, 0x2C) {
    None => (body, None)
    // An empty title is the same as none: the comma after the duration is
    // required either way.
    Some((duration, title)) =>
      (
        duration,
        if title.trim().length() == 0 {
          None
        } else {
          Some(title.to_owned())
        },
      )
  }
  let (duration_text, title) = parsed
  let duration = @string.parse_double(duration_text.trim()) catch {
    _ =>
      raise Malformed(
        at~,
        want="a duration in #EXTINF",
        got=duration_text.to_owned(),
      )
  }
  self.duration = Some(duration)
  self.title = title
}

///|
fn Builder::take_ext_tag(
  self : Builder,
  line : StringView,
  at : Int32,
) -> Unit raise PlaylistError {
  if line.has_prefix("#EXT-X-VERSION:") {
    self.version = Some(bare_int(line, "#EXT-X-VERSION:", at))
  } else if line.has_prefix("#EXT-X-TARGETDURATION:") {
    self.target_duration = Some(bare_int(line, "#EXT-X-TARGETDURATION:", at))
  } else if line.has_prefix("#EXT-X-MEDIA-SEQUENCE:") {
    self.media_sequence = bare_int(line, "#EXT-X-MEDIA-SEQUENCE:", at).to_int64()
  } else if line.has_prefix("#EXT-X-DISCONTINUITY-SEQUENCE:") {
    self.discontinuity_sequence = bare_int(
      line, "#EXT-X-DISCONTINUITY-SEQUENCE:", at,
    ).to_int64()
  } else if line.has_prefix("#EXT-X-PLAYLIST-TYPE:") {
    let value = tag_value(line, "#EXT-X-PLAYLIST-TYPE:").unwrap()
    self.playlist_type = Some(
      if value == "EVENT" {
        PlaylistType::Event
      } else if value == "VOD" {
        PlaylistType::Vod
      } else {
        PlaylistType::Other(value.to_owned())
      },
    )
  } else if line.has_prefix("#EXT-X-ALLOW-CACHE:") {
    let value = tag_value(line, "#EXT-X-ALLOW-CACHE:").unwrap()
    self.allow_cache = Some(value == "YES")
  } else if line.has_prefix("#EXT-X-PROGRAM-DATE-TIME:") {
    self.program_date_time = Some(
      tag_body(line, "#EXT-X-PROGRAM-DATE-TIME:").to_owned(),
    )
  } else if line.has_prefix("#EXT-X-BYTERANGE:") {
    self.byte_range = Some(
      parse_byte_range(tag_body(line, "#EXT-X-BYTERANGE:"), at),
    )
  } else if line.has_prefix("#EXT-X-START:") {
    self.start = Some(
      take_start(Attrs::of(tag_body(line, "#EXT-X-START:"), at)),
    )
  } else if line.has_prefix("#EXT-X-KEY:") {
    self.key = Some(take_key(Attrs::of(tag_body(line, "#EXT-X-KEY:"), at)))
  } else if line.has_prefix("#EXT-X-MAP:") {
    self.map = Some(take_map(Attrs::of(tag_body(line, "#EXT-X-MAP:"), at)))
  } else if line.has_prefix("#EXT-X-PART:") {
    self.parts.push(take_part(Attrs::of(tag_body(line, "#EXT-X-PART:"), at)))
  } else if line.has_prefix("#EXT-X-PART-INF:") {
    let attrs = Attrs::of(tag_body(line, "#EXT-X-PART-INF:"), at)
    self.part_inf = attrs.double("PART-TARGET")
  } else if line.has_prefix("#EXT-X-SERVER-CONTROL:") {
    self.server_control = Some(
      take_server_control(
        Attrs::of(tag_body(line, "#EXT-X-SERVER-CONTROL:"), at),
      ),
    )
  } else if line.has_prefix("#EXT-X-SKIP:") {
    self.skip = Attrs::of(tag_body(line, "#EXT-X-SKIP:"), at).int(
      "SKIPPED-SEGMENTS",
    )
  } else if line.has_prefix("#EXT-X-PRELOAD-HINT:") {
    self.preload_hint = Some(
      take_preload_hint(Attrs::of(tag_body(line, "#EXT-X-PRELOAD-HINT:"), at)),
    )
  } else if line.has_prefix("#EXT-X-RENDITION-REPORT:") {
    self.rendition_reports.push(
      take_rendition_report(
        Attrs::of(tag_body(line, "#EXT-X-RENDITION-REPORT:"), at),
      ),
    )
  } else if line.has_prefix("#EXT-X-DATERANGE:") {
    self.date_ranges.push(
      take_date_range(Attrs::of(tag_body(line, "#EXT-X-DATERANGE:"), at)),
    )
  } else if line.has_prefix("#EXT-X-DEFINE:") {
    self.defines.push(
      take_define(Attrs::of(tag_body(line, "#EXT-X-DEFINE:"), at)),
    )
  } else if line.has_prefix("#EXT-X-SESSION-DATA:") {
    self.session_data.push(
      take_session_data(Attrs::of(tag_body(line, "#EXT-X-SESSION-DATA:"), at)),
    )
  } else if line.has_prefix("#EXT-X-SESSION-KEY:") {
    self.session_keys.push(
      take_key(Attrs::of(tag_body(line, "#EXT-X-SESSION-KEY:"), at)),
    )
  } else if line.has_prefix("#EXT-X-MEDIA:") {
    self.renditions.push(
      take_rendition(Attrs::of(tag_body(line, "#EXT-X-MEDIA:"), at)),
    )
  } else if line.has_prefix("#EXT-X-STREAM-INF:") {
    self.pending_variant = Some(
      take_variant(Attrs::of(tag_body(line, "#EXT-X-STREAM-INF:"), at)),
    )
  } else if line.has_prefix("#EXT-X-I-FRAME-STREAM-INF:") {
    self.i_frame_streams.push(
      take_i_frame_stream(
        Attrs::of(tag_body(line, "#EXT-X-I-FRAME-STREAM-INF:"), at),
      ),
    )
  } else {
    self.unknown.push(line.to_owned())
  }
}

///|
fn Builder::finish(self : Builder) -> Playlist raise PlaylistError {
  if self.pending_variant != None {
    raise Malformed(
      at=self.unknown.length(),
      want="a URI after #EXT-X-STREAM-INF",
      got="end of playlist",
    )
  }
  if self.looks_master() {
    return MasterPlaylist({
      version: self.version,
      independent_segments: self.independent_segments,
      start: self.start,
      defines: self.defines,
      session_data: self.session_data,
      session_keys: self.session_keys,
      renditions: self.renditions,
      variants: self.variants,
      i_frame_streams: self.i_frame_streams,
      unknown: self.unknown,
    })
  }
  let target_duration = match self.target_duration {
    Some(value) => value
    None =>
      raise Malformed(at=1, want="#EXT-X-TARGETDURATION", got="end of playlist")
  }
  MediaPlaylist({
    version: self.version,
    target_duration,
    media_sequence: self.media_sequence,
    discontinuity_sequence: self.discontinuity_sequence,
    playlist_type: self.playlist_type,
    i_frames_only: self.i_frames_only,
    independent_segments: self.independent_segments,
    allow_cache: self.allow_cache,
    end_list: self.end_list,
    start: self.start,
    server_control: self.server_control,
    part_inf: self.part_inf,
    skip: self.skip,
    defines: self.defines,
    date_ranges: self.date_ranges,
    segments: self.segments,
    next_parts: self.parts,
    preload_hint: self.preload_hint,
    rendition_reports: self.rendition_reports,
    unknown: self.unknown,
  })
}

///|
/// The attributes no field of the model reads, kept in the order and the form
/// they came in.
fn leftovers(attrs : Attrs, known : Array[StringView]) -> Array[String] {
  let out : Array[String] = []
  for pair in attrs.pairs {
    let mut seen = false
    for name in known {
      if pair.name[:] == name {
        seen = true
      }
    }
    if !seen {
      out.push(pair.text)
    }
  }
  out
}

///|
fn take_start(attrs : Attrs) -> Start raise PlaylistError {
  let time_offset = match attrs.double("TIME-OFFSET") {
    Some(value) => value
    None =>
      raise Malformed(
        at=attrs.line,
        want="TIME-OFFSET in #EXT-X-START",
        got=attrs.raw,
      )
  }
  { time_offset, precise: attrs.yes("PRECISE"), }
}

///|
fn take_key(attrs : Attrs) -> Key raise PlaylistError {
  let cipher = match attrs.find("METHOD") {
    Some(value) => Cipher::of(value[:])
    None =>
      raise Malformed(at=attrs.line, want="METHOD in #EXT-X-KEY", got=attrs.raw)
  }
  {
    cipher,
    uri: attrs.find("URI"),
    iv: attrs.find("IV"),
    key_format: attrs.find("KEYFORMAT"),
    key_format_versions: attrs.find("KEYFORMATVERSIONS"),
    extra: leftovers(attrs, [
      "METHOD", "URI", "IV", "KEYFORMAT", "KEYFORMATVERSIONS",
    ]),
  }
}

///|
fn take_map(attrs : Attrs) -> InitSection raise PlaylistError {
  let uri = match attrs.find("URI") {
    Some(value) => value
    None =>
      raise Malformed(at=attrs.line, want="URI in #EXT-X-MAP", got=attrs.raw)
  }
  let byte_range = match attrs.find("BYTERANGE") {
    Some(value) => Some(parse_byte_range(value[:], attrs.line))
    None => None
  }
  { uri, byte_range, }
}

///|
fn take_part(attrs : Attrs) -> Part raise PlaylistError {
  let uri = match attrs.find("URI") {
    Some(value) => value
    None =>
      raise Malformed(at=attrs.line, want="URI in #EXT-X-PART", got=attrs.raw)
  }
  let duration = match attrs.double("DURATION") {
    Some(value) => value
    None =>
      raise Malformed(
        at=attrs.line,
        want="DURATION in #EXT-X-PART",
        got=attrs.raw,
      )
  }
  let byte_range = match attrs.find("BYTERANGE") {
    Some(value) => Some(parse_byte_range(value[:], attrs.line))
    None => None
  }
  {
    uri,
    duration,
    independent: attrs.yes("INDEPENDENT"),
    byte_range,
    gap: attrs.yes("GAP"),
  }
}

///|
fn take_server_control(attrs : Attrs) -> ServerControl raise PlaylistError {
  {
    can_skip_until: attrs.double("CAN-SKIP-UNTIL"),
    can_skip_date_ranges: attrs.yes("CAN-SKIP-DATERANGES"),
    hold_back: attrs.double("HOLD-BACK"),
    part_hold_back: attrs.double("PART-HOLD-BACK"),
    can_block_reload: attrs.yes("CAN-BLOCK-RELOAD"),
    extra: leftovers(attrs, [
      "CAN-SKIP-UNTIL", "CAN-SKIP-DATERANGES", "HOLD-BACK", "PART-HOLD-BACK", "CAN-BLOCK-RELOAD",
    ]),
  }
}

///|
fn take_preload_hint(attrs : Attrs) -> PreloadHint raise PlaylistError {
  let kind = match attrs.find("TYPE") {
    Some(value) => value
    None =>
      raise Malformed(
        at=attrs.line,
        want="TYPE in #EXT-X-PRELOAD-HINT",
        got=attrs.raw,
      )
  }
  let uri = match attrs.find("URI") {
    Some(value) => value
    None =>
      raise Malformed(
        at=attrs.line,
        want="URI in #EXT-X-PRELOAD-HINT",
        got=attrs.raw,
      )
  }
  let byte_range = match attrs.find("BYTERANGE") {
    Some(value) => Some(parse_byte_range(value[:], attrs.line))
    None => None
  }
  {
    kind,
    uri,
    byte_range,
    start: attrs.int64("BYTERANGE-START"),
    extra: leftovers(attrs, ["TYPE", "URI", "BYTERANGE", "BYTERANGE-START"]),
  }
}

///|
fn take_rendition_report(attrs : Attrs) -> RenditionReport raise PlaylistError {
  let uri = match attrs.find("URI") {
    Some(value) => value
    None =>
      raise Malformed(
        at=attrs.line,
        want="URI in #EXT-X-RENDITION-REPORT",
        got=attrs.raw,
      )
  }
  {
    uri,
    last_msn: attrs.int64("LAST-MSN"),
    last_part: attrs.int("LAST-PART"),
    extra: leftovers(attrs, ["URI", "LAST-MSN", "LAST-PART"]),
  }
}

///|
fn take_date_range(attrs : Attrs) -> DateRange raise PlaylistError {
  let id = match attrs.find("ID") {
    Some(value) => value
    None =>
      raise Malformed(
        at=attrs.line,
        want="ID in #EXT-X-DATERANGE",
        got=attrs.raw,
      )
  }
  {
    id,
    class_: attrs.find("CLASS"),
    start_date: attrs.find("START-DATE"),
    end_date: attrs.find("END-DATE"),
    duration: attrs.double("DURATION"),
    planned_duration: attrs.double("PLANNED-DURATION"),
    scte35_cmd: attrs.find("SCTE35-CMD"),
    scte35_out: attrs.find("SCTE35-OUT"),
    scte35_in: attrs.find("SCTE35-IN"),
    end_on_next: attrs.yes("END-ON-NEXT"),
    extra: leftovers(attrs, [
      "ID", "CLASS", "START-DATE", "END-DATE", "DURATION", "PLANNED-DURATION", "SCTE35-CMD",
      "SCTE35-OUT", "SCTE35-IN", "END-ON-NEXT",
    ]),
  }
}

///|
fn take_define(attrs : Attrs) -> Define raise PlaylistError {
  if !attrs.has("NAME") && !attrs.has("IMPORT") && !attrs.has("QUERYPARAM") {
    raise Malformed(
      at=attrs.line,
      want="NAME, IMPORT or QUERYPARAM in #EXT-X-DEFINE",
      got=attrs.raw,
    )
  }
  {
    name: attrs.find("NAME"),
    value: attrs.find("VALUE"),
    import_: attrs.find("IMPORT"),
    queryparam: attrs.find("QUERYPARAM"),
  }
}

///|
fn take_session_data(attrs : Attrs) -> SessionData raise PlaylistError {
  let data_id = match attrs.find("DATA-ID") {
    Some(value) => value
    None =>
      raise Malformed(
        at=attrs.line,
        want="DATA-ID in #EXT-X-SESSION-DATA",
        got=attrs.raw,
      )
  }
  if !attrs.has("VALUE") && !attrs.has("URI") {
    raise Malformed(
      at=attrs.line,
      want="VALUE or URI in #EXT-X-SESSION-DATA",
      got=attrs.raw,
    )
  }
  {
    data_id,
    value: attrs.find("VALUE"),
    uri: attrs.find("URI"),
    language: attrs.find("LANGUAGE"),
    extra: leftovers(attrs, ["DATA-ID", "VALUE", "URI", "LANGUAGE"]),
  }
}

///|
fn take_rendition(attrs : Attrs) -> Rendition raise PlaylistError {
  let kind = match attrs.find("TYPE") {
    Some(value) => MediaKind::of(value[:])
    None =>
      raise Malformed(at=attrs.line, want="TYPE in #EXT-X-MEDIA", got=attrs.raw)
  }
  let group_id = match attrs.find("GROUP-ID") {
    Some(value) => value
    None =>
      raise Malformed(
        at=attrs.line,
        want="GROUP-ID in #EXT-X-MEDIA",
        got=attrs.raw,
      )
  }
  let name = match attrs.find("NAME") {
    Some(value) => value
    None =>
      raise Malformed(at=attrs.line, want="NAME in #EXT-X-MEDIA", got=attrs.raw)
  }
  {
    kind,
    group_id,
    name,
    uri: attrs.find("URI"),
    language: attrs.find("LANGUAGE"),
    assoc_language: attrs.find("ASSOC-LANGUAGE"),
    characteristics: attrs.find("CHARACTERISTICS"),
    channels: attrs.find("CHANNELS"),
    instream_id: attrs.find("INSTREAM-ID"),
    stable_rendition_id: attrs.find("STABLE-RENDITION-ID"),
    pathway_id: attrs.find("PATHWAY-ID"),
    default: attrs.yes("DEFAULT"),
    autoselect: attrs.yes("AUTOSELECT"),
    forced: attrs.yes("FORCED"),
    extra: leftovers(attrs, [
      "TYPE", "GROUP-ID", "NAME", "URI", "LANGUAGE", "ASSOC-LANGUAGE", "CHARACTERISTICS",
      "CHANNELS", "INSTREAM-ID", "STABLE-RENDITION-ID", "PATHWAY-ID", "DEFAULT",
      "AUTOSELECT", "FORCED",
    ]),
  }
}

///|
fn take_variant(attrs : Attrs) -> Variant raise PlaylistError {
  {
    uri: "",
    bandwidth: attrs.int("BANDWIDTH"),
    average_bandwidth: attrs.int("AVERAGE-BANDWIDTH"),
    codecs: attrs.find("CODECS"),
    resolution: attrs.resolution("RESOLUTION"),
    frame_rate: attrs.double("FRAME-RATE"),
    hdcp_level: attrs.find("HDCP-LEVEL"),
    audio: attrs.find("AUDIO"),
    video: attrs.find("VIDEO"),
    subtitles: attrs.find("SUBTITLES"),
    closed_captions: attrs.find("CLOSED-CAPTIONS"),
    video_range: attrs.find("VIDEO-RANGE"),
    pathway_id: attrs.find("PATHWAY-ID"),
    stable_variant_id: attrs.find("STABLE-VARIANT-ID"),
    extra: leftovers(attrs, [
      "BANDWIDTH", "AVERAGE-BANDWIDTH", "CODECS", "RESOLUTION", "FRAME-RATE", "HDCP-LEVEL",
      "AUDIO", "VIDEO", "SUBTITLES", "CLOSED-CAPTIONS", "VIDEO-RANGE", "PATHWAY-ID",
      "STABLE-VARIANT-ID",
    ]),
  }
}

///|
fn take_i_frame_stream(attrs : Attrs) -> IFrameStream raise PlaylistError {
  let uri = match attrs.find("URI") {
    Some(value) => value
    None =>
      raise Malformed(
        at=attrs.line,
        want="URI in #EXT-X-I-FRAME-STREAM-INF",
        got=attrs.raw,
      )
  }
  {
    uri,
    bandwidth: attrs.int("BANDWIDTH"),
    average_bandwidth: attrs.int("AVERAGE-BANDWIDTH"),
    codecs: attrs.find("CODECS"),
    resolution: attrs.resolution("RESOLUTION"),
    hdcp_level: attrs.find("HDCP-LEVEL"),
    video_range: attrs.find("VIDEO-RANGE"),
    pathway_id: attrs.find("PATHWAY-ID"),
    stable_variant_id: attrs.find("STABLE-VARIANT-ID"),
    extra: leftovers(attrs, [
      "URI", "BANDWIDTH", "AVERAGE-BANDWIDTH", "CODECS", "RESOLUTION", "HDCP-LEVEL",
      "VIDEO-RANGE", "PATHWAY-ID", "STABLE-VARIANT-ID",
    ]),
  }
}