///|
fn validate_word_property_vectors(
  nodes : Array[Node],
  control : WorkControl,
) -> Unit raise {
  let vt = "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"
  for i, node in nodes {
    control.checkpoint(
      "word-property-vector",
      1 + node.element.attributes.length(),
    )
    let name = node.element.name.local_name
    if node.element.name.namespace_uri != Some(vt) {
      continue
    }
    guard node.element.attributes.all(fn(a) {
      name == "vector" &&
      a.name.namespace_uri is None &&
      ["size", "baseType"].contains(a.name.local_name)
    }) else {
      raise Refused("unsupported document property vector attribute")
    }
    if name == "vector" || name == "variant" {
      let base = if name == "vector" { attr(node, "baseType") } else { "" }
      guard name == "variant" || ["variant", "lpstr"].contains(base) else {
        raise Refused("unsupported document property vector type")
      }
      let mut count = 0
      let mut j = i + 1
      while j < nodes.length() && nodes[j].depth > node.depth {
        control.checkpoint("word-property-vector-child", 1)
        if nodes[j].depth == node.depth + 1 {
          count += 1
          let child = nodes[j]
          guard child.element.name.namespace_uri == Some(vt) &&
            (if name == "vector" {
              child.element.name.local_name == base
            } else {
              ["lpstr", "i4"].contains(child.element.name.local_name)
            }) else {
            raise Invalid("document property vector child/type mismatch")
          }
        }
        j += 1
      }
      guard node.text_fragments.all(fn(t) { t.trim() == "" }) &&
        (if name == "vector" {
          checked_index(attr(node, "size"), 2147483647) == count
        } else {
          count == 1
        }) else {
        raise Invalid("document property vector size mismatch")
      }
    }
  }
}

///|
fn word_attr_opt(node : Node, name : String) -> String? {
  for a in node.element.attributes {
    if a.name.namespace_uri == Some(word_ns) && a.name.local_name == name {
      return Some(a.value)
    }
  }
  None
}

///|
fn word_attr(node : Node, name : String) -> String raise {
  guard word_attr_opt(node, name) is Some(value) else {
    raise Invalid("required Word attribute missing: " + name)
  }
  value
}

///|
fn word_id(value : String, negative~ : Bool = false) -> String raise {
  if negative && value == "-1" {
    return "-1"
  }
  checked_index(value, 2147483647).to_string()
}

///|
fn word_extra_layout_element(name : String, parent : String) -> Bool {
  match name {
    "outlineLvl" | "contextualSpacing" | "tabs" | "pBdr" => parent == "pPr"
    "bCs" | "iCs" | "kern" | "rStyle" | "vertAlign" => parent == "rPr"
    "tab" => parent == "tabs"
    "numPr" => parent == "pPr"
    "numId" | "ilvl" => parent == "numPr"
    "top" | "left" | "bottom" | "right" | "between" | "bar" => parent == "pBdr"
    _ => false
  }
}

///|
fn word_numbering_element(node : Node, parent : String) -> Bool {
  if node.element.name.namespace_uri != Some(word_ns) {
    return false
  }
  match node.element.name.local_name {
    "numbering" => node.depth == 0
    "abstractNum" | "num" => parent == "numbering"
    "nsid" | "multiLevelType" | "tmpl" | "name" => parent == "abstractNum"
    "abstractNumId" | "lvlOverride" => parent == "num"
    "lvl" => parent == "abstractNum" || parent == "lvlOverride"
    "startOverride" => parent == "lvlOverride"
    "start"
    | "numFmt"
    | "lvlText"
    | "lvlJc"
    | "suff"
    | "lvlRestart"
    | "isLgl" => parent == "lvl"
    "pPr" | "rPr" => parent == "lvl"
    "ind" | "spacing" | "jc" => parent == "pPr"
    "rFonts" | "b" | "i" | "sz" | "color" => parent == "rPr"
    _ =>
      !["numPr", "numId", "ilvl"].contains(node.element.name.local_name) &&
      word_extra_layout_element(node.element.name.local_name, parent)
  }
}

