///|
pub fn parse_playlist(input : String) -> Result[HlsPlaylist, ParseError] {
  let lines = split_char(normalize_newlines(input), '\n')
  let segments : Array[Segment] = []
  let variants : Array[VariantStream] = []
  let renditions : Array[Rendition] = []
  let unknown_tags : Array[String] = []
  let mut seen_header = false
  let mut version = 0
  let mut target_duration = 0
  let mut media_sequence = 0
  let mut discontinuity_sequence = 0
  let mut start_offset_ms : Int? = None
  let mut end_list = false
  let mut independent_segments = false
  let mut current_key : KeyInfo? = None
  let mut current_map : SegmentMap? = None
  let mut pending_duration = -1
  let mut pending_title = ""
  let mut pending_byte_range : ByteRange? = None
  let mut pending_discontinuity = false
  let mut pending_program_date_time : String? = None
  let mut pending_variant_attrs : Array[HlsAttribute]? = None
  let mut pending_variant_line = 0
  for index, raw_line in lines {
    let line = index + 1
    let text = clean(raw_line[:])
    if text == "" {
      continue
    }
    if text == "#EXTM3U" {
      seen_header = true
      continue
    }
    if !seen_header {
      return Err(MissingHeader(line))
    }
    if text.has_prefix("#EXTINF:") {
      match parse_extinf(text, line) {
        Ok((duration, title)) => {
          pending_duration = duration
          pending_title = title
        }
        Err(err) => return Err(err)
      }
    } else if text.has_prefix("#EXT-X-VERSION:") {
      match parse_unsigned_int(after_colon(text), line, "EXT-X-VERSION") {
        Ok(value) => version = value
        Err(err) => return Err(err)
      }
    } else if text.has_prefix("#EXT-X-TARGETDURATION:") {
      match parse_unsigned_int(
        after_colon(text),
        line,
        "EXT-X-TARGETDURATION",
      ) {
        Ok(value) => target_duration = value
        Err(err) => return Err(err)
      }
    } else if text.has_prefix("#EXT-X-MEDIA-SEQUENCE:") {
      match parse_unsigned_int(
        after_colon(text),
        line,
        "EXT-X-MEDIA-SEQUENCE",
      ) {
        Ok(value) => media_sequence = value
        Err(err) => return Err(err)
      }
    } else if text.has_prefix("#EXT-X-DISCONTINUITY-SEQUENCE:") {
      match parse_unsigned_int(
        after_colon(text),
        line,
        "EXT-X-DISCONTINUITY-SEQUENCE",
      ) {
        Ok(value) => discontinuity_sequence = value
        Err(err) => return Err(err)
      }
    } else if text.has_prefix("#EXT-X-START:") {
      match parse_start_offset(text, line) {
        Ok(value) => start_offset_ms = Some(value)
        Err(err) => return Err(err)
      }
    } else if text == "#EXT-X-ENDLIST" {
      end_list = true
    } else if text == "#EXT-X-INDEPENDENT-SEGMENTS" {
      independent_segments = true
    } else if text.has_prefix("#EXT-X-KEY:") {
      match parse_key(after_colon(text), line) {
        Ok(value) => current_key = Some(value)
        Err(err) => return Err(err)
      }
    } else if text.has_prefix("#EXT-X-MAP:") {
      match parse_map(after_colon(text), line) {
        Ok(value) => current_map = Some(value)
        Err(err) => return Err(err)
      }
    } else if text.has_prefix("#EXT-X-BYTERANGE:") {
      match parse_byte_range(after_colon(text), line) {
        Ok(value) => pending_byte_range = Some(value)
        Err(err) => return Err(err)
      }
    } else if text == "#EXT-X-DISCONTINUITY" {
      pending_discontinuity = true
    } else if text.has_prefix("#EXT-X-PROGRAM-DATE-TIME:") {
      pending_program_date_time = Some(after_colon(text))
    } else if text.has_prefix("#EXT-X-STREAM-INF:") {
      match parse_attribute_list(after_colon(text), line~) {
        Ok(attrs) => {
          pending_variant_attrs = Some(attrs)
          pending_variant_line = line
        }
        Err(err) => return Err(err)
      }
    } else if text.has_prefix("#EXT-X-MEDIA:") {
      match parse_rendition(after_colon(text), line) {
        Ok(rendition) => renditions.push(rendition)
        Err(err) => return Err(err)
      }
    } else if text.has_prefix("#") {
      push_unknown(unknown_tags, line, text)
    } else {
      match pending_variant_attrs {
        Some(attrs) => {
          match parse_variant(attrs, text, pending_variant_line) {
            Ok(variant) => {
              variants.push(variant)
              pending_variant_attrs = None
              pending_variant_line = 0
            }
            Err(err) => return Err(err)
          }
        }
        None =>
          if pending_duration >= 0 {
            segments.push({
              uri: text,
              title: pending_title,
              duration_ms: pending_duration,
              byte_range: pending_byte_range,
              key: current_key,
              map: current_map,
              discontinuity: pending_discontinuity,
              program_date_time: pending_program_date_time,
              line,
            })
            pending_duration = -1
            pending_title = ""
            pending_byte_range = None
            pending_discontinuity = false
            pending_program_date_time = None
          } else {
            return Err(UriWithoutContext(line, text))
          }
      }
    }
  }
  if pending_variant_attrs is Some(_) {
    return Err(MissingVariantUri(pending_variant_line))
  }
  if pending_duration >= 0 {
    return Err(MissingSegmentUri(lines.length()))
  }
  let kind = classify_playlist(
    segments.length(),
    variants.length(),
    renditions.length(),
  )
  Ok({
    version,
    target_duration,
    media_sequence,
    discontinuity_sequence,
    start_offset_ms,
    end_list,
    independent_segments,
    segments,
    variants,
    renditions,
    unknown_tags,
    kind,
  })
}

