///|
fn podcast_extension_enabled(root : Node, channel : Node) -> Bool {
  let uri = "http://www.itunes.com/dtds/podcast-1.0.dtd"
  root.attributes.get("xmlns:itunes") == Some(uri) ||
  channel.attributes.get("xmlns:itunes") == Some(uri)
}

///|
fn first_named(parent : Node, name : String) -> Node? {
  match children(parent, name) {
    [first, ..] => Some(first)
    _ => None
  }
}

///|
fn child_text(
  parent : Node,
  name : String,
  path : String,
) -> String? raise ParseError {
  match first_named(parent, name) {
    Some(element) => Some(text(element, path + "." + name))
    None => None
  }
}

///|
fn extension_bool(value : String, path : String) -> Bool raise ParseError {
  match value {
    "true" | "yes" => true
    "false" | "no" | "clean" => false
    _ =>
      raise InvalidRequiredField(
        path, "expected yes, no, true, false, or clean",
      )
  }
}

///|
fn optional_extension_bool(
  parent : Node,
  name : String,
  path : String,
) -> Bool? raise ParseError {
  match child_text(parent, name, path) {
    Some(value) => Some(extension_bool(value, path + "." + name))
    None => None
  }
}

///|
/// Parse Apple's HH:MM:SS, MM:SS, or SS duration forms.
fn podcast_duration(value : String, path : String) -> Double raise ParseError {
  let parts : Array[String] = []
  let mut part = StringBuilder()
  for ch in value.to_array() {
    if ch == ':' {
      if part.to_string().is_empty() {
        raise InvalidRequiredField(path, "duration contains an empty component")
      }
      parts.push(part.to_string())
      part = StringBuilder()
    } else if ch >= '0' && ch <= '9' {
      part.write_char(ch)
    } else {
      raise InvalidRequiredField(
        path, "duration must contain decimal components",
      )
    }
  }
  if part.to_string().is_empty() {
    raise InvalidRequiredField(path, "duration contains an empty component")
  }
  parts.push(part.to_string())
  if parts.length() > 3 {
    raise InvalidRequiredField(
      path, "duration must have at most three components",
    )
  }
  let values : Array[UInt64] = []
  for component in parts {
    values.push(
      @string.parse_uint64(component) catch {
        _ =>
          raise InvalidRequiredField(path, "duration component exceeds UInt64")
      },
    )
  }
  let seconds = values[values.length() - 1]
  if seconds > 59UL && values.length() > 1 {
    raise InvalidRequiredField(path, "seconds component must be at most 59")
  }
  if values.length() == 1 {
    seconds.to_double()
  } else if values.length() == 2 {
    values[0].to_double() * 60.0 + seconds.to_double()
  } else {
    let minutes = values[1]
    if minutes > 59UL {
      raise InvalidRequiredField(path, "minutes component must be at most 59")
    }
    values[0].to_double() * 3600.0 +
    minutes.to_double() * 60.0 +
    seconds.to_double()
  }
}

///|
fn podcast_categories(
  channel : Node,
) -> Array[@model.PodcastCategory] raise ParseError {
  let result : Array[@model.PodcastCategory] = []
  for index, category in children(channel, "itunes:category") {
    let path = "channel.itunes:category[\{index}]"
    let name = category.attributes
      .get("text")
      .unwrap_or("")
      .trim(chars=" \t\r\n")
      .to_owned()
    if name.is_empty() {
      raise InvalidRequiredField(
        path + ".text",
        "missing or empty category text",
      )
    }
    let subcategories : Array[String] = []
    for child in children(category, "itunes:category") {
      let value = child.attributes
        .get("text")
        .unwrap_or("")
        .trim(chars=" \t\r\n")
        .to_owned()
      if value.is_empty() {
        raise InvalidRequiredField(
          path + ".itunes:category.text",
          "missing or empty subcategory text",
        )
      }
      subcategories.push(value)
    }
    result.push({ name, subcategories, })
  }
  result
}