///|
fn word_structure_attribute(name : String, attribute : String) -> Bool {
  match name {
    "footnote" | "endnote" => ["id", "type"].contains(attribute)
    "footnoteReference" | "endnoteReference" => attribute == "id"
    "abstractNum" => attribute == "abstractNumId"
    "num" => attribute == "numId"
    "lvl" => ["ilvl", "tplc", "tentative"].contains(attribute)
    "lvlOverride" => attribute == "ilvl"
    "tab" => ["pos", "leader"].contains(attribute)
    "bookmarkStart" | "bookmarkEnd" | "div" => attribute == "id"
    "pgSz" => attribute == "code"
    "compatSetting" => attribute == "uri"
    "ptab" => ["relativeTo", "alignment", "leader"].contains(attribute)
    _ => false
  }
}

///|
fn validate_word_structure_attributes(
  node : Node,
  parent : String,
  root : String,
) -> Unit raise {
  if node.element.name.namespace_uri != Some(word_ns) {
    return
  }
  let name = node.element.name.local_name
  let exact : Array[String]? = match name {
    "bookmarkStart" => Some(["id", "name"])
    "bookmarkEnd" => Some(["id"])
    "proofErr" => Some(["type"])
    "div" => Some(["id"])
    "divs" | "divBdr" => Some([])
    "bodyDiv" | "marLeft" | "marRight" | "marTop" | "marBottom" => Some(["val"])
    "ptab" => Some(["relativeTo", "alignment", "leader"])
    "removePersonalInformation"
    | "removeDateAndTime"
    | "doNotAutoCompressPictures"
    | "doNotDisplayPageBoundaries"
    | "doNotIncludeSubdocsInStats" => Some(["val"])
    "compatSetting" => Some(["name", "uri", "val"])
    "footnote" | "endnote" => Some(["id", "type"])
    "footnoteReference" | "endnoteReference" => Some(["id"])
    "footnoteRef" | "endnoteRef" | "separator" | "continuationSeparator" =>
      Some([])
    "numPr" => Some([])
    "numId" | "ilvl" | "abstractNumId" | "outlineLvl" => Some(["val"])
    "abstractNum" => Some(["abstractNumId"])
    "num" => Some(["numId"])
    "lvl" => Some(["ilvl", "tplc", "tentative"])
    "lvlOverride" => Some(["ilvl"])
    "start"
    | "startOverride"
    | "lvlRestart"
    | "numFmt"
    | "lvlText"
    | "lvlJc"
    | "suff"
    | "multiLevelType"
    | "nsid"
    | "tmpl"
    | "isLgl" => if root == "numbering" { Some(["val"]) } else { None }
    _ => None
  }
  if exact is Some(names) {
    guard node.element.attributes.all(fn(a) {
        (
          a.name.namespace_uri == Some(word_ns) &&
          names.contains(a.name.local_name)
        ) ||
        (
          root == "numbering" &&
          name == "abstractNum" &&
          node.depth == 1 &&
          a.name.namespace_uri == Some(word15_ns) &&
          a.name.local_name == "restartNumberingAfterBreak" &&
          ["0", "1", "true", "false", "on", "off"].contains(a.value)
        )
      }) &&
      node.text_fragments.all(fn(t) { t.trim() == "" }) else {
      raise Refused("unsupported Word structural attributes or text")
    }
  }
  if name == "div" {
    ignore(word_id(word_attr(node, "id")))
  }
  if name == "bodyDiv" {
    guard ["0", "1", "true", "false", "on", "off"].contains(
      word_attr(node, "val"),
    ) else {
      raise Invalid("invalid web layout flag")
    }
  }
  if ["marLeft", "marRight", "marTop", "marBottom"].contains(name) {
    ignore(checked_index(word_attr(node, "val"), 2147483647))
  }
  if name == "compatSetting" {
    let setting = word_attr(node, "name")
    let value = word_attr(node, "val")
    guard parent == "compat" &&
      word_attr(node, "uri") == "http://schemas.microsoft.com/office/word" &&
      (if setting == "compatibilityMode" {
        ["12", "14", "15", "16"].contains(value)
      } else {
        [
          "overrideTableStyleFontSizeAndJustification", "enableOpenTypeFeatures",
          "doNotFlipMirrorIndents", "differentiateMultirowTableHeaders",
        ].contains(setting) &&
        ["0", "1", "true", "false"].contains(value)
      }) else {
      raise Refused("unsupported Word compatibility setting")
    }
  }
  if [
      "removePersonalInformation", "removeDateAndTime", "doNotAutoCompressPictures",
      "doNotDisplayPageBoundaries", "doNotIncludeSubdocsInStats",
    ].contains(name) {
    guard parent == "settings" &&
      word_attr_opt(node, "val")
      .map(fn(v) { ["0", "1", "true", "false", "on", "off"].contains(v) })
      .unwrap_or(true) else {
      raise Refused("unsupported Word settings flag")
    }
  }
  if name == "ptab" {
    guard parent == "r" &&
      ["margin", "indent"].contains(word_attr(node, "relativeTo")) &&
      ["left", "center", "right"].contains(word_attr(node, "alignment")) &&
      ["none", "dot", "hyphen", "underscore", "middleDot"].contains(
        word_attr(node, "leader"),
      ) else {
      raise Refused("unsupported positional tab settings")
    }
  }
  if root == "numbering" &&
    ["start", "startOverride", "lvlRestart"].contains(name) {
    ignore(checked_index(word_attr(node, "val"), 2147483647))
  }
  if root == "numbering" && name == "numFmt" {
    guard [
      "decimal", "upperRoman", "lowerRoman", "upperLetter", "lowerLetter", "ordinal",
      "bullet", "none", "decimalZero",
    ].contains(word_attr(node, "val")) else {
      raise Refused("unsupported numbering format")
    }
  }
  if ["outlineLvl", "ilvl"].contains(name) {
    ignore(
      checked_index(
        word_attr(node, "val"),
        if name == "outlineLvl" {
          10
        } else {
          9
        },
      ),
    )
  }
  if name == "lvl" || name == "lvlOverride" {
    ignore(checked_index(word_attr(node, "ilvl"), 9))
  }
  if ["footnoteReference", "endnoteReference", "footnote", "endnote"].contains(
      name,
    ) {
    ignore(word_id(word_attr(node, "id"), negative=true))
    if word_attr_opt(node, "type") is Some(kind) {
      guard parent != "footnotePr" &&
        parent != "endnotePr" &&
        ["normal", "separator", "continuationSeparator", "continuationNotice"].contains(
          kind,
        ) else {
        raise Refused("unsupported note type")
      }
    }
  }
  if (name == "footnoteRef" && root != "footnotes") ||
    (name == "endnoteRef" && root != "endnotes") {
    raise Refused("note marker type does not match its owning part")
  }
}

