///|
pub(all) suberror ParseError {
  InvalidXml(String)
  InvalidAtomRoot(String)
  InvalidRequiredField(String, String)
  InvalidDateTime(String, @datetime.DateTimeError)
} derive(Eq, @debug.Debug)

///|
pub fn ParseError::equal(self : ParseError, other : ParseError) -> Bool {
  Eq::equal(self, other)
}

///|
pub fn ParseError::not_equal(self : ParseError, other : ParseError) -> Bool {
  !Eq::equal(self, other)
}

///|
pub fn ParseError::to_repr(self : ParseError) -> @debug.Repr {
  @debug.Debug::to_repr(self)
}

///|
priv enum NodeContent {
  PartText(String)
  PartElement(Node)
}

///|
priv struct Node {
  name : String
  attributes : Map[String, String]
  children : Array[Node]
  content : Array[NodeContent]
  text : StringBuilder
}

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

///|
fn local_name(name : String) -> String {
  if name.has_prefix("atom:") {
    name[5:].to_owned()
  } else {
    name
  }
}

///|
fn is_atom_name(name : String) -> Bool {
  !name.contains(":") || name.has_prefix("atom:")
}

///|
fn named(node : Node, name : String) -> Bool {
  is_atom_name(node.name) && local_name(node.name) == name
}

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

///|
fn doc(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(make_node(element))
      }
      Empty(element) => {
        let child = make_node(element)
        if stack.is_empty() {
          roots.push(child)
        } else {
          stack[stack.length() - 1].children.push(child)
          stack[stack.length() - 1].content.push(PartElement(child))
        }
      }
      End(_) => {
        guard stack.pop() is Some(child) else {
          raise InvalidXml("unexpected closing element")
        }
        if stack.is_empty() {
          roots.push(child)
        } else {
          stack[stack.length() - 1].children.push(child)
          stack[stack.length() - 1].content.push(PartElement(child))
        }
      }
      Text(value) | CData(value) =>
        if !stack.is_empty() {
          stack[stack.length() - 1].text.write_string(value)
          stack[stack.length() - 1].content.push(PartText(value))
        }
      Eof => break
    }
  }
  guard roots.length() == 1 && stack.is_empty() else {
    raise InvalidXml("expected one complete document element")
  }
  roots[0]
}

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

///|
fn plain_text(node : Node) -> String {
  let out = StringBuilder()
  for part in node.content {
    match part {
      PartText(value) => out.write_string(value)
      PartElement(child) => out.write_string(plain_text(child))
    }
  }
  out.to_string().trim(chars=" \t\r\n").to_owned()
}

///|
fn optional_text(
  parent : Node,
  name : String,
  path : String,
) -> String? raise ParseError {
  match one(parent, name, path) {
    Some(node) => Some(plain_text(node))
    None => None
  }
}

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

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

///|
fn optional_attr(node : Node, key : String) -> String? {
  match node.attributes.get(key) {
    Some(value) if !value.trim(chars=" \t\r\n").is_empty() => Some(value)
    _ => None
  }
}

///|
fn text_construct(node : Node) -> String {
  let kind = node.attributes.get("type").unwrap_or("text")
  if kind == "xhtml" {
    plain_text(node)
  } else {
    node.text.to_string().trim(chars=" \t\r\n").to_owned()
  }
}

///|
fn construct(
  parent : Node,
  name : String,
  path : String,
) -> String? raise ParseError {
  match one(parent, name, path) {
    Some(node) => Some(text_construct(node))
    None => None
  }
}

///|
fn persons(parent : Node, name : String) -> Array[@model.Author] {
  let result : Array[@model.Author] = []
  for person in atom_children(parent, name) {
    let author : @model.Author = {
      name: match atom_children(person, "name") {
        [n, ..] => Some(plain_text(n))
        _ => None
      },
      url: match atom_children(person, "uri") {
        [n, ..] => Some(plain_text(n))
        _ => None
      },
      avatar: None,
    }
    result.push(author)
  }
  result
}

