///|
/// Map structural cases in source order. None removes a case. Extension
/// subtrees are opaque; cases directly inside the root testsuites are supported.
pub fn JUnitDocument::map_cases(
  self : JUnitDocument,
  transform : (JUnitElement) -> JUnitElement? raise,
) -> JUnitDocument raise {
  ignore(self.to_xml())
  fn visit(element : JUnitElement) -> JUnitElement raise {
    let content = []
    for node in element.content {
      match node {
        Element(child) if child.name == "testcase" =>
          if transform(child) is Some(replacement) {
            if replacement.name != "testcase" {
              raise InvalidInput("map_cases must return testcase or None")
            }
            content.push(Element(replacement))
          }
        Element(child) if child.name == "testsuite" =>
          content.push(Element(visit(child)))
        _ => content.push(node)
      }
    }
    element.with_content(content)
  }
  let result = self.with_root(visit(self.root))
  ignore(result.to_xml())
  result
}

///|
/// Apply a transformation to top-level suites only. A bare suite is wrapped
/// in testsuites when removed. Nested suites remain editable via with_content.
pub fn JUnitDocument::map_suites(
  self : JUnitDocument,
  transform : (JUnitElement) -> JUnitElement? raise,
) -> JUnitDocument raise {
  ignore(self.to_xml())
  fn apply(suite : JUnitElement) -> JUnitElement? raise {
    let result = transform(suite)
    if result is Some(element) && element.name != "testsuite" {
      raise InvalidInput("map_suites must return testsuite or None")
    }
    result
  }
  let result = if self.root.name == "testsuite" {
    self.with_root(apply(self.root).unwrap_or(JUnitElement::new("testsuites")))
  } else {
    let content = []
    for node in self.root.content {
      match node {
        Element(child) if child.name == "testsuite" =>
          if apply(child) is Some(value) {
            content.push(Element(value))
          }
        _ => content.push(node)
      }
    }
    self.with_root(self.root.with_content(content))
  }
  ignore(result.to_xml())
  result
}

///|
/// Comparison projection deliberately retains the existing strict 0.2 profile.
pub fn JUnitDocument::to_report(
  self : JUnitDocument,
  source~ : String = "",
) -> Report raise InputError {
  parse_document(self.to_xml(), source~)
}

///|
priv struct DocumentStats {
  mut tests : Int32
  mut failures : Int32
  mut errors : Int32
  mut skipped : Int32
  mut time : Int64?
}

///|
fn document_stats() -> DocumentStats {
  { tests: 0, failures: 0, errors: 0, skipped: 0, time: None, }
}

///|
fn DocumentStats::add(
  self : DocumentStats,
  other : DocumentStats,
) -> Unit raise InputError {
  self.tests = self.tests + other.tests
  self.failures = self.failures + other.failures
  self.errors = self.errors + other.errors
  self.skipped = self.skipped + other.skipped
  if other.time is Some(value) {
    let before = self.time.unwrap_or(0L)
    if value > 9223372036854775807L - before {
      raise Rejected(
        "invalid_time", "aggregate duration overflows Int64 microseconds",
      )
    }
    self.time = Some(before + value)
  }
}

///|
// Full Int64 range for document statistics; the comparison profile retains
// its own lower duration ceiling. Fractional digits beyond six are floored.
fn document_time_us(value : String) -> Int64? raise InputError {
  if value == "" {
    return None
  }
  let mut whole = 0L
  let mut fraction = 0L
  let mut decimals = 0
  let mut digits = 0
  let mut dot = false
  for ch in value {
    if ch == '.' && !dot {
      dot = true
      continue
    }
    if ch < '0' || ch > '9' {
      raise Rejected("invalid_time", "invalid decimal duration: \{value}")
    }
    let digit = (ch.to_int() - 48).to_int64()
    if dot {
      if decimals < 6 {
        fraction = fraction * 10L + digit
      }
      decimals = decimals + 1
    } else {
      if whole > 9223372036854L / 10L ||
        (whole == 9223372036854L / 10L && digit > 9223372036854L % 10L) {
        raise Rejected("invalid_time", "duration overflows Int64 microseconds")
      }
      whole = whole * 10L + digit
      digits = digits + 1
    }
  }
  if digits == 0 || (dot && decimals == 0) {
    raise Rejected("invalid_time", "invalid decimal duration: \{value}")
  }
  while decimals < 6 {
    fraction = fraction * 10L
    decimals = decimals + 1
  }
  if whole == 9223372036854L && fraction > 775807L {
    raise Rejected("invalid_time", "duration overflows Int64 microseconds")
  }
  Some(whole * 1000000L + fraction)
}

