///|
fn validate_value(
  value : DataValue,
  kind : ValueType?,
  path : String,
) -> Unit raise GraphError {
  match value {
    ScalarValue(scalar) =>
      require(
        scalar.value_type() == kind.unwrap_or(StringValue),
        path,
        "scalar type does not match key",
      )
    XmlPayload(_) =>
      require(kind is None, path, "typed key cannot contain XML payload")
  }
}

///|
fn validate_locator(locator : Locator) -> Unit raise GraphError {
  require(
    locator.link_type is None || locator.link_type == Some("simple"),
    "locator.type",
    "only simple XLink supported",
  )
}

///|
fn validate_model_ports(
  ports : Array[Port],
  depth : Int32,
  budget : ParseBudget,
) -> Unit raise GraphError {
  check_bound("port_depth", depth, budget.limits.xml_depth)
  for port in ports {
    check_bound("ports", budget.ports + 1, budget.limits.ports)
    budget.ports = budget.ports + 1
    validate_model_ports(port.ports, depth + 1, budget)
  }
}

///|
fn validate_model_graph(
  graph : Graph,
  depth : Int32,
  budget : ParseBudget,
) -> Unit raise GraphError {
  check_bound("graph_depth", depth, budget.limits.xml_depth)
  check_bound("graphs", budget.graphs + 1, budget.limits.graphs)
  budget.graphs = budget.graphs + 1
  if graph.locator is Some(locator) {
    validate_locator(locator)
    require(
      graph.nodes.is_empty() &&
      graph.edges.is_empty() &&
      graph.hyperedges.is_empty() &&
      graph.data.is_empty(),
      "graph.locator",
      "locator and inline content are exclusive",
    )
  }
  for node in graph.nodes {
    check_bound("nodes", budget.nodes + 1, budget.limits.nodes)
    budget.nodes = budget.nodes + 1
    if node.locator is Some(locator) {
      validate_locator(locator)
      require(
        node.data.is_empty() && node.ports.is_empty() && node.graph is None,
        "node.locator",
        "locator and inline content are exclusive",
      )
    }
    validate_model_ports(node.ports, depth + 1, budget)
    if node.graph is Some(nested) {
      validate_model_graph(nested, depth + 1, budget)
    }
  }
  for edge in graph.edges {
    check_bound("edges", budget.edges + 1, budget.limits.edges)
    budget.edges = budget.edges + 1
    if edge.graph is Some(nested) {
      validate_model_graph(nested, depth + 1, budget)
    }
  }
  for hyperedge in graph.hyperedges {
    check_bound("edges", budget.edges + 1, budget.limits.edges)
    budget.edges = budget.edges + 1
    for _endpoint in hyperedge.endpoints {
      check_bound("endpoints", budget.endpoints + 1, budget.limits.endpoints)
      budget.endpoints = budget.endpoints + 1
    }
    if hyperedge.graph is Some(nested) {
      validate_model_graph(nested, depth + 1, budget)
    }
  }
}

///|
fn validate_model(doc : Document, limits : Limits) -> Unit raise GraphError {
  limits.validate()
  check_bound("keys", doc.keys.length(), limits.keys)
  let ids : Map[String, Bool] = Map([])
  for key in doc.keys {
    if key.name is Some(name) {
      valid_token(name, "key.attr.name")
    }
    unique_optional_id(Some(key.id), ids, "key.id")
    require(
      key.default_value is Some(_) ||
      (key.default_extra.is_empty() && key.default_namespaces.is_empty()),
      "key.default",
      "default metadata requires a default value",
    )
    if key.default_value is Some(value) {
      validate_value(value, key.value_type, "key.default")
    }
  }
  let budget = ParseBudget::new(limits)
  for graph in doc.graphs {
    validate_model_graph(graph, 1, budget)
  }
  validate_hierarchy(doc, limits)
}