///|
fn classify_playlist(
  segment_count : Int,
  variant_count : Int,
  rendition_count : Int,
) -> PlaylistKind {
  if segment_count > 0 && (variant_count > 0 || rendition_count > 0) {
    Hybrid
  } else if segment_count > 0 {
    Media
  } else if variant_count > 0 || rendition_count > 0 {
    Master
  } else {
    Empty
  }
}

///|
fn after_colon(text : String) -> String {
  match text.split_once(":") {
    Some((_, value)) => value.to_owned()
    None => ""
  }
}

///|
fn parse_extinf(text : String, line : Int) -> Result[(Int, String), ParseError] {
  let body = after_colon(text)
  match body.split_once(",") {
    Some((duration, title)) =>
      match parse_millis(clean(duration), line, "EXTINF.duration") {
        Ok(ms) => Ok((ms, clean(title)))
        Err(err) => Err(err)
      }
    None =>
      match parse_millis(body, line, "EXTINF.duration") {
        Ok(ms) => Ok((ms, ""))
        Err(err) => Err(err)
      }
  }
}

///|
fn parse_start_offset(text : String, line : Int) -> Result[Int, ParseError] {
  match parse_attribute_list(after_colon(text), line~) {
    Ok(attrs) =>
      match maybe_attr(attrs, "TIME-OFFSET") {
        Some(value) => parse_signed_millis(value, line, "TIME-OFFSET")
        None => Err(InvalidAttribute(line, "EXT-X-START requires TIME-OFFSET"))
      }
    Err(err) => Err(err)
  }
}

