///|
fn child_payload(
  doc : Document,
  ctx : SchemaContext,
  parent : Int32,
  tag : String,
) -> String? {
  if parent < 0 || parent >= doc.nodes.length() {
    None
  } else {
    ctx.metadata[parent].get(tag)
  }
}

///|
fn inherited_language(
  doc : Document,
  ctx : SchemaContext,
  index : Int32,
) -> String {
  let mut current = Some(index)
  while current is Some(i) {
    if child_payload(doc, ctx, i, "LANG") is Some(s) {
      return s.to_lower()
    }
    current = doc.nodes[i].parent
  }
  child_payload(doc, ctx, 0, "LANG").unwrap_or("und").to_lower()
}

///|
fn translation_format(
  doc : Document,
  ctx : SchemaContext,
  index : Int32,
  parent : Int32,
) -> (String, String) {
  let language = child_payload(doc, ctx, index, "LANG")
    .map(fn(s) { s.to_lower() })
    .unwrap_or(inherited_language(doc, ctx, parent))
  let is_file = ctx.types[index] == Some("FILE-TRAN") ||
    ctx.types[index] == Some("FILE")
  let format = if is_file { "FORM" } else { "MIME" }
  let media = child_payload(doc, ctx, index, format).unwrap_or(
    child_payload(doc, ctx, parent, format).unwrap_or("text/plain"),
  )
  (language, media_identity(media))
}