///|
fn word_structure_part(pkg : Package, kind : String) -> String? raise {
  let content_type = "application/vnd.openxmlformats-officedocument.wordprocessingml." +
    kind +
    "+xml"
  let relations = pkg.audit.relationships.filter(fn(r) {
    r.kind == office_rel + kind
  })
  let parts = pkg.audit.parts.filter(fn(p) { p.content_type == content_type })
  guard relations.length() <= 1 && parts.length() == relations.length() else {
    raise Invalid("duplicate or unowned Word " + kind + " part")
  }
  if relations.length() == 0 {
    return None
  }
  let r = relations[0]
  guard !r.external &&
    r.source == pkg.audit.main_part &&
    r.resolved == parts[0].name &&
    graph_incoming(pkg.graph, r.resolved).length() == 1 else {
    raise Invalid("ambiguous Word structural part ownership")
  }
  Some(r.resolved)
}

///|
fn validate_word_notes(pkg : Package, kind : String) -> Unit raise {
  let singular = if kind == "footnotes" { "footnote" } else { "endnote" }
  let definitions : Map[String, String] = Map([])
  let special_types : Map[String, Bool] = Map([])
  if word_structure_part(pkg, kind) is Some(part) {
    let mut current_type = ""
    for node in pkg.xml_nodes[part] {
      pkg.control.checkpoint("word-note-definition", 1)
      if node.depth == 1 && node.element.name.local_name == singular {
        let id = word_id(word_attr(node, "id"), negative=true)
        let note_type = word_attr_opt(node, "type").unwrap_or("normal")
        guard !definitions.contains(id) && (id != "-1" || note_type != "normal") else {
          raise Invalid("duplicate or invalid note definition ID")
        }
        definitions[id] = note_type
        if note_type != "normal" {
          guard !special_types.contains(note_type) else {
            raise Invalid("duplicate note separator type")
          }
          special_types[note_type] = true
        }
        current_type = note_type
      } else if [
          "separator", "continuationSeparator", "footnoteRef", "endnoteRef",
        ].contains(node.element.name.local_name) {
        let marker = node.element.name.local_name
        guard (marker == "separator" && current_type == "separator") ||
          (
            marker == "continuationSeparator" &&
            current_type == "continuationSeparator"
          ) ||
          (marker == singular + "Ref" && current_type == "normal") else {
          raise Invalid("note marker and definition type mismatch")
        }
      }
    }
  }
  let referenced : Map[String, Bool] = Map([])
  let settings_references : Map[String, Bool] = Map([])
  for part in pkg.audit.parts {
    if pkg.xml_nodes.get(part.name) is Some(nodes) {
      for node in nodes {
        pkg.control.checkpoint("word-note-reference", 1)
        if node.element.name.namespace_uri != Some(word_ns) {
          continue
        }
        let name = node.element.name.local_name
        if name == singular + "Reference" {
          let id = word_id(word_attr(node, "id"), negative=true)
          guard part.name == pkg.audit.main_part &&
            definitions.get(id) == Some("normal") &&
            !referenced.contains(id) else {
            raise Invalid(
              "dangling duplicated or wrongly scoped note reference",
            )
          }
          referenced[id] = true
        } else if name == singular && part.content_type.contains(".settings+") {
          let id = word_id(word_attr(node, "id"), negative=true)
          guard definitions.get(id) is Some(note_type) && note_type != "normal" else {
            raise Invalid("settings note separator reference is not defined")
          }
          guard !settings_references.contains(id) else {
            raise Invalid("duplicate settings note separator reference")
          }
          settings_references[id] = true
        }
      }
    }
  }
  for id, kind in definitions {
    pkg.control.checkpoint("word-note-definition-binding", 1)
    guard kind != "normal" || referenced.contains(id) else {
      raise Invalid("unreferenced normal note definition")
    }
  }
}