///|
fn podcast_show_metadata(
  channel : Node,
  enabled : Bool,
) -> @model.PodcastShowMetadata raise ParseError {
  if !enabled {
    return {
      author: None,
      owner_name: None,
      owner_email: None,
      subtitle: None,
      summary: None,
      explicit: None,
      image_url: None,
      categories: [],
      podcast_type: None,
      complete: None,
      new_feed_url: None,
    }
  }
  let owner = first_named(channel, "itunes:owner")
  let image = first_named(channel, "itunes:image")
  {
    author: child_text(channel, "itunes:author", "channel"),
    owner_name: match owner {
      Some(value) => child_text(value, "itunes:name", "channel.itunes:owner")
      None => None
    },
    owner_email: match owner {
      Some(value) => child_text(value, "itunes:email", "channel.itunes:owner")
      None => None
    },
    subtitle: child_text(channel, "itunes:subtitle", "channel"),
    summary: child_text(channel, "itunes:summary", "channel"),
    explicit: optional_extension_bool(channel, "itunes:explicit", "channel"),
    image_url: match image {
      Some(value) => value.attributes.get("href")
      None => None
    },
    categories: podcast_categories(channel),
    podcast_type: child_text(channel, "itunes:type", "channel"),
    complete: optional_extension_bool(channel, "itunes:complete", "channel"),
    new_feed_url: child_text(channel, "itunes:new-feed-url", "channel"),
  }
}

///|
fn podcast_episode_metadata(
  item : Node,
  index : Int,
  enabled : Bool,
) -> @model.PodcastEpisodeMetadata raise ParseError {
  if !enabled {
    return {
      subtitle: None,
      summary: None,
      image_url: None,
      duration_in_seconds: None,
      episode_number: None,
      season_number: None,
      episode_type: None,
      explicit: None,
      block: None,
    }
  }
  let path = "channel.item[\{index}]"
  let image = first_named(item, "itunes:image")
  {
    subtitle: child_text(item, "itunes:subtitle", path),
    summary: child_text(item, "itunes:summary", path),
    image_url: match image {
      Some(value) => value.attributes.get("href")
      None => None
    },
    duration_in_seconds: match child_text(item, "itunes:duration", path) {
      Some(value) => Some(podcast_duration(value, path + ".itunes:duration"))
      None => None
    },
    episode_number: optional_unsigned(item, "itunes:episode", path),
    season_number: optional_unsigned(item, "itunes:season", path),
    episode_type: child_text(item, "itunes:episodeType", path),
    explicit: optional_extension_bool(item, "itunes:explicit", path),
    block: optional_extension_bool(item, "itunes:block", path),
  }
}

///|
fn feed_with_podcast_durations(
  feed : @model.Feed,
  episodes : Array[@model.PodcastEpisodeMetadata],
) -> @model.Feed {
  let items : Array[@model.FeedItem] = []
  for index, item in feed.items {
    let attachments : Array[@model.Attachment] = []
    for attachment in item.attachments {
      let duration = if index < episodes.length() {
        episodes[index].duration_in_seconds
      } else {
        None
      }
      attachments.push({
        url: attachment.url,
        mime_type: attachment.mime_type,
        title: attachment.title,
        size_in_bytes: attachment.size_in_bytes,
        duration_in_seconds: duration,
      })
    }
    items.push({
      id: item.id,
      title: item.title,
      url: item.url,
      external_url: item.external_url,
      authors: item.authors,
      summary: item.summary,
      content_text: item.content_text,
      content_html: item.content_html,
      published: item.published,
      published_at: item.published_at,
      updated: item.updated,
      updated_at: item.updated_at,
      language: item.language,
      categories: item.categories,
      attachments,
    })
  }
  {
    title: feed.title,
    description: feed.description,
    home_page_url: feed.home_page_url,
    feed_url: feed.feed_url,
    language: feed.language,
    authors: feed.authors,
    categories: feed.categories,
    updated: feed.updated,
    updated_at: feed.updated_at,
    items,
  }
}

///|
/// Parse RSS 2.0 plus the Apple Podcasts/iTunes RSS extension metadata.
/// Only the literal `itunes:` prefix and the canonical extension URI are recognized.
pub fn parse_podcast(input : String) -> @model.PodcastFeed raise ParseError {
  let root = document(input)
  let raw = parse_channel(root)
  let channel = match single(root, "channel", "rss") {
    Some(value) => value
    None => raise MissingChannel
  }
  let enabled = podcast_extension_enabled(root, channel)
  let show = podcast_show_metadata(channel, enabled)
  let episodes : Array[@model.PodcastEpisodeMetadata] = []
  for index, item in children(channel, "item") {
    episodes.push(podcast_episode_metadata(item, index, enabled))
  }
  let feed = feed_with_podcast_durations(normalize(raw), episodes)
  { feed, show, episodes, }
}