///|
pub(all) enum Severity {
  Error
  Warning
} derive(Eq, @debug.Debug)

///|
pub extend Severity with Eq::{equal, not_equal}

///|
pub extend Severity with @debug.Debug::{to_repr}

///|
pub struct Diagnostic {
  severity : Severity
  code : String
  line : Int32
  related_line : Int32?
  message : String
} derive(Eq, @debug.Debug)

///|
pub extend Diagnostic with Eq::{equal, not_equal}

///|
pub extend Diagnostic with @debug.Debug::{to_repr}

///|
fn diagnostic(
  code : String,
  line : Int32,
  message : String,
  severity~ : Severity = Error,
  related_line? : Int32,
) -> Diagnostic {
  { severity, code, line, related_line, message, }
}

///|
/// A standard-looking tag below a tagged extension is extension-defined (ยง1.5).
/// A single preorder pass keeps that scope local to its own subtree.
fn standard_contexts(doc : Document) -> Array[Bool] {
  let contexts : Array[Bool] = []
  for node in doc.nodes {
    let parent_standard = match node.parent {
      Some(p) => contexts[p]
      None => true
    }
    contexts.push(!node.tag.has_prefix("_") && parent_standard)
  }
  contexts
}

///|
fn target_record(doc : Document, index : Int32) -> String? {
  let node = doc.nodes[index]
  guard node.parent is Some(parent) else { return None }
  let owner = doc.nodes[parent]
  match node.tag {
    "FAMC" | "FAMS" => Some("FAM")
    "HUSB" | "WIFE" =>
      if owner.level == 0 && owner.tag == "FAM" {
        Some("INDI")
      } else {
        None
      }
    "CHIL" | "ASSO" | "ALIA" => Some("INDI")
    "SUBM" => Some("SUBM")
    "SOUR" => if owner.tag == "HEAD" { None } else { Some("SOUR") }
    "SNOTE" => Some("SNOTE")
    "REPO" => Some("REPO")
    "OBJE" => Some("OBJE")
    _ => None
  }
}

