///|
/// Full report tree, separate from the deliberately lossy comparison Report.
pub struct JUnitDocument {
  root : JUnitElement
  before : ReadOnlyArray[JUnitNode]
  after : ReadOnlyArray[JUnitNode]
  declaration : (String, String?, String?)?
}

///|
pub fn JUnitDocument::new(
  root~ : JUnitElement = JUnitElement::new("testsuites"),
) -> JUnitDocument {
  { root, before: [], after: [], declaration: None, }
}

///|
pub fn JUnitDocument::with_root(
  self : JUnitDocument,
  root : JUnitElement,
) -> JUnitDocument {
  { ..self, root, }
}

///|
priv struct DocumentFrame {
  name : String
  attributes : Array[(String, String)]
  content : Array[JUnitNode]
  core_context : Bool
}

///|
fn document_namespace(
  element : @xml.NamespaceElement,
  parent_core : Bool,
) -> Bool raise InputError {
  let name = element.name.qualified_name
  let core_context = parent_core &&
    (
      name == "testsuite" ||
      name == "testsuites" ||
      name == "testcase" ||
      name == "properties" ||
      name == "property" ||
      name == "system-out" ||
      name == "system-err" ||
      document_result_tag(name)
    )
  if core_context && element.name.namespace_uri != None {
    raise Rejected(
      "unsupported_namespace", "JUnit core elements must be unnamespaced",
    )
  }
  core_context
}

///|
fn document_input(text : String) -> Unit raise InputError {
  if text.length() > 10000000 {
    raise Rejected("limit_exceeded", "XML exceeds 10000000 UTF-16 units")
  }
  check_xml_attribute_budget(text)
}

///|
/// Read a complete XML tree. No DTD, entity loading, file or network access.
pub fn JUnitDocument::from_xml(text : String) -> JUnitDocument raise InputError {
  document_input(text)
  let reader = @xml.NamespaceReader::from_string(text)
  let stack : Array[DocumentFrame] = []
  let top : Array[JUnitNode] = []
  let mut declaration = None
  let mut events = 0
  let mut cases = 0
  while true {
    events = events + 1
    if events > 2000000 {
      raise Rejected("limit_exceeded", "XML event limit exceeded")
    }
    let event = reader.read_event() catch {
      error => raise Rejected("malformed_xml", "\{error}")
    }
    let node : JUnitNode? = match event.kind {
      Start(element) | Empty(element) => {
        let core_context = document_namespace(
          element,
          stack.last().map(frame => frame.core_context).unwrap_or(true),
        )
        if stack.length() >= 128 {
          raise Rejected("limit_exceeded", "XML depth exceeds 128")
        }
        if element.name.qualified_name == "testcase" {
          cases = cases + 1
          if cases > 100000 {
            raise Rejected("limit_exceeded", "more than 100000 testcases")
          }
        }
        let attrs = []
        for ns in element.namespace_declarations {
          let name = match ns.prefix {
            Some(prefix) => "xmlns:\{prefix}"
            None => "xmlns"
          }
          attrs.push((name, ns.namespace_uri))
        }
        for attr in element.attributes {
          attrs.push((attr.name.qualified_name, attr.value))
        }
        match event.kind {
          Start(_) => {
            stack.push({
              name: element.name.qualified_name,
              attributes: attrs,
              content: [],
              core_context,
            })
            None
          }
          _ =>
            Some(
              Element(
                JUnitElement::new(element.name.qualified_name, attributes=attrs),
              ),
            )
        }
      }
      End(_) => {
        guard stack.pop() is Some(frame) else {
          raise InvalidInput("unmatched closing element")
        }
        Some(
          Element(
            JUnitElement::new(
              frame.name,
              attributes=frame.attributes,
              content=frame.content,
            ),
          ),
        )
      }
      Text(value) => Some(Text(value))
      CData(value) => Some(CData(value))
      Comment(value) => Some(Comment(value))
      PI(target~, data~) => Some(Instruction(target, data))
      Decl(version~, encoding~, standalone~) => {
        if version != "1.0" || (encoding is Some(e) && e.to_lower() != "utf-8") {
          raise Rejected(
            "unsupported_encoding", "only XML 1.0 / UTF-8 is supported",
          )
        }
        declaration = Some((version, encoding, standalone))
        None
      }
      DocType(_) => raise Rejected("doctype", "DOCTYPE is unsupported")
      Eof => {
        if !stack.is_empty() {
          raise Rejected("malformed_xml", "unclosed XML elements")
        }
        break
      }
    }
    if node is Some(value) {
      match stack.last() {
        Some(frame) => frame.content.push(value)
        None => top.push(value)
      }
    }
  }
  let before = []
  let after = []
  let mut root = None
  for node in top {
    match node {
      Element(element) => {
        if root is Some(_) {
          raise InvalidInput("multiple XML roots")
        }
        root = Some(element)
      }
      _ => if root is None { before.push(node) } else { after.push(node) }
    }
  }
  guard root is Some(root) else { raise InvalidInput("missing XML root") }
  document_root(root)
  {
    root,
    before: ReadOnlyArray::from_array(before),
    after: ReadOnlyArray::from_array(after),
    declaration,
  }
}

