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

///|
/// Write a playlist back out.
///
/// Tags are written in the order the specification lists them rather than the
/// order they were read, except inside a segment, where the order the file had
/// is the order players expect to see: the key and the map apply to the segment
/// that follows, so they come first, and `#EXTINF` immediately precedes its URI.
pub fn Playlist::encode(self : Playlist) -> String {
  match self {
    MasterPlaylist(master) => master.encode()
    MediaPlaylist(media) => media.encode()
  }
}

///|
fn line(buf : @buffer.Buffer, text : StringView) -> Unit {
  buf.write_string_utf8(text)
  buf.write_byte(b'\n')
}

///|
/// The bytes written so far, as text. `Buffer::to_string` is the display form of
/// the buffer rather than its contents, so the contents are decoded instead;
/// they are UTF-8 by construction, every write having come from a `StringView`.
fn text_of(buf : @buffer.Buffer) -> String {
  @utf8.decode_lossy(buf.contents())
}

///|
/// `NAME=value`.
fn attr(name : StringView, value : StringView) -> String {
  name.to_owned() + "=" + value.to_owned()
}

///|
/// `NAME="value"`.
fn quoted(name : StringView, value : StringView) -> String {
  name.to_owned() + "=\"" + value.to_owned() + "\""
}

///|
/// An attribute list as one comma-separated string.
fn join_attrs(attrs : Array[String]) -> String {
  let buf = @buffer.Buffer()
  for i = 0; i < attrs.length(); i = i + 1 {
    if i > 0 {
      buf.write_byte(b',')
    }
    buf.write_string_utf8(attrs[i])
  }
  text_of(buf)
}

///|
/// Write a tag line, `#EXT-X-VERSION:6` or `#EXT-X-ENDLIST`.
fn tag(buf : @buffer.Buffer, name : StringView, attrs : Array[String]) -> Unit {
  if attrs.length() == 0 {
    line(buf, name)
  } else {
    line(buf, name + ":" + join_attrs(attrs))
  }
}

///|
/// Whether a value of this attribute is written in quotes. The specification
/// fixes the form per attribute name, so a reader that kept the quotes would
/// not be able to tell a quoted enumerated string from a bare one.
fn needs_quotes(name : StringView) -> Bool {
  let names = [
    "URI", "LANGUAGE", "ASSOC-LANGUAGE", "NAME", "GROUP-ID", "STABLE-VARIANT-ID",
    "STABLE-RENDITION-ID", "AUDIO", "VIDEO", "SUBTITLES", "CLOSED-CAPTIONS", "CODECS",
    "PATHWAY-ID", "VALUE", "QUERYPARAM", "CLASS", "ID", "START-DATE", "END-DATE",
    "CHARACTERISTICS", "CHANNELS", "KEYFORMAT", "KEYFORMATVERSIONS",
  ]
  for candidate in names {
    if name == candidate {
      return true
    }
  }
  // A client attribute is defined by whoever invented it, and every one in the
  // wild is quoted.
  name.has_prefix("X-")
}

///|
/// `NAME=value`, quoted when the attribute's grammar says so.
fn text_attr(name : StringView, value : StringView) -> String {
  if name == "CLOSED-CAPTIONS" && value == "NONE" {
    return attr(name, value)
  }
  if needs_quotes(name) {
    quoted(name, value)
  } else {
    attr(name, value)
  }
}

///|
/// `NAME=123`.
fn int_attr(name : StringView, value : Int32) -> String {
  attr(name, value.to_string())
}

///|
/// `NAME=123`.
fn int64_attr(name : StringView, value : Int64) -> String {
  attr(name, value.to_string())
}

///|
/// `NAME=1.5`.
fn double_attr(name : StringView, value : Double) -> String {
  attr(name, compact(value))
}

///|
/// A duration as the shortest text that reads back as the same number. `9.009`
/// stays `9.009`; a whole number loses the `.0` the specification never had.
fn compact(value : Double) -> String {
  if value == value.floor() && value.abs() < 1.0e15 {
    value.to_int64().to_string()
  } else {
    value.to_string()
  }
}

///|
/// `NAME=YES` or `NAME=NO`.
fn flag_attr(name : StringView, value : Bool) -> String {
  attr(name, if value { "YES" } else { "NO" })
}