///|
fn categories(parent : Node, path : String) -> Array[String] raise ParseError {
  let result = []
  for index, category in atom_children(parent, "category") {
    result.push(attr(category, "term", path + ".category[\{index}]"))
  }
  result
}

///|
fn link_values(
  parent : Node,
  path : String,
) -> (String?, String?, Array[@model.Attachment]) raise ParseError {
  let mut alternate = None
  let mut self_url = None
  let attachments : Array[@model.Attachment] = []
  for index, link in atom_children(parent, "link") {
    let link_path = path + ".link[\{index}]"
    let href = attr(link, "href", link_path)
    let rel = link.attributes.get("rel").unwrap_or("alternate")
    match rel {
      "alternate" => if alternate is None { alternate = Some(href) }
      "self" => if self_url is None { self_url = Some(href) }
      "enclosure" =>
        attachments.push({
          url: href,
          mime_type: optional_attr(link, "type").unwrap_or(
            "application/octet-stream",
          ),
          title: optional_attr(link, "title"),
          size_in_bytes: match optional_attr(link, "length") {
            Some(value) =>
              Some(
                @string.parse_uint64(value) catch {
                  _ =>
                    raise InvalidRequiredField(
                      link_path + ".length",
                      "expected unsigned integer",
                    )
                },
              )
            None => None
          },
          duration_in_seconds: None,
        })
      _ => ()
    }
  }
  (alternate, self_url, attachments)
}

///|
fn entry(
  node : Node,
  index : Int,
  feed_authors : Array[@model.Author],
) -> @model.FeedItem raise ParseError {
  let path = "feed.entry[\{index}]"
  let id = required_text(node, "id", path)
  let title = construct(node, "title", path)
  if title is None || title.unwrap().is_empty() {
    raise InvalidRequiredField(path + ".title", "missing or empty element")
  }
  let updated = required_text(node, "updated", path)
  let updated_at = @datetime.parse_datetime(updated) catch {
    error => raise InvalidDateTime(path + ".updated", error)
  }
  let published = optional_text(node, "published", path)
  let published_at = @datetime.parse_optional_datetime(published)
  let (url, _, attachments) = link_values(node, path)
  let authors = persons(node, "author")
  let summary = construct(node, "summary", path)
  let content = construct(node, "content", path)
  let content_kind = match one(node, "content", path) {
    Some(c) => c.attributes.get("type").unwrap_or("text")
    _ => ""
  }
  {
    id,
    title,
    url,
    external_url: None,
    authors: if authors.is_empty() {
      feed_authors
    } else {
      authors
    },
    summary,
    content_text: if content_kind == "text" {
      content
    } else {
      None
    },
    content_html: if content_kind == "html" || content_kind == "xhtml" {
      content
    } else {
      None
    },
    published,
    published_at,
    updated: Some(updated),
    updated_at: Some(updated_at),
    language: None,
    categories: categories(node, path),
    attachments,
  }
}

///|
/// Parse and normalize an Atom 1.0 feed. Original date strings remain available; successfully parsed dates also carry normalized UTC instants. Invalid required updated dates return typed errors.
pub fn parse(input : String) -> @model.Feed raise ParseError {
  let root = doc(input)
  if !named(root, "feed") {
    raise InvalidAtomRoot(root.name)
  }
  let _feed_id = required_text(root, "id", "feed")
  let title = construct(root, "title", "feed")
  if title is None || title.unwrap().is_empty() {
    raise InvalidRequiredField("feed.title", "missing or empty element")
  }
  let updated = required_text(root, "updated", "feed")
  let updated_at = @datetime.parse_datetime(updated) catch {
    error => raise InvalidDateTime("feed.updated", error)
  }
  let authors = persons(root, "author")
  let (home_page_url, feed_url, _) = link_values(root, "feed")
  let entries = []
  for index, item in atom_children(root, "entry") {
    entries.push(entry(item, index, authors))
  }
  {
    title: title.unwrap(),
    description: construct(root, "subtitle", "feed"),
    home_page_url,
    feed_url,
    language: optional_attr(root, "xml:lang"),
    authors,
    categories: categories(root, "feed"),
    updated: Some(updated),
    updated_at: Some(updated_at),
    items: entries,
  }
}