///|
fn validate_word_numbering(pkg : Package) -> Unit raise {
  let abstracts : Map[String, Array[String]] = Map([])
  let instances : Map[String, String] = Map([])
  let overrides : Map[String, Array[String]] = Map([])
  let override_fields : Map[String, Bool] = Map([])
  if word_structure_part(pkg, "numbering") is Some(part) {
    let mut abstract_id = ""
    let mut num_id = ""
    let mut override_level = ""
    for node in pkg.xml_nodes[part] {
      pkg.control.checkpoint("word-numbering-definition", 1)
      let name = node.element.name.local_name
      if node.depth == 1 {
        abstract_id = ""
        num_id = ""
        override_level = ""
        if name == "abstractNum" {
          abstract_id = word_id(word_attr(node, "abstractNumId"))
          guard !abstracts.contains(abstract_id) else {
            raise Invalid("duplicate abstract numbering ID")
          }
          abstracts[abstract_id] = []
        } else if name == "num" {
          num_id = word_id(word_attr(node, "numId"))
          guard num_id != "0" && !instances.contains(num_id) else {
            raise Invalid("duplicate or reserved numbering ID")
          }
          instances[num_id] = ""
          overrides[num_id] = []
        }
      } else if node.depth == 2 && abstract_id != "" && name == "lvl" {
        let level = checked_index(word_attr(node, "ilvl"), 9).to_string()
        guard !abstracts[abstract_id].contains(level) else {
          raise Invalid("duplicate abstract numbering level")
        }
        abstracts[abstract_id].push(level)
      } else if node.depth == 2 && num_id != "" && name == "abstractNumId" {
        guard instances[num_id] == "" else {
          raise Invalid("multiple abstract numbering references")
        }
        instances[num_id] = word_id(word_attr(node, "val"))
      } else if node.depth == 2 && num_id != "" && name == "lvlOverride" {
        override_level = checked_index(word_attr(node, "ilvl"), 9).to_string()
        guard !overrides[num_id].contains(override_level) else {
          raise Invalid("duplicate numbering override")
        }
        overrides[num_id].push(override_level)
      } else if node.depth == 3 &&
        num_id != "" &&
        ["lvl", "startOverride"].contains(name) {
        let key = num_id + "/" + override_level + "/" + name
        guard !override_fields.contains(key) else {
          raise Invalid("duplicate numbering override property")
        }
        override_fields[key] = true
        if name == "lvl" {
          guard checked_index(word_attr(node, "ilvl"), 9).to_string() ==
            override_level else {
            raise Invalid("numbering override level mismatch")
          }
        }
      }
    }
  }
  for id, definition in instances {
    pkg.control.checkpoint("word-numbering-instance-binding", 1)
    guard abstracts.contains(definition) else {
      raise Invalid("dangling abstract numbering reference")
    }
    for level in overrides[id] {
      guard abstracts[definition].contains(level) ||
        override_fields.contains(id + "/" + level + "/lvl") else {
        raise Refused("override without an abstract level unsupported")
      }
    }
  }
  for nodes in pkg.xml_nodes.values() {
    for i, node in nodes {
      pkg.control.checkpoint("word-numbering-reference", 1)
      if node.element.name.namespace_uri != Some(word_ns) ||
        node.element.name.local_name != "numPr" {
        continue
      }
      let mut id : String? = None
      let mut level : String? = None
      let mut j = i + 1
      while j < nodes.length() && nodes[j].depth > node.depth {
        pkg.control.checkpoint("word-numbering-property", 1)
        let child = nodes[j]
        if child.depth == node.depth + 1 {
          if child.element.name.local_name == "numId" {
            guard id is None else {
              raise Invalid("duplicate numbering instance reference")
            }
            id = Some(word_id(word_attr(child, "val")))
          } else if child.element.name.local_name == "ilvl" {
            guard level is None else {
              raise Invalid("duplicate numbering level reference")
            }
            level = Some(checked_index(word_attr(child, "val"), 9).to_string())
          }
        }
        j += 1
      }
      guard id is Some(n) else {
        raise Refused("inherited numbering without explicit numId unsupported")
      }
      if n == "0" {
        continue
      }
      guard instances.get(n) is Some(definition) &&
        (
          abstracts[definition].contains(level.unwrap_or("0")) ||
          override_fields.contains(n + "/" + level.unwrap_or("0") + "/lvl")
        ) else {
        raise Invalid("dangling numbering instance or level reference")
      }
    }
  }
}

