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

///|
/// Serialization. The format-library verbs: `dumps` writes a tree to text,
/// `dump` answers bytes, `loads` and `load` read one back (those live with
/// their parsers, in `moonxml/xml` and `moonxml/html`).
///
/// Two rule sets, because there are two languages. The XML writer is exact:
/// attribute order, CDATA, comments, processing instructions and the doctype
/// survive, and a well-formed document that is not edited comes back
/// byte-identical — the one deviation is that character references are
/// decoded, as a DOM means them to be. The HTML writer follows WHATWG's
/// serialization rules, and its criterion is the fixpoint: write, read, write
/// again gives the same bytes.

///|
/// The XML serialization of a node.
pub fn dumps(node : Node) -> String {
  let out = StringBuilder()
  write_xml(node, out)
  out.to_string()
}

///|
/// The XML serialization of a node, as bytes.
pub fn dump(node : Node) -> Bytes {
  @utf8.encode(dumps(node))
}

///|
/// The HTML serialization of a node, WHATWG's rules.
pub fn dumps_html(node : Node) -> String {
  let out = StringBuilder()
  write_html(node, out, raw=false, scripting=false)
  out.to_string()
}

///|
/// The HTML serialization of a node, as bytes.
pub fn dump_html(node : Node) -> Bytes {
  @utf8.encode(dumps_html(node))
}

///|
/// A whole document, in the flavour it was read as.
pub fn Document::dumps(self : Document) -> String {
  match self.flavour {
    Xml => {
      let out = StringBuilder()
      for child in self.root.children {
        write_xml(child, out)
      }
      out.to_string()
    }
    Html => self.dumps_html()
  }
}

///|
/// A whole document as bytes, in the flavour it was read as.
pub fn Document::dump(self : Document) -> Bytes {
  @utf8.encode(self.dumps())
}

///|
/// A whole document, HTML rules whatever it was read as.
pub fn Document::dumps_html(self : Document) -> String {
  let out = StringBuilder()
  let scripting = self.scripting()
  for child in self.root.children {
    write_html(child, out, raw=false, scripting~)
  }
  out.to_string()
}

///|
/// A whole document as bytes, by HTML's rules.
pub fn Document::dump_html(self : Document) -> Bytes {
  @utf8.encode(self.dumps_html())
}

// The XML writer.

///|
fn write_xml(node : Node, out : StringBuilder) -> Unit {
  write_xml_in(node, out, [])
}

///|
/// Write one node given the space declarations in force: `{uri}local`
/// becomes a prefixed name again, using the prefix the document declared when
/// there is one and inventing `ns1` when there is not.
fn write_xml_in(
  node : Node,
  out : StringBuilder,
  scope : Array[(String, String)],
) -> Unit {
  match node {
    ElementNode(data) => {
      let local_scope = scope.copy()
      for pair in data.attrs {
        if pair.0 == "xmlns" {
          local_scope.push(("", pair.1))
        } else if pair.0.has_prefix("xmlns:") {
          local_scope.push((pair.0[6:pair.0.length()].to_owned(), pair.1))
        }
      }
      let declared : Array[(String, String)] = []
      let tag = qualify(data.name, local_scope, declared, false)
      out.write_string("<" + tag)
      for pair in declared {
        out.write_string(
          " xmlns:" + pair.0 + "=\"" + escape_xml_attr(pair.1) + "\"",
        )
      }
      for pair in data.attrs {
        out.write_string(
          " " +
          qualify(pair.0, local_scope, declared, true) +
          "=\"" +
          escape_xml_attr(pair.1) +
          "\"",
        )
      }
      if data.children.is_empty() && data.closed {
        out.write_string("/>")
        return
      }
      out.write_string(">")
      for child in data.children {
        write_xml_in(child, out, local_scope)
      }
      out.write_string(
        "",
      )
    }
    TextNode(text) => out.write_string(escape_xml_text(text))
    CDataNode(text) => out.write_string("")
    CommentNode(text) => out.write_string("")
    ProcessingInstructionNode(pi) =>
      out.write_string("")
    DoctypeNode(text) => out.write_string("")
    // Attributes and namespaces are not children of anything, so there is no
    // position in a document for them to be written at.
    AttributeNode(_) | NamespaceNode(_) => ()
  }
}

///|
/// A name as it is written: unchanged when it carries no space, prefixed
/// when it does. A name whose space nothing declares gets a declaration on
/// the element being written, collected in `declared`.
fn qualify(
  name : String,
  scope : Array[(String, String)],
  declared : Array[(String, String)],
  is_attr : Bool,
) -> String {
  let uri = match namespace_of(name) {
    Some(found) => found
    None => return name
  }
  let tail = local_of(name)
  for pair in scope {
    if pair.1 == uri && (!is_attr || !pair.0.is_empty()) {
      if pair.0.is_empty() {
        return tail
      }
      return pair.0 + ":" + tail
    }
  }
  let mut k = 1
  let mut prefix = ""
  let mut chosen = false
  while !chosen {
    prefix = "ns\{k}"
    let mut taken = false
    for pair in scope {
      if pair.0 == prefix {
        taken = true
      }
    }
    chosen = !taken
    if !chosen {
      k = k + 1
    }
  }
  scope.push((prefix, uri))
  declared.push((prefix, uri))
  prefix + ":" + tail
}