///|
/// A byte range, `n@o`.
fn byte_range_text(range : ByteRange) -> String {
  match range.offset {
    Some(offset) => range.length.to_string() + "@" + offset.to_string()
    None => range.length.to_string()
  }
}

///|
/// The attributes of a key.
fn key_attrs(key : Key) -> Array[String] {
  let out : Array[String] = []
  out.push(attr("METHOD", key.cipher.code()))
  if key.cipher != Cipher::None {
    match key.uri {
      Some(uri) => out.push(quoted("URI", uri))
      None => ()
    }
    match key.iv {
      Some(iv) => out.push(attr("IV", iv))
      None => ()
    }
    match key.key_format {
      Some(format) => out.push(quoted("KEYFORMAT", format))
      None => ()
    }
    match key.key_format_versions {
      Some(versions) => out.push(quoted("KEYFORMATVERSIONS", versions))
      None => ()
    }
  }
  for fragment in key.extra {
    out.push(fragment)
  }
  out
}

///|
/// The attributes of an initialisation section.
fn map_attrs(map : InitSection) -> Array[String] {
  let out : Array[String] = [quoted("URI", map.uri)]
  match map.byte_range {
    Some(range) => out.push(quoted("BYTERANGE", byte_range_text(range)))
    None => ()
  }
  out
}

///|
/// The attributes of a partial segment.
fn part_attrs(part : Part) -> Array[String] {
  let out : Array[String] = [
    double_attr("DURATION", part.duration),
    quoted("URI", part.uri),
  ]
  if part.independent {
    out.push(attr("INDEPENDENT", "YES"))
  }
  match part.byte_range {
    Some(range) => out.push(quoted("BYTERANGE", byte_range_text(range)))
    None => ()
  }
  if part.gap {
    out.push(attr("GAP", "YES"))
  }
  out
}

///|
/// The attributes of a variant stream.
fn variant_attrs(variant : Variant) -> Array[String] {
  let out : Array[String] = []
  match variant.bandwidth {
    Some(value) => out.push(int_attr("BANDWIDTH", value))
    None => ()
  }
  match variant.average_bandwidth {
    Some(value) => out.push(int_attr("AVERAGE-BANDWIDTH", value))
    None => ()
  }
  match variant.codecs {
    Some(value) => out.push(quoted("CODECS", value))
    None => ()
  }
  match variant.resolution {
    Some(value) => out.push(attr("RESOLUTION", resolution_text(value)))
    None => ()
  }
  match variant.frame_rate {
    Some(value) => out.push(double_attr("FRAME-RATE", value))
    None => ()
  }
  match variant.video_range {
    Some(value) => out.push(attr("VIDEO-RANGE", value))
    None => ()
  }
  match variant.hdcp_level {
    Some(value) => out.push(attr("HDCP-LEVEL", value))
    None => ()
  }
  match variant.audio {
    Some(value) => out.push(quoted("AUDIO", value))
    None => ()
  }
  match variant.video {
    Some(value) => out.push(quoted("VIDEO", value))
    None => ()
  }
  match variant.subtitles {
    Some(value) => out.push(quoted("SUBTITLES", value))
    None => ()
  }
  match variant.closed_captions {
    Some(value) => out.push(text_attr("CLOSED-CAPTIONS", value))
    None => ()
  }
  match variant.pathway_id {
    Some(value) => out.push(quoted("PATHWAY-ID", value))
    None => ()
  }
  match variant.stable_variant_id {
    Some(value) => out.push(quoted("STABLE-VARIANT-ID", value))
    None => ()
  }
  for fragment in variant.extra {
    out.push(fragment)
  }
  out
}

///|
/// `1920x1080`.
fn resolution_text(resolution : Resolution) -> String {
  resolution.width.to_string() + "x" + resolution.height.to_string()
}