///|
fn parse_key(raw : String, line : Int) -> Result[KeyInfo, ParseError] {
  match parse_attribute_list(raw, line~) {
    Ok(attrs) =>
      match required_attr(attrs, "METHOD", line) {
        Ok(key_method) =>
          Ok({
            key_method,
            uri: maybe_attr(attrs, "URI"),
            iv: maybe_attr(attrs, "IV"),
            key_format: maybe_attr(attrs, "KEYFORMAT"),
            key_format_versions: maybe_attr(attrs, "KEYFORMATVERSIONS"),
            line,
          })
        Err(err) => Err(err)
      }
    Err(err) => Err(err)
  }
}

///|
fn parse_map(raw : String, line : Int) -> Result[SegmentMap, ParseError] {
  match parse_attribute_list(raw, line~) {
    Ok(attrs) =>
      match required_attr(attrs, "URI", line) {
        Ok(uri) => {
          let byte_range = match maybe_attr(attrs, "BYTERANGE") {
            Some(value) =>
              match parse_byte_range(value, line) {
                Ok(range) => Some(range)
                Err(err) => return Err(err)
              }
            None => None
          }
          Ok({ uri, byte_range, line })
        }
        Err(err) => Err(err)
      }
    Err(err) => Err(err)
  }
}

///|
fn parse_variant(
  attrs : Array[HlsAttribute],
  uri : String,
  line : Int,
) -> Result[VariantStream, ParseError] {
  let bandwidth = match maybe_int_attr(attrs, "BANDWIDTH", line) {
    Ok(Some(value)) => value
    Ok(None) =>
      return Err(InvalidAttribute(line, "EXT-X-STREAM-INF requires BANDWIDTH"))
    Err(err) => return Err(err)
  }
  let average_bandwidth = match maybe_int_attr(
    attrs,
    "AVERAGE-BANDWIDTH",
    line,
  ) {
    Ok(value) => value
    Err(err) => return Err(err)
  }
  let resolution = match maybe_attr(attrs, "RESOLUTION") {
    Some(value) =>
      match parse_resolution(value, line) {
        Ok(parsed) => Some(parsed)
        Err(err) => return Err(err)
      }
    None => None
  }
  let frame_rate_milli = match maybe_attr(attrs, "FRAME-RATE") {
    Some(value) =>
      match parse_millis(value, line, "FRAME-RATE") {
        Ok(parsed) => Some(parsed)
        Err(err) => return Err(err)
      }
    None => None
  }
  Ok({
    uri,
    bandwidth,
    average_bandwidth,
    codecs: maybe_attr(attrs, "CODECS"),
    resolution,
    frame_rate_milli,
    audio_group: maybe_attr(attrs, "AUDIO"),
    subtitles_group: maybe_attr(attrs, "SUBTITLES"),
    closed_captions: maybe_attr(attrs, "CLOSED-CAPTIONS"),
    line,
  })
}

///|
fn parse_rendition(raw : String, line : Int) -> Result[Rendition, ParseError] {
  match parse_attribute_list(raw, line~) {
    Ok(attrs) => {
      let rendition_type = match required_attr(attrs, "TYPE", line) {
        Ok(value) => value
        Err(err) => return Err(err)
      }
      let group_id = match required_attr(attrs, "GROUP-ID", line) {
        Ok(value) => value
        Err(err) => return Err(err)
      }
      let name = match required_attr(attrs, "NAME", line) {
        Ok(value) => value
        Err(err) => return Err(err)
      }
      let default = match maybe_attr(attrs, "DEFAULT") {
        Some(value) => parse_yes_no(value)
        None => false
      }
      let autoselect = match maybe_attr(attrs, "AUTOSELECT") {
        Some(value) => parse_yes_no(value)
        None => false
      }
      let forced = match maybe_attr(attrs, "FORCED") {
        Some(value) => parse_yes_no(value)
        None => false
      }
      Ok({
        rendition_type,
        group_id,
        name,
        language: maybe_attr(attrs, "LANGUAGE"),
        uri: maybe_attr(attrs, "URI"),
        default,
        autoselect,
        forced,
        line,
      })
    }
    Err(err) => Err(err)
  }
}