///|
fn validate_word_structures(pkg : Package) -> Unit raise {
  validate_word_notes(pkg, "footnotes")
  validate_word_notes(pkg, "endnotes")
  validate_word_numbering(pkg)
  validate_word_markers(pkg)
}

///|
fn validate_word_markers(pkg : Package) -> Unit raise {
  for nodes in pkg.xml_nodes.values() {
    let bookmarks : Map[String, Bool] = Map([])
    let names : Map[String, Bool] = Map([])
    let proof : Map[String, Int32] = Map([("spell", 0), ("gram", 0)])
    for node in nodes {
      pkg.control.checkpoint("word-range-marker", 1)
      if node.element.name.namespace_uri != Some(word_ns) {
        continue
      }
      let name = node.element.name.local_name
      if name == "bookmarkStart" {
        let id = word_id(word_attr(node, "id"))
        let label = word_attr(node, "name").to_lower()
        guard !bookmarks.contains(id) &&
          !names.contains(label) &&
          label.length() > 0 &&
          label.length() <= 40 &&
          label
          .code_units()
          .all(fn(c) {
            (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_'
          }) else {
          raise Invalid("invalid or duplicate bookmark identity")
        }
        bookmarks[id] = true
        names[label] = true
      } else if name == "bookmarkEnd" {
        let id = word_id(word_attr(node, "id"))
        guard bookmarks.get(id) == Some(true) else {
          raise Invalid("unpaired or duplicate bookmark end")
        }
        bookmarks[id] = false
      } else if name == "proofErr" {
        let kind = word_attr(node, "type")
        guard ["spellStart", "spellEnd", "gramStart", "gramEnd"].contains(kind) else {
          raise Refused("unsupported proofing marker")
        }
        let key = if kind.has_prefix("spell") { "spell" } else { "gram" }
        proof[key] = proof[key] +
          (if kind.has_suffix("Start") { 1 } else { -1 })
        guard proof[key] >= 0 else {
          raise Invalid("unpaired proofing end marker")
        }
      }
    }
    guard bookmarks.values().all(fn(open) { !open }) &&
      proof.values().all(fn(count) { count == 0 }) else {
      raise Invalid("unpaired Word range marker")
    }
  }
}