///|
/// The attributes of an alternative rendition.
fn rendition_attrs(rendition : Rendition) -> Array[String] {
  let out : Array[String] = [
    attr("TYPE", rendition.kind.code()),
    quoted("GROUP-ID", rendition.group_id),
    quoted("NAME", rendition.name),
  ]
  out.push(flag_attr("DEFAULT", rendition.default))
  out.push(flag_attr("AUTOSELECT", rendition.autoselect))
  match rendition.language {
    Some(value) => out.push(quoted("LANGUAGE", value))
    None => ()
  }
  match rendition.assoc_language {
    Some(value) => out.push(quoted("ASSOC-LANGUAGE", value))
    None => ()
  }
  if rendition.forced {
    out.push(attr("FORCED", "YES"))
  }
  match rendition.instream_id {
    Some(value) => out.push(quoted("INSTREAM-ID", value))
    None => ()
  }
  match rendition.characteristics {
    Some(value) => out.push(quoted("CHARACTERISTICS", value))
    None => ()
  }
  match rendition.channels {
    Some(value) => out.push(quoted("CHANNELS", value))
    None => ()
  }
  match rendition.stable_rendition_id {
    Some(value) => out.push(quoted("STABLE-RENDITION-ID", value))
    None => ()
  }
  match rendition.pathway_id {
    Some(value) => out.push(quoted("PATHWAY-ID", value))
    None => ()
  }
  match rendition.uri {
    Some(value) => out.push(quoted("URI", value))
    None => ()
  }
  for fragment in rendition.extra {
    out.push(fragment)
  }
  out
}

///|
/// The attributes of an I-frame stream.
fn i_frame_attrs(stream : IFrameStream) -> Array[String] {
  let out : Array[String] = []
  match stream.bandwidth {
    Some(value) => out.push(int_attr("BANDWIDTH", value))
    None => ()
  }
  match stream.average_bandwidth {
    Some(value) => out.push(int_attr("AVERAGE-BANDWIDTH", value))
    None => ()
  }
  match stream.codecs {
    Some(value) => out.push(quoted("CODECS", value))
    None => ()
  }
  match stream.resolution {
    Some(value) => out.push(attr("RESOLUTION", resolution_text(value)))
    None => ()
  }
  match stream.video_range {
    Some(value) => out.push(attr("VIDEO-RANGE", value))
    None => ()
  }
  match stream.hdcp_level {
    Some(value) => out.push(attr("HDCP-LEVEL", value))
    None => ()
  }
  match stream.pathway_id {
    Some(value) => out.push(quoted("PATHWAY-ID", value))
    None => ()
  }
  match stream.stable_variant_id {
    Some(value) => out.push(quoted("STABLE-VARIANT-ID", value))
    None => ()
  }
  out.push(quoted("URI", stream.uri))
  for fragment in stream.extra {
    out.push(fragment)
  }
  out
}

///|
/// The attributes of a session data tag.
fn session_data_attrs(data : SessionData) -> Array[String] {
  let out : Array[String] = [quoted("DATA-ID", data.data_id)]
  match data.value {
    Some(value) => out.push(quoted("VALUE", value))
    None => ()
  }
  match data.uri {
    Some(value) => out.push(quoted("URI", value))
    None => ()
  }
  match data.language {
    Some(value) => out.push(quoted("LANGUAGE", value))
    None => ()
  }
  for fragment in data.extra {
    out.push(fragment)
  }
  out
}

///|
/// The attributes of a start tag.
fn start_attrs(start : Start) -> Array[String] {
  let out : Array[String] = [double_attr("TIME-OFFSET", start.time_offset)]
  if start.precise {
    out.push(attr("PRECISE", "YES"))
  }
  out
}

///|
/// The attributes of a server control tag.
fn server_control_attrs(control : ServerControl) -> Array[String] {
  let out : Array[String] = []
  match control.can_skip_until {
    Some(value) => out.push(double_attr("CAN-SKIP-UNTIL", value))
    None => ()
  }
  if control.can_skip_date_ranges {
    out.push(attr("CAN-SKIP-DATERANGES", "YES"))
  }
  match control.hold_back {
    Some(value) => out.push(double_attr("HOLD-BACK", value))
    None => ()
  }
  match control.part_hold_back {
    Some(value) => out.push(double_attr("PART-HOLD-BACK", value))
    None => ()
  }
  if control.can_block_reload {
    out.push(attr("CAN-BLOCK-RELOAD", "YES"))
  }
  for fragment in control.extra {
    out.push(fragment)
  }
  out
}