///|
fn document_root(root : JUnitElement) -> Unit raise InputError {
  if root.name != "testsuite" && root.name != "testsuites" {
    raise InvalidInput("JUnit root must be testsuite or testsuites")
  }
}

///|
// xml 0.6.1 exposes write_event but keeps Event construction read-only.
// Obtain a validated event through its reader and verify exact decoded fields;
// malformed or injected markup cannot become a different output event.
fn document_event(kind : @xml.EventKind) -> @xml.Event raise @xml.WriterError {
  let text = match kind {
    PI(target~, data~) =>
      ""
    Decl(version~, encoding~, standalone~) => {
      let out = StringBuilder()
      out.write_string("")
      out.to_string()
    }
    _ => raise @xml.WriterError::InvalidContent("unsupported document event")
  }
  let event = @xml.Reader::from_string(text).read_event() catch {
    _ =>
      raise @xml.WriterError::InvalidContent(
        "invalid document declaration or instruction",
      )
  }
  if event.kind != kind {
    raise @xml.WriterError::InvalidContent(
      "document event changed during validation",
    )
  }
  event
}

///|
priv struct DocumentBudget {
  output : OutputBudget
  mut nodes : Int32
  mut cases : Int32
}

///|
fn document_escaped_units(text : String, attribute : Bool) -> Int64 {
  let mut units = 0L
  for ch in text {
    units = units +
      (match ch {
        '&' => 5L
        '<' | '>' => 4L
        '\'' | '"' => 6L
        '\r' => 5L
        '\n' if attribute => 5L
        '\t' if attribute => 4L
        _ => if ch.to_int() > 65535 { 2L } else { 1L }
      })
  }
  units
}

///|
fn document_measure(
  node : JUnitNode,
  budget : DocumentBudget,
  depth : Int32,
) -> Unit raise InputError {
  budget.nodes = budget.nodes +
    (match node {
      Element(element) => if element.content.is_empty() { 1 } else { 2 }
      Text(text) => if text == "" { 0 } else { 1 }
      _ => 1
    })
  if budget.nodes > 2000000 {
    raise Rejected("limit_exceeded", "XML event limit exceeded")
  }
  match node {
    Element(element) => {
      if depth > 128 {
        raise Rejected("limit_exceeded", "XML depth exceeds 128")
      }
      if element.name == "testcase" {
        budget.cases = budget.cases + 1
        if budget.cases > 100000 {
          raise Rejected("limit_exceeded", "more than 100000 testcases")
        }
      }
      if element.attributes.length() > 1024 {
        raise Rejected("limit_exceeded", "more than 1024 attributes")
      }
      budget.output.add(
        if element.content.is_empty() {
          3L + element.name.length().to_int64()
        } else {
          5L + 2L * element.name.length().to_int64()
        },
      )
      for attr in element.attributes {
        budget.output.add(
          4L + attr.0.length().to_int64() + document_escaped_units(attr.1, true),
        )
      }
      for child in element.content {
        document_measure(child, budget, depth + 1)
      }
    }
    Text(text) => budget.output.add(document_escaped_units(text, false))
    CData(text) => budget.output.add(12L + text.length().to_int64())
    Comment(text) => budget.output.add(7L + text.length().to_int64())
    Instruction(target, data) =>
      budget.output.add(
        4L +
        target.length().to_int64() +
        (if data == "" { 0L } else { 1L + data.length().to_int64() }),
      )
  }
}