// The HTML writer, WHATWG's serialization rules.

///|
fn write_html(
  node : Node,
  out : StringBuilder,
  raw~ : Bool,
  scripting~ : Bool,
) -> Unit {
  match node {
    ElementNode(data) => {
      let foreign = data.name.has_prefix("{")
      let tag = if foreign { local_of(data.name) } else { data.name }
      out.write_string("<" + tag)
      for pair in data.attrs {
        let value = pair.1
        let name = html_attribute_name(pair.0)
        if value.is_empty() {
          out.write_string(" " + name)
        } else {
          out.write_string(" " + name + "=\"" + escape_html_attr(value) + "\"")
        }
      }
      out.write_string(">")
      // A void element never opens; a raw text element's children are written
      // exactly as they are. Foreign elements answer neither rule.
      if !foreign && is_void_element(tag) {
        return
      }
      // The reader drops one newline right after these tags' start tag, so one
      // has to be written for it to drop.
      if !foreign && keeps_leading_newline(tag) {
        match data.children {
          [TextNode(text), ..] =>
            if text.has_prefix("\n") {
              out.write_string("\n")
            }
          _ => ()
        }
      }
      // With scripting on, a `noscript`'s content is raw text, because that is
      // how it was read: escaping it would put the escapes in the text.
      let raw_here = !foreign &&
        (is_raw_text_element(tag) || (tag == "noscript" && scripting))
      for child in data.children {
        write_html(child, out, raw=raw_here || raw, scripting~)
      }
      // A template's children are its contents, and they belong inside the
      // element when it is written.
      match data.content() {
        Some(fragment) =>
          for child in fragment.children {
            write_html(child, out, raw=raw_here || raw, scripting~)
          }
        None => ()
      }
      out.write_string("")
    }
    TextNode(text) =>
      if raw {
        out.write_string(text)
      } else {
        out.write_string(escape_html_text(text))
      }
    CDataNode(text) =>
      if raw {
        out.write_string(text)
      } else {
        out.write_string(escape_html_text(text))
      }
    CommentNode(text) => out.write_string("")
    ProcessingInstructionNode(pi) =>
      out.write_string("")
    DoctypeNode(text) => write_doctype(text, out)
    // Attributes and namespaces are not children of anything, so there is no
    // position in a document for them to be written at.
    AttributeNode(_) | NamespaceNode(_) => ()
  }
}

///|
/// The tags whose content the reader starts one newline later: a newline right
/// after the start tag is dropped, the way an author's line break is.
fn keeps_leading_newline(tag : String) -> Bool {
  tag == "pre" || tag == "textarea" || tag == "listing"
}

///|
/// An attribute name as HTML spells it. A foreign attribute is a Clark name in
/// the tree; HTML has prefixes for exactly three namespaces, so those get
/// theirs back and anything else is written by its local name.
fn html_attribute_name(name : String) -> String {
  match namespace_of(name) {
    Some(uri) =>
      match uri {
        "http://www.w3.org/1999/xlink" => "xlink:" + local_of(name)
        "http://www.w3.org/XML/1998/namespace" => "xml:" + local_of(name)
        "http://www.w3.org/2000/xmlns/" => "xmlns:" + local_of(name)
        _ => local_of(name)
      }
    None => name
  }
}

///|
/// A document type declaration as HTML spells it: the name, then `PUBLIC` with
/// both identifiers or `SYSTEM` with the one. The tree keeps the declaration
/// the way the test corpus writes it — `name "public" "system"` — so the
/// keywords have to be put back.
fn write_doctype(text : String, out : StringBuilder) -> Unit {
  let space = match text.find(" ") {
    Some(at) => at
    None => -1
  }
  if space < 0 {
    out.write_string("")
    return
  }
  let name = text[0:space].to_owned()
  let rest = text[space + 1:text.length()].trim().to_owned()
  match quoted_parts(rest) {
    Some(pair) => {
      let (public_id, system_id) = pair
      if public_id.is_empty() {
        out.write_string("")
      } else {
        out.write_string(
          "",
        )
      }
    }
    None => out.write_string("")
  }
}

///|
/// The two quoted identifiers of a doctype, or nothing when they are not two
/// quoted strings — an identifier that carries a quote of its own cannot be
/// told from the closing one.
fn quoted_parts(text : String) -> (String, String)? {
  let n = text.length()
  if n < 2 || text.at(0) != 0x22 || text.at(n - 1) != 0x22 {
    return None
  }
  // The public identifier ends at the first quote followed by a space, the
  // system one at the last quote.
  let mut i = 1
  while i < n - 1 {
    if text.at(i) == 0x22 && text.at(i + 1) == 0x20 {
      // Past the closing quote, the space and the next opening quote.
      return Some((text[1:i].to_owned(), text[i + 3:n - 1].to_owned()))
    }
    i = i + 1
  }
  None
}