///|
/// The attributes of a preload hint.
fn preload_hint_attrs(hint : PreloadHint) -> Array[String] {
  let out : Array[String] = [attr("TYPE", hint.kind), quoted("URI", hint.uri)]
  match hint.byte_range {
    Some(range) => out.push(quoted("BYTERANGE", byte_range_text(range)))
    None => ()
  }
  match hint.start {
    Some(value) => out.push(int64_attr("BYTERANGE-START", value))
    None => ()
  }
  for fragment in hint.extra {
    out.push(fragment)
  }
  out
}

///|
/// The attributes of a rendition report.
fn rendition_report_attrs(report : RenditionReport) -> Array[String] {
  let out : Array[String] = [quoted("URI", report.uri)]
  match report.last_msn {
    Some(value) => out.push(int64_attr("LAST-MSN", value))
    None => ()
  }
  match report.last_part {
    Some(value) => out.push(int_attr("LAST-PART", value))
    None => ()
  }
  for fragment in report.extra {
    out.push(fragment)
  }
  out
}

///|
/// The attributes of a date range.
fn date_range_attrs(range : DateRange) -> Array[String] {
  let out : Array[String] = [quoted("ID", range.id)]
  match range.class_ {
    Some(value) => out.push(quoted("CLASS", value))
    None => ()
  }
  match range.start_date {
    Some(value) => out.push(quoted("START-DATE", value))
    None => ()
  }
  match range.end_date {
    Some(value) => out.push(quoted("END-DATE", value))
    None => ()
  }
  match range.duration {
    Some(value) => out.push(double_attr("DURATION", value))
    None => ()
  }
  match range.planned_duration {
    Some(value) => out.push(double_attr("PLANNED-DURATION", value))
    None => ()
  }
  if range.end_on_next {
    out.push(attr("END-ON-NEXT", "YES"))
  }
  match range.scte35_cmd {
    Some(value) => out.push(text_attr("SCTE35-CMD", value))
    None => ()
  }
  match range.scte35_out {
    Some(value) => out.push(text_attr("SCTE35-OUT", value))
    None => ()
  }
  match range.scte35_in {
    Some(value) => out.push(text_attr("SCTE35-IN", value))
    None => ()
  }
  for fragment in range.extra {
    out.push(fragment)
  }
  out
}

///|
/// The attributes of a variable definition.
fn define_attrs(variable : Define) -> Array[String] {
  let out : Array[String] = []
  match variable.name {
    Some(value) => out.push(quoted("NAME", value))
    None => ()
  }
  match variable.value {
    Some(value) => out.push(quoted("VALUE", value))
    None => ()
  }
  match variable.import_ {
    Some(value) => out.push(quoted("IMPORT", value))
    None => ()
  }
  match variable.queryparam {
    Some(value) => out.push(quoted("QUERYPARAM", value))
    None => ()
  }
  out
}

///|
pub fn Master::encode(self : Master) -> String {
  let buf = @buffer.Buffer()
  line(buf, "#EXTM3U")
  match self.version {
    Some(value) => tag(buf, "#EXT-X-VERSION", [value.to_string()])
    None => ()
  }
  if self.independent_segments {
    line(buf, "#EXT-X-INDEPENDENT-SEGMENTS")
  }
  match self.start {
    Some(value) => tag(buf, "#EXT-X-START", start_attrs(value))
    None => ()
  }
  for variable in self.defines {
    tag(buf, "#EXT-X-DEFINE", define_attrs(variable))
  }
  for data in self.session_data {
    tag(buf, "#EXT-X-SESSION-DATA", session_data_attrs(data))
  }
  for key in self.session_keys {
    tag(buf, "#EXT-X-SESSION-KEY", key_attrs(key))
  }
  for rendition in self.renditions {
    tag(buf, "#EXT-X-MEDIA", rendition_attrs(rendition))
  }
  for variant in self.variants {
    tag(buf, "#EXT-X-STREAM-INF", variant_attrs(variant))
    line(buf, variant.uri)
  }
  for stream in self.i_frame_streams {
    tag(buf, "#EXT-X-I-FRAME-STREAM-INF", i_frame_attrs(stream))
  }
  for unknown in self.unknown {
    line(buf, unknown)
  }
  text_of(buf)
}