///|
/// Container, standard schema, datatypes and machine-checkable semantic rules.
/// Unknown structures remain in the document and are never discarded here.
pub fn audit(doc : Document) -> Array[Diagnostic] {
  let out = []
  let standard = standard_contexts(doc)
  let first = doc.nodes[doc.records[0]]
  let last = doc.nodes[doc.records[doc.records.length() - 1]]
  if first.tag != "HEAD" {
    out.push(
      diagnostic("missing-header", first.line, "first record must be HEAD"),
    )
  }
  if last.tag != "TRLR" {
    out.push(
      diagnostic("missing-trailer", last.line, "last record must be TRLR"),
    )
  }
  let seen : Map[String, Int32] = Map([])
  let declared : Map[String, Bool] = Map([])
  if first.tag == "HEAD" {
    let gedc = doc.select(0, "GEDC")
    if gedc.length() != 1 {
      out.push(
        diagnostic(
          "header-version",
          first.line,
          "HEAD requires exactly one GEDC",
        ),
      )
    } else {
      let vers = doc.select(gedc[0], "VERS")
      if vers.length() != 1 || !supported_version(doc.nodes[vers[0]].payload) {
        out.push(
          diagnostic(
            "unsupported-version",
            doc.nodes[gedc[0]].line,
            "only GEDCOM 7.0 UTF-8 text is supported",
          ),
        )
      }
    }
    for schma in doc.select(0, "SCHMA") {
      for index in doc.select(schma, "TAG") {
        match doc.nodes[index].payload {
          Text(s) => {
            let parts = s.split(" ").to_array()
            if parts.length() == 2 &&
              valid_tag(parts[0].to_owned()) &&
              parts[0].has_prefix("_") &&
              valid_uri_reference(parts[1].to_owned()) {
              let tag = parts[0].to_owned()
              if !documented_uri(parts[1]) {
                out.push(
                  diagnostic(
                    "relative-schema-uri",
                    doc.nodes[index].line,
                    "relative schema URI needs an agreed base URI",
                    severity=Warning,
                  ),
                )
              }
              if declared.get(tag) != None {
                out.push(
                  diagnostic(
                    "duplicate-schema-tag",
                    doc.nodes[index].line,
                    "extension tag is declared more than once",
                  ),
                )
              }
              declared.set(tag, true)
            } else {
              out.push(
                diagnostic(
                  "schema-tag",
                  doc.nodes[index].line,
                  "SCHMA.TAG requires an extension tag and a documented URI",
                ),
              )
            }
          }
          _ =>
            out.push(
              diagnostic(
                "schema-tag",
                doc.nodes[index].line,
                "SCHMA.TAG requires text",
              ),
            )
        }
      }
    }
  }
  let mut headers = 0
  let mut trailers = 0
  for i, node in doc.nodes {
    if node.level == 0 {
      if node.tag == "HEAD" {
        headers = headers + 1
      }
      if node.tag == "TRLR" {
        trailers = trailers + 1
      }
      if node.tag == "HEAD" && i != 0 {
        out.push(diagnostic("header-order", node.line, "HEAD must be first"))
      }
      if node.tag == "TRLR" && i != doc.nodes.length() - 1 {
        out.push(
          diagnostic(
            "trailer-order",
            node.line,
            "TRLR must be last and have no children",
          ),
        )
      }
      if [
          "HEAD", "TRLR", "INDI", "FAM", "OBJE", "REPO", "SNOTE", "SOUR", "SUBM",
        ].contains(node.tag) ==
        false &&
        !node.tag.has_prefix("_") {
        out.push(
          diagnostic(
            "unknown-record",
            node.line,
            "unrecognized standard record tag " + node.tag,
            severity=Warning,
          ),
        )
      }
    } else if node.tag == "HEAD" || node.tag == "TRLR" {
      out.push(
        diagnostic(
          "pseudo-structure-level",
          node.line,
          "HEAD and TRLR must be records",
        ),
      )
    }
    if node.tag == "HEAD" || node.tag == "TRLR" {
      if node.payload != Empty || node.xref != None {
        out.push(
          diagnostic(
            "pseudo-structure-payload",
            node.line,
            "HEAD/TRLR cannot have payloads or ids",
          ),
        )
      }
    } else if node.payload == Empty && doc.children(i).length() == 0 {
      out.push(
        diagnostic(
          "empty-structure",
          node.line,
          "structure requires a payload or substructure",
        ),
      )
    }
    if node.tag == "CONC" {
      out.push(
        diagnostic("legacy-conc", node.line, "CONC is not part of GEDCOM 7"),
      )
    }
    if node.tag.has_prefix("_") && declared.get(node.tag) == None {
      out.push(
        diagnostic(
          "undeclared-extension",
          node.line,
          "extension retained without HEAD.SCHMA declaration",
          severity=Warning,
        ),
      )
    }
    if node.xref is Some(id) {
      match seen.get(id) {
        Some(previous) =>
          out.push(
            diagnostic(
              "duplicate-id",
              node.line,
              "duplicate identifier @" + id + "@",
              related_line=doc.nodes[previous].line,
            ),
          )
        None => seen.set(id, i)
      }
    }
    let expected = if standard[i] { target_record(doc, i) } else { None }
    if expected != None && !(node.payload is Pointer(_)) && node.payload != Void {
      out.push(
        diagnostic(
          "pointer-payload",
          node.line,
          node.tag + " requires a pointer or @VOID@",
        ),
      )
    }
    if node.payload is Pointer(id) {
      match doc.ids.get(id) {
        None =>
          out.push(
            diagnostic(
              "dangling-pointer",
              node.line,
              "no target for @" + id + "@",
            ),
          )
        Some(target) =>
          if expected is Some(expected) &&
            (doc.nodes[target].tag != expected || doc.nodes[target].level != 0) {
            out.push(
              diagnostic(
                "pointer-type",
                node.line,
                node.tag + " must point to a " + expected + " record",
                related_line=doc.nodes[target].line,
              ),
            )
          }
      }
    }
    if node.tag == "FAM" && node.level == 0 {
      for tag in ["HUSB", "WIFE"] {
        let links = doc.select(i, tag)
        if links.length() > 1 {
          out.push(
            diagnostic(
              "family-cardinality",
              doc.nodes[links[1]].line,
              "FAM may have at most one " + tag,
            ),
          )
        }
      }
      let links : Map[String, Int32] = Map([])
      for index in doc.children(i) {
        let child = doc.nodes[index]
        if ["HUSB", "WIFE", "CHIL"].contains(child.tag) &&
          child.payload is Pointer(id) {
          let key = child.tag + "@" + id
          if links.get(key) is Some(prior) {
            out.push(
              diagnostic(
                "duplicate-family-link",
                child.line,
                "duplicate " + child.tag + " link",
                related_line=doc.nodes[prior].line,
                severity=Warning,
              ),
            )
          } else {
            links.set(key, index)
          }
        }
      }
    }
  }
  if headers != 1 {
    out.push(
      diagnostic(
        "header-count",
        first.line,
        "document must contain exactly one HEAD",
      ),
    )
  }
  if trailers != 1 {
    out.push(
      diagnostic(
        "trailer-count",
        last.line,
        "document must contain exactly one TRLR",
      ),
    )
  }
  let ctx = schema_context(doc)
  out.append(audit_schema(doc, ctx))
  out.append(audit_semantics(doc, ctx))
  out
}

///|
fn supported_version(payload : Payload) -> Bool {
  guard payload is Text(s) else { return false }
  let parts = s.split(".").map(fn(x) { x.to_owned() }).to_array()
  parts.length() >= 2 &&
  parts.length() <= 3 &&
  parts[0] == "7" &&
  parts[1] == "0" &&
  parts.all(ascii_digits)
}

///|
pub fn has_errors(diagnostics : Array[Diagnostic]) -> Bool {
  diagnostics.any(fn(d) { d.severity == Error })
}