///|
// RSS nodes are built from the internal XML event stream.
priv struct Node {
  name : String
  attributes : Map[String, String]
  children : Array[Node]
  text : StringBuilder
}

///|
fn node(element : @xmlmini.XmlElement) -> Node {
  {
    name: element.name,
    attributes: element.attributes,
    children: [],
    text: StringBuilder(),
  }
}

///|
fn document(input : String) -> Node raise ParseError {
  let reader = @xmlmini.Reader::from_string(input)
  let stack : Array[Node] = []
  let roots : Array[Node] = []
  while true {
    let event = reader.read_event() catch {
      _ => raise InvalidXml("malformed XML")
    }
    match event {
      Start(element) => {
        if stack.length() >= 256 {
          raise InvalidXml("element depth exceeds 256")
        }
        stack.push(node(element))
      }
      Empty(element) => {
        let element = node(element)
        if stack.is_empty() {
          roots.push(element)
        } else {
          stack[stack.length() - 1].children.push(element)
        }
      }
      End(_) => {
        guard stack.pop() is Some(element) else {
          raise InvalidXml("unexpected closing element")
        }
        if stack.is_empty() {
          roots.push(element)
        } else {
          stack[stack.length() - 1].children.push(element)
        }
      }
      Text(text) | CData(text) =>
        if !stack.is_empty() {
          stack[stack.length() - 1].text.write_string(text)
        }
      Eof => break
    }
  }
  guard roots.length() == 1 && stack.is_empty() else {
    raise InvalidXml("expected one complete document element")
  }
  roots[0]
}

///|
fn children(parent : Node, name : String) -> Array[Node] {
  parent.children.filter(child => child.name == name)
}

///|
fn single(
  parent : Node,
  name : String,
  path : String,
) -> Node? raise ParseError {
  let matches = children(parent, name)
  if matches.length() > 1 {
    raise InvalidRequiredField(path + "." + name, "duplicate singleton element")
  }
  if matches.length() == 0 {
    None
  } else {
    Some(matches[0])
  }
}

///|
fn text(element : Node, path : String) -> String raise ParseError {
  if !element.children.is_empty() {
    raise InvalidRequiredField(
      path, "expected text or CDATA, not nested elements",
    )
  }
  element.text.to_string().trim(chars=" \t\r\n").to_owned()
}

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

///|
fn required_text(
  parent : Node,
  name : String,
  path : String,
) -> String raise ParseError {
  match optional_text(parent, name, path) {
    Some(value) => value
    None => raise InvalidRequiredField(path + "." + name, "missing element")
  }
}

///|
fn attribute(
  element : Node,
  name : String,
  path : String,
) -> String raise ParseError {
  match element.attributes.get(name) {
    Some(value) if value.trim(chars=" \t\r\n").length() > 0 => value
    _ =>
      raise InvalidRequiredField(
        path + "." + name,
        "missing or empty attribute",
      )
  }
}

///|
fn unsigned(value : String, path : String) -> UInt64 raise ParseError {
  if value.is_empty() {
    raise InvalidRequiredField(path, "expected unsigned decimal integer")
  }
  for ch in value {
    if ch < '0' || ch > '9' {
      raise InvalidRequiredField(path, "expected unsigned decimal integer")
    }
  }
  @string.parse_uint64(value) catch {
    _ => raise InvalidRequiredField(path, "integer exceeds UInt64 range")
  }
}

///|
fn optional_unsigned(
  parent : Node,
  name : String,
  path : String,
) -> UInt64? raise ParseError {
  match optional_text(parent, name, path) {
    Some(value) => Some(unsigned(value, path + "." + name))
    None => None
  }
}

///|
fn categories(parent : Node, path : String) -> Array[Category] raise ParseError {
  let result = []
  for index, category in children(parent, "category") {
    result.push({
      value: text(category, path + ".category[\{index}]"),
      domain: category.attributes.get("domain"),
    })
  }
  result
}