///|
/// The elements HTML leaves open on purpose, the list WHATWG's serialisation
/// gives (§13.3): the end tag is never written for them. SVG's handful rides
/// along as foreign content, which the writer knows by the `{` on the name.
fn is_void_element(tag : String) -> Bool {
  match tag {
    "area"
    | "base"
    | "basefont"
    | "bgsound"
    | "br"
    | "col"
    | "embed"
    | "frame"
    | "hr"
    | "img"
    | "input"
    | "keygen"
    | "link"
    | "meta"
    | "param"
    | "source"
    | "track"
    | "wbr" => true
    _ => false
  }
}

///|
/// The elements whose text is written exactly as it stands.
fn is_raw_text_element(tag : String) -> Bool {
  match tag {
    "script"
    | "style"
    | "xmp"
    | "iframe"
    | "noembed"
    | "noframes"
    | "plaintext" => true
    _ => false
  }
}

///|
/// The local part of a `{uri}local` name.
pub fn local_of(name : String) -> String {
  match rfind(name, '}') {
    Some(at) => name[at + 1:name.length()].to_owned()
    None => name
  }
}

///|
/// The URI of a `{uri}local` name, or `None` when the name carries none.
pub fn namespace_of(name : String) -> String? {
  if name.has_prefix("{") {
    match rfind(name, '}') {
      Some(at) => Some(name[1:at].to_owned())
      None => None
    }
  } else {
    None
  }
}

///|
/// The last place a character occurs, if it does.
fn rfind(text : String, unit : Char) -> Int32? {
  let mut i = text.length() - 1
  while i >= 0 {
    if char_at(text, i) == unit {
      return Some(i)
    }
    i = i - 1
  }
  None
}

///|
fn char_at(text : String, at : Int32) -> Char {
  match text.at(at).to_char() {
    Some(c) => c
    None => '?'
  }
}

///|
/// Text escapes `&` and `<` always; a `>` only when it would close a CDATA
/// section that is not one — the minimal escape that keeps the text legal.
fn escape_xml_text(text : String) -> String {
  if !(text.contains("&") ||
    text.contains("<") ||
    text.contains(">") ||
    text.contains("\r")) {
    return text
  }
  let out = StringBuilder()
  let mut run = 0
  for ch in text {
    if ch == '&' {
      out.write_string("&")
      run = 0
    } else if ch == '<' {
      out.write_string("<")
      run = 0
    } else if ch == '\r' {
      // A reader turns a raw carriage return into a line feed; one that came
      // in as a reference is still a carriage return here, and only a
      // reference keeps it one.
      out.write_string("
")
      run = 0
    } else if ch == '>' && run >= 2 {
      out.write_string(">")
      run = 0
    } else {
      out.write_char(ch)
      run = if ch == ']' { run + 1 } else { 0 }
    }
  }
  out.to_string()
}

///|
/// Attribute values escape the quote they are written in, plus `&` and `<`,
/// and the three characters a reader normalises to spaces inside an attribute
/// value (XML 1.0 §3.3.3), which survive only as references.
fn escape_xml_attr(text : String) -> String {
  if !(text.contains("&") ||
    text.contains("<") ||
    text.contains("\"") ||
    text.contains("\t") ||
    text.contains("\n") ||
    text.contains("\r")) {
    return text
  }
  let out = StringBuilder()
  for ch in text {
    match ch {
      '&' => out.write_string("&")
      '<' => out.write_string("<")
      '"' => out.write_string(""")
      '\t' => out.write_string("	")
      '\n' => out.write_string("
")
      '\r' => out.write_string("
")
      _ => out.write_char(ch)
    }
  }
  out.to_string()
}

///|
/// WHATWG's text escaping: `&`, `<`, `>` and a no-break space, which has no
/// business sitting raw in a document that may be re-read as Latin-1.
fn escape_html_text(text : String) -> String {
  if !(text.contains("&") ||
    text.contains("<") ||
    text.contains(">") ||
    text.contains("\r") ||
    text.contains("\u{A0}")) {
    return text
  }
  let out = StringBuilder()
  for ch in text {
    match ch {
      '&' => out.write_string("&")
      '<' => out.write_string("<")
      '>' => out.write_string(">")
      // The reader folds a carriage return into a line feed, so a text that
      // carries one has to write it as a reference.
      '\r' => out.write_string("
")
      _ =>
        if ch == '\u{A0}' {
          out.write_string(" ")
        } else {
          out.write_char(ch)
        }
    }
  }
  out.to_string()
}

///|
/// WHATWG's attribute-value escaping: `&`, the no-break space and the quote.
fn escape_html_attr(text : String) -> String {
  if !(text.contains("&") ||
    text.contains("\"") ||
    text.contains("\r") ||
    text.contains("\u{A0}")) {
    return text
  }
  let out = StringBuilder()
  for ch in text {
    match ch {
      '&' => out.write_string("&")
      '"' => out.write_string(""")
      '\r' => out.write_string("
")
      _ =>
        if ch == '\u{A0}' {
          out.write_string(" ")
        } else {
          out.write_char(ch)
        }
    }
  }
  out.to_string()
}