///|
pub fn MediaPlaylist::encode(self : MediaPlaylist) -> String {
  let buf = @buffer.Buffer()
  line(buf, "#EXTM3U")
  match self.version {
    Some(value) => tag(buf, "#EXT-X-VERSION", [value.to_string()])
    None => ()
  }
  tag(buf, "#EXT-X-TARGETDURATION", [self.target_duration.to_string()])
  if self.media_sequence != 0L {
    tag(buf, "#EXT-X-MEDIA-SEQUENCE", [self.media_sequence.to_string()])
  }
  if self.discontinuity_sequence != 0L {
    tag(buf, "#EXT-X-DISCONTINUITY-SEQUENCE", [
      self.discontinuity_sequence.to_string(),
    ])
  }
  match self.playlist_type {
    Some(value) =>
      tag(buf, "#EXT-X-PLAYLIST-TYPE", [
        match value {
          Event => "EVENT"
          Vod => "VOD"
          Other(name) => name
        },
      ])
    None => ()
  }
  if self.i_frames_only {
    line(buf, "#EXT-X-I-FRAMES-ONLY")
  }
  match self.allow_cache {
    Some(value) =>
      tag(buf, "#EXT-X-ALLOW-CACHE", [if value { "YES" } else { "NO" }])
    None => ()
  }
  if self.independent_segments {
    line(buf, "#EXT-X-INDEPENDENT-SEGMENTS")
  }
  match self.start {
    Some(value) => tag(buf, "#EXT-X-START", start_attrs(value))
    None => ()
  }
  match self.server_control {
    Some(value) =>
      tag(buf, "#EXT-X-SERVER-CONTROL", server_control_attrs(value))
    None => ()
  }
  match self.part_inf {
    Some(value) =>
      tag(buf, "#EXT-X-PART-INF", [double_attr("PART-TARGET", value)])
    None => ()
  }
  match self.skip {
    Some(value) =>
      tag(buf, "#EXT-X-SKIP", [int_attr("SKIPPED-SEGMENTS", value)])
    None => ()
  }
  for variable in self.defines {
    tag(buf, "#EXT-X-DEFINE", define_attrs(variable))
  }
  for interval in self.date_ranges {
    tag(buf, "#EXT-X-DATERANGE", date_range_attrs(interval))
  }
  let mut key : Key? = None
  let mut map : InitSection? = None
  for segment in self.segments {
    if segment.key != key {
      match segment.key {
        Some(value) => tag(buf, "#EXT-X-KEY", key_attrs(value))
        None => ()
      }
      key = segment.key
    }
    if segment.map != map {
      match segment.map {
        Some(value) => tag(buf, "#EXT-X-MAP", map_attrs(value))
        None => ()
      }
      map = segment.map
    }
    if segment.discontinuity {
      line(buf, "#EXT-X-DISCONTINUITY")
    }
    match segment.program_date_time {
      Some(value) => tag(buf, "#EXT-X-PROGRAM-DATE-TIME", [value])
      None => ()
    }
    if segment.gap {
      line(buf, "#EXT-X-GAP")
    }
    match segment.byte_range {
      Some(range) => tag(buf, "#EXT-X-BYTERANGE", [byte_range_text(range)])
      None => ()
    }
    for part in segment.parts {
      tag(buf, "#EXT-X-PART", part_attrs(part))
    }
    let title = match segment.title {
      Some(value) => value
      None => ""
    }
    tag(buf, "#EXTINF", [compact(segment.duration) + "," + title])
    line(buf, segment.uri)
  }
  for part in self.next_parts {
    tag(buf, "#EXT-X-PART", part_attrs(part))
  }
  match self.preload_hint {
    Some(value) => tag(buf, "#EXT-X-PRELOAD-HINT", preload_hint_attrs(value))
    None => ()
  }
  for report in self.rendition_reports {
    tag(buf, "#EXT-X-RENDITION-REPORT", rendition_report_attrs(report))
  }
  if self.end_list {
    line(buf, "#EXT-X-ENDLIST")
  }
  for unknown in self.unknown {
    line(buf, unknown)
  }
  text_of(buf)
}