///|
fn audit_semantics(doc : Document, ctx : SchemaContext) -> Array[Diagnostic] {
  let out = []
  let references : Map[String, Int32] = Map([])
  let owner : Array[Int32] = []
  let links : Map[String, Bool] = Map([])
  let translations : Map[String, Int32] = Map([])
  // Materialize pointers once, including reverse-link evidence. No quadratic
  // scan across all records and no recursion on attacker-controlled graphs.
  for i, node in doc.nodes {
    owner.push(
      match node.parent {
        Some(p) => owner[p]
        None => i
      },
    )
    if node.payload is Pointer(id) && node.parent is Some(p) {
      links.set(p.to_string() + "/" + node.tag + "/" + id, true)
    }
  }
  let cycle_edges : Map[Int32, Array[(Int32, Int32)]] = Map([])
  for i, node in doc.nodes {
    let t = ctx.types[i].unwrap_or("?")
    if standard_enum_uris.contains(t) && node.payload is Text(s) {
      for value in s.split(",") {
        let value = trim_spaces(value.to_owned())
        if value.has_prefix("_") {
          out.push(
            diagnostic(
              "enum-extension-unassessed",
              node.line,
              "extension enumeration is retained without certifying its external meaning",
              severity=Warning,
            ),
          )
        }
      }
    }
    if t == "MIME" && node.payload is Text(s) {
      if !s.to_lower().has_prefix("text/") {
        out.push(
          diagnostic(
            "note-mime-kind",
            node.line,
            "NOTE and SNOTE MIME must be a text media type",
          ),
        )
      }
      if s.to_lower() == "text/html" &&
        node.parent is Some(p) &&
        doc.nodes[p].payload is Text(text) &&
        noncore_html_entity(text) {
        out.push(
          diagnostic(
            "html-entity",
            doc.nodes[p].line,
            "HTML note contains non-core named entities; export requires their Unicode characters",
          ),
        )
      }
    }
    if t == "NO" &&
      node.payload is Text(value) &&
      !value.has_prefix("_") &&
      node.parent is Some(p) {
      let edges = standard_children.get_or_default(
        ctx.types[p].unwrap_or("?"),
        [],
      )
      if !edges.any(fn(e) { e.0 == value }) {
        out.push(
          diagnostic(
            "no-event-context",
            node.line,
            "NO event must be legal in its enclosing structure",
          ),
        )
      }
    }
    if ["FAM-HUSB", "FAM-WIFE", "CHIL"].contains(t) &&
      node.payload is Pointer(id) &&
      doc.lookup(id) is Some(person) {
      let family = owner[i]
      let back = if t == "CHIL" { "FAMC" } else { "FAMS" }
      let reciprocal = match doc.nodes[family].xref {
        Some(fid) => links.contains(person.to_string() + "/" + back + "/" + fid)
        None => false
      }
      if !reciprocal {
        out.push(
          diagnostic(
            "required-reciprocal-link",
            node.line,
            "FAM membership requires INDI." +
            back +
            " pointing back to this family",
            related_line=doc.nodes[person].line,
          ),
        )
      }
    }
    if t == "REFN" && node.payload is Text(s) && node.parent is Some(p) {
      if references.get(s) is Some(previous) &&
        doc.nodes[previous].parent != Some(p) {
        out.push(
          diagnostic(
            "refn-reuse",
            node.line,
            "REFN must not identify distinct structures describing different aspects",
            related_line=doc.nodes[previous].line,
          ),
        )
      } else {
        references.set(s, i)
      }
    }
    if t == "NOTE-TRAN" ||
      t == "NAME-TRAN" ||
      t == "PLAC-TRAN" ||
      t == "FILE-TRAN" {
      if node.parent is Some(p) {
        if t == "NOTE-TRAN" &&
          child_payload(doc, ctx, i, "LANG") == None &&
          child_payload(doc, ctx, i, "MIME") == None {
          out.push(
            diagnostic(
              "translation-format",
              node.line,
              "note translation requires LANG or MIME",
            ),
          )
        }
        let format = translation_format(doc, ctx, i, p)
        if format == translation_format(doc, ctx, p, p) {
          out.push(
            diagnostic(
              "translation-distinct",
              node.line,
              "translation must differ in language or media type from its superstructure",
              related_line=doc.nodes[p].line,
            ),
          )
        }
        let key = p.to_string() +
          "/" +
          Json::array([Json::string(format.0), Json::string(format.1)]).stringify()
        if translations.get(key) is Some(sibling) {
          out.push(
            diagnostic(
              "translation-duplicate",
              node.line,
              "translations must have distinct effective language/media-type pairs",
              related_line=doc.nodes[sibling].line,
            ),
          )
        } else {
          translations.set(key, i)
        }
      }
    }
    if node.payload is Pointer(id) &&
      doc.lookup(id) is Some(target) &&
      ctx.types[target] != None {
      let source = owner[i]
      let a = ctx.types[source].unwrap_or("?")
      let b = ctx.types[target].unwrap_or("?")
      if (a == "record-SOUR" && (b == "record-OBJE" || b == "record-SNOTE")) ||
        (b == "record-SOUR" && (a == "record-OBJE" || a == "record-SNOTE")) {
        let list = cycle_edges.get_or_default(source, [])
        list.push((target, i))
        cycle_edges.set(source, list)
      }
    }
    if t == "MIME" &&
      node.payload is Text(s) &&
      s != "text/plain" &&
      s != "text/html" {
      out.push(
        diagnostic(
          "note-media-type",
          node.line,
          "unsupported note media type should be treated as text/plain",
          severity=Warning,
        ),
      )
    }
    if node.tag.has_prefix("_") && ctx.types[i] == None {
      out.push(
        diagnostic(
          "extension-unassessed",
          node.line,
          "extension data is preserved; its external semantic definition is not certified",
          severity=Warning,
        ),
      )
    }
  }
  let colors : Map[Int32, Int32] = Map([])
  for start in doc.records {
    if colors.get_or_default(start, 0) != 0 {
      continue
    }
    let stack = [(start, 0)]
    colors.set(start, 1)
    while stack.length() > 0 {
      let k = stack.length() - 1
      let (current, next) = stack[k]
      let edges = cycle_edges.get_or_default(current, [])
      if next >= edges.length() {
        colors.set(current, 2)
        stack.remove(k) |> ignore
        continue
      }
      stack[k] = (current, next + 1)
      let (target, line_index) = edges[next]
      let color = colors.get_or_default(target, 0)
      if color == 1 {
        out.push(
          diagnostic(
            "source-reference-cycle",
            doc.nodes[line_index].line,
            "source/note/media references must not form a cycle",
            related_line=doc.nodes[target].line,
          ),
        )
      } else if color == 0 {
        colors.set(target, 1)
        stack.push((target, 0))
      }
    }
  }
  out
}