///|
fn hyperlink_source(parts : Array[PartRecord], source : String) -> Bool {
  parts.any(fn(p) {
    p.name == source &&
    [
      docx_ct, docm_ct, "application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml",
      "application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml",
      "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml",
    ].contains(p.content_type)
  })
}

///|
fn hyperlink_node(node : Node) -> Bool {
  node.element.name.local_name == "hyperlink" &&
  [Some(word_ns), Some(spreadsheet_ns)].contains(
    node.element.name.namespace_uri,
  )
}

///|
fn hyperlink_declared(
  r : Relationship,
  parts : Array[PartRecord],
  nodes : Map[String, Array[Node]],
) -> Bool {
  r.external &&
  r.kind == office_rel + "hyperlink" &&
  hyperlink_source(parts, r.source) &&
  nodes
  .get(r.source)
  .unwrap_or([])
  .any(fn(n) {
    hyperlink_node(n) &&
    n.element.attributes.any(fn(a) {
      a.name.namespace_uri ==
      Some(office_rel[0:office_rel.length() - 1].to_owned()) &&
      a.name.local_name == "id" &&
      a.value == r.id
    })
  })
}

///|
// URI classification only. Never resolve DNS, fetch, follow redirects or normalize the saved target.
fn valid_click_url(target : String) -> Bool {
  let uri = target.to_lower()
  let start = if uri.has_prefix("https://") {
    8
  } else if uri.has_prefix("http://") {
    7
  } else {
    return false
  }
  if target.length() > 8192 || target.length() <= start {
    return false
  }
  if target.code_units().any(fn(c) { c <= 32 || c == 127 || c == '\\' }) {
    return false
  }
  let mut end = start
  while end < target.length() && ![47, 63, 35].contains(target[end].to_int()) {
    end += 1
  }
  let authority = target[start:end].to_owned()
  // Deliberately narrow ASCII DNS / IPv4 authority, optional decimal port. No credentials or IPv6 literals.
  if authority == "" || authority.contains("@") {
    return false
  }
  let fields = authority.split(":").collect()
  if fields.length() > 2 {
    return false
  }
  let host = fields[0]
  if host.length() > 253 || host.has_prefix(".") || host.has_suffix(".") {
    return false
  }
  for label in host.split(".") {
    if label.length() == 0 ||
      label.length() > 63 ||
      label.has_prefix("-") ||
      label.has_suffix("-") {
      return false
    }
    if label
      .code_units()
      .any(fn(c) {
        !((c >= 'a' && c <= 'z') ||
        (c >= 'A' && c <= 'Z') ||
        (c >= '0' && c <= '9') ||
        c == '-')
      }) {
      return false
    }
  }
  if fields.length() == 2 {
    let port = fields[1]
    if port.length() == 0 || port.length() > 5 {
      return false
    }
    let mut number = 0
    for c in port.code_units() {
      if c < '0' || c > '9' {
        return false
      }
      number = number * 10 + c.to_int() - 48
    }
    if number == 0 || number > 65535 {
      return false
    }
  }
  true
}

///|
fn validate_hyperlink(pkg : Package, r : Relationship) -> Unit raise {
  guard hyperlink_declared(r, pkg.audit.parts, pkg.xml_nodes) &&
    valid_click_url(r.target) else {
    raise Refused(
      "only source-bound HTTP(S) click hyperlinks with supported authority are permitted",
    )
  }
  let references = pkg.graph.graph.xml_references.filter(fn(reference) {
    reference.source == r.source && reference.relationship_id == r.id
  })
  guard references.length() > 0 &&
    references.all(fn(reference) {
      reference.element_name == "hyperlink" &&
      reference.attribute_name == "id" &&
      [word_ns, spreadsheet_ns].contains(reference.element_namespace)
    }) else {
    raise Invalid("hyperlink relationship has a non-click XML reference")
  }
}

///|
fn extended_office(pkg : Package) -> Bool {
  pkg.audit.profile.has_suffix("-v2")
}

///|
fn click_cell(text : String) -> (Int, Int) raise {
  guard text.length() > 1 && text.length() <= 10 else {
    raise Invalid("invalid hyperlink cell reference")
  }
  let mut i = 0
  let mut col = 0
  while i < text.length() && text[i] >= 'A' && text[i] <= 'Z' {
    col = col * 26 + text[i].to_int() - 64
    i += 1
    guard i <= 3 else { raise Invalid("hyperlink column overflow") }
  }
  guard col > 0 && col <= 16384 && i < text.length() && text[i] != '0' else {
    raise Invalid("hyperlink reference outside grid")
  }
  let mut row = 0
  while i < text.length() {
    let c = text[i]
    guard c >= '0' && c <= '9' else { raise Invalid("invalid hyperlink row") }
    row = row * 10 + c.to_int() - 48
    i += 1
    guard row <= 1048576 else { raise Invalid("hyperlink row overflow") }
  }
  guard row > 0 else { raise Invalid("hyperlink row is zero") }
  (col, row)
}

///|
fn validate_click_range(text : String) -> Unit raise {
  let parts = text.split(":").collect()
  guard parts.length() == 1 || parts.length() == 2 else {
    raise Invalid("invalid hyperlink range")
  }
  let (c1, r1) = click_cell(parts[0].to_owned())
  if parts.length() == 2 {
    let (c2, r2) = click_cell(parts[1].to_owned())
    guard c1 <= c2 && r1 <= r2 else {
      raise Invalid("reversed hyperlink range")
    }
  }
}