///|
fn document_write_node(
  writer : @xml.Writer,
  node : JUnitNode,
) -> Unit raise @xml.WriterError {
  match node {
    Element(element) => {
      let attrs = element.attributes.iter().to_array()
      if element.content.is_empty() {
        writer.empty_element(element.name, attrs)
      } else {
        writer.start_element(element.name, attrs)
        for child in element.content {
          document_write_node(writer, child)
        }
        writer.end_element(element.name)
      }
    }
    Text(text) => writer.text(text)
    CData(text) => writer.cdata(text)
    Comment(text) => writer.comment(text)
    Instruction(target, data) =>
      writer.write_event(document_event(PI(target~, data~)))
  }
}

///|
/// Preserve authored statistics; call with_statistics explicitly after editing.
/// Namespace and XML validation complete before returning any output.
pub fn JUnitDocument::to_xml(
  self : JUnitDocument,
  max_units~ : Int64 = 10000000L,
) -> String raise InputError {
  if max_units > 10000000L {
    raise InvalidInput("XML output limit cannot exceed 10000000 units")
  }
  document_root(self.root)
  let budget = { output: output_budget(max_units), nodes: 1, cases: 0, }
  if self.declaration is Some((version, encoding, standalone)) {
    budget.nodes = budget.nodes + 1
    budget.output.add(
      18L +
      version.length().to_int64() +
      (match encoding {
        Some(value) => 12L + value.length().to_int64()
        None => 0L
      }) +
      (match standalone {
        Some(value) => 14L + value.length().to_int64()
        None => 0L
      }),
    )
    if version != "1.0" || (encoding is Some(e) && e.to_lower() != "utf-8") {
      raise Rejected(
        "unsupported_encoding", "only XML 1.0 / UTF-8 is supported",
      )
    }
  }
  for node in self.before {
    document_measure(node, budget, 1)
  }
  document_measure(Element(self.root), budget, 1)
  for node in self.after {
    document_measure(node, budget, 1)
  }
  let writer = @xml.Writer::new()
  let text = (fn() raise @xml.WriterError {
    if self.declaration is Some((version, encoding, standalone)) {
      writer.write_event(document_event(Decl(version~, encoding~, standalone~)))
    }
    for node in self.before {
      document_write_node(writer, node)
    }
    document_write_node(writer, Element(self.root))
    for node in self.after {
      document_write_node(writer, node)
    }
    writer.to_string()
  })() catch {
    _ =>
      raise Rejected(
        "invalid_xml_output", "invalid XML name, character, content or structure",
      )
  }
  if text.length().to_int64() > max_units {
    raise Rejected("output_limit", "serialized XML exceeds output budget")
  }
  let reader = @xml.NamespaceReader::from_string(text)
  let contexts : Array[Bool] = []
  while true {
    let event = reader.read_event() catch {
      error => raise Rejected("invalid_xml_output", "\{error}")
    }
    match event.kind {
      Start(element) =>
        contexts.push(
          document_namespace(element, contexts.last().unwrap_or(true)),
        )
      Empty(element) =>
        ignore(document_namespace(element, contexts.last().unwrap_or(true)))
      End(_) => ignore(contexts.pop())
      Eof => break
      _ => ()
    }
  }
  text
}

///|
/// Deliver validated XML to a caller-owned file, stream or other sink.
/// Sink errors propagate; atomic file replacement belongs to the caller.
pub fn JUnitDocument::write_to(
  self : JUnitDocument,
  write : (String) -> Unit raise,
  max_units~ : Int64 = 10000000L,
) -> Unit raise {
  write(self.to_xml(max_units~))
}