///|
fn document_count_case(
  element : JUnitElement,
) -> DocumentStats raise InputError {
  let stats = document_stats()
  stats.tests = 1
  stats.time = document_time_us(element.attribute("time").unwrap_or(""))
  let mut failure = false
  let mut error = false
  let mut skipped = false
  for child in element.elements() {
    match child.name {
      "failure" => failure = true
      "error" => error = true
      "skipped" => skipped = true
      _ => ()
    }
  }
  if error {
    stats.errors = 1
  } else if failure {
    stats.failures = 1
  } else if skipped {
    stats.skipped = 1
  }
  stats
}

///|
/// Recount structural cases (error > failure > skipped > passed), not result
/// elements. Preserve suite wall time unless overwrite_time is true. Missing
/// case times contribute no information; a partial sum is only known case time.
pub fn JUnitDocument::with_statistics(
  self : JUnitDocument,
  overwrite_time~ : Bool = false,
) -> JUnitDocument raise InputError {
  ignore(self.to_xml())
  fn visit(
    element : JUnitElement,
  ) -> (JUnitElement, DocumentStats) raise InputError {
    let stats = document_stats()
    let content = []
    for node in element.content {
      match node {
        Element(child) if child.name == "testcase" => {
          stats.add(document_count_case(child))
          content.push(node)
        }
        Element(child) if child.name == "testsuite" => {
          let (updated, child_stats) = visit(child)
          stats.add(child_stats)
          content.push(Element(updated))
        }
        _ => content.push(node)
      }
    }
    if !overwrite_time {
      if document_time_us(element.attribute("time").unwrap_or(""))
        is Some(value) {
        stats.time = Some(value)
      }
    }
    let mut result = element
      .with_content(content)
      .with_attribute("tests", stats.tests.to_string())
      .with_attribute("failures", stats.failures.to_string())
      .with_attribute("errors", stats.errors.to_string())
      .with_attribute("skipped", stats.skipped.to_string())
    result = match stats.time {
      Some(value) => result.with_attribute("time", document_seconds(value))
      None => result.without_attribute("time")
    }
    (result, stats)
  }
  let (root, _) = visit(self.root)
  let result = self.with_root(root)
  ignore(result.to_xml())
  result
}

///|
fn document_aggregate_attribute(name : String) -> Bool {
  match name {
    "tests"
    | "failures"
    | "errors"
    | "skipped"
    | "time"
    | "disabled"
    | "assertions" => true
    _ => false
  }
}

///|
fn document_same_misc(
  a : ReadOnlyArray[JUnitNode],
  b : ReadOnlyArray[JUnitNode],
) -> Bool {
  if a.length() != b.length() {
    return false
  }
  for i = 0; i < a.length(); i = i + 1 {
    let same = match (a[i], b[i]) {
      (Text(x), Text(y)) | (Comment(x), Comment(y)) | (CData(x), CData(y)) =>
        x == y
      (Instruction(t, x), Instruction(u, y)) => t == u && x == y
      _ => false
    }
    if !same {
      return false
    }
  }
  true
}

///|
/// Concatenate report documents without coalescing suites, deduplicating cases
/// or interpreting retries. Conflicting testsuites metadata, namespace bindings
/// or outside-root content is rejected. Resolve conflicts explicitly first.
/// New root aggregate attributes are omitted; use with_statistics to recount.
pub fn merge_documents(
  documents : Array[JUnitDocument],
) -> JUnitDocument raise InputError {
  if documents.is_empty() {
    return JUnitDocument::new()
  }
  for document in documents {
    ignore(document.to_xml())
  }
  if documents.length() == 1 {
    return documents[0]
  }
  let first = documents[0]
  let mut metadata : JUnitElement? = None
  let content = []
  for i = 0; i < documents.length(); i = i + 1 {
    let document = documents[i]
    if !document_same_misc(first.before, document.before) ||
      !document_same_misc(first.after, document.after) ||
      first.declaration != document.declaration {
      raise Rejected(
        "merge_conflict",
        "document \{i}: outside-root content or declaration differs",
      )
    }
    let root = document.root
    if root.name == "testsuite" {
      content.push(Element(root))
    } else {
      let attrs = root.attributes
        .iter()
        .filter(pair => !document_aggregate_attribute(pair.0))
        .to_array()
      let current = JUnitElement::new("testsuites", attributes=attrs)
      match metadata {
        None => metadata = Some(current)
        Some(previous) =>
          if previous.attributes.length() != current.attributes.length() ||
            current.attributes
            .iter()
            .any(pair => previous.attribute(pair.0) != Some(pair.1)) {
            raise Rejected(
              "merge_conflict",
              "document \{i}: testsuites metadata or namespace bindings differ",
            )
          }
      }
      for node in root.content {
        content.push(node)
      }
    }
  }
  let root = metadata
    .unwrap_or(JUnitElement::new("testsuites"))
    .with_content(content)
  let result = { ..first, root, }
  ignore(result.to_xml())
  result
}