///|
fn enclosure(parent : Node, path : String) -> Enclosure? raise ParseError {
  try {
    match single(parent, "enclosure", path) {
      None => None
      Some(element) => {
        let path = path + ".enclosure"
        if !element.children.is_empty() || text(element, path) != "" {
          raise InvalidRequiredField(path, "enclosure must be empty")
        }
        Some({
          url: attribute(element, "url", path),
          mime_type: attribute(element, "type", path),
          length: unsigned(attribute(element, "length", path), path + ".length"),
        })
      }
    }
  } catch {
    InvalidRequiredField(path, message) => raise InvalidEnclosure(path, message)
    error => raise error
  }
}

///|
fn parse_item(element : Node, path : String) -> Item raise ParseError {
  let title = optional_text(element, "title", path)
  let description = optional_text(element, "description", path)
  if title is None && description is None {
    raise InvalidRequiredField(
      path + ".title|description",
      "at least one must be present",
    )
  }
  let guid = match single(element, "guid", path) {
    None => None
    Some(element) => {
      let value = text(element, path + ".guid")
      if value.is_empty() {
        raise InvalidRequiredField(path + ".guid", "guid must not be empty")
      }
      let is_permalink = match element.attributes.get("isPermaLink") {
        None | Some("true") => true
        Some("false") => false
        _ =>
          raise InvalidRequiredField(
            path + ".guid.isPermaLink",
            "expected true or false",
          )
      }
      Some({ value, is_permalink, })
    }
  }
  let source = match single(element, "source", path) {
    None => None
    Some(element) =>
      Some({
        title: text(element, path + ".source"),
        url: attribute(element, "url", path + ".source"),
      })
  }
  {
    title,
    description,
    guid,
    source,
    link: optional_text(element, "link", path),
    author: optional_text(element, "author", path),
    categories: categories(element, path),
    comments: optional_text(element, "comments", path),
    enclosure: enclosure(element, path),
    pub_date: optional_text(element, "pubDate", path),
  }
}

///|
/// Parse RSS 2.0 core fields while preserving metadata not represented by Feed.
pub fn parse_raw(input : String) -> Channel raise ParseError {
  let root = document(input)
  if root.name != "rss" || root.attributes.get("xmlns") is Some(_) {
    raise InvalidRequiredField("rss", "expected unnamespaced rss root")
  }
  let version = root.attributes.get("version").unwrap_or("")
  if version != "2.0" {
    raise UnsupportedRssVersion(version)
  }
  let channel = match single(root, "channel", "rss") {
    Some(channel) => channel
    None => raise MissingChannel
  }
  let title = optional_text(channel, "title", "channel").unwrap_or_else(() => {
    raise MissingChannelTitle
  })
  let link = optional_text(channel, "link", "channel").unwrap_or_else(() => {
    raise MissingChannelLink
  })
  let description = optional_text(channel, "description", "channel").unwrap_or_else(() => {
      raise MissingChannelDescription
    },
  )
  let image = match single(channel, "image", "channel") {
    None => None
    Some(element) =>
      Some({
        url: required_text(element, "url", "channel.image"),
        title: required_text(element, "title", "channel.image"),
        link: required_text(element, "link", "channel.image"),
        width: optional_unsigned(element, "width", "channel.image"),
        height: optional_unsigned(element, "height", "channel.image"),
        description: optional_text(element, "description", "channel.image"),
      })
  }
  let items = []
  for index, item in children(channel, "item") {
    items.push(parse_item(item, "channel.item[\{index}]"))
  }
  {
    title,
    link,
    description,
    image,
    items,
    language: optional_text(channel, "language", "channel"),
    pub_date: optional_text(channel, "pubDate", "channel"),
    last_build_date: optional_text(channel, "lastBuildDate", "channel"),
    managing_editor: optional_text(channel, "managingEditor", "channel"),
    web_master: optional_text(channel, "webMaster", "channel"),
    categories: categories(channel, "channel"),
    generator: optional_text(channel, "generator", "channel"),
    docs: optional_text(channel, "docs", "channel"),
    ttl: optional_unsigned(channel, "ttl", "channel"),
  }
}