///|
/// Validate references and ownership, without evaluating state-machine behavior.
/// Exhaustion uses state/model-limit, state/depth-limit or state/work-limit.
pub fn validate(
  model : StateDiagram,
  nodes_limit~ : Int32 = 10000,
  edges_limit~ : Int32 = 50000,
  regions_limit~ : Int32 = 10000,
  depth_limit~ : Int32 = 256,
  diagnostics_limit~ : Int32 = 256,
  work_limit~ : Int32 = 1000000,
) -> ReadOnlyArray[@diagrams.Diagnostic] {
  let out : Array[@diagrams.Diagnostic] = []
  let mut work = 0
  let mut halted = false
  fn error(code : String, text : String) {
    if halted {
      return
    }
    if out.length() >= diagnostics_limit {
      halted = true
      if !out.is_empty() {
        out[out.length() - 1] = @diagrams.Diagnostic::error(
          "state/diagnostic-limit", "State validation diagnostic budget exhausted",
        )
      }
    } else {
      out.push(@diagrams.Diagnostic::error("state/" + code, text))
    }
  }
  fn charge(n : Int32) -> Bool {
    if halted {
      return false
    }
    if n < 0 || n > work_limit - work {
      error("work-limit", "State validation work budget exhausted")
      halted = true
      false
    } else {
      work += n
      true
    }
  }
  fn text(value : String) -> Bool {
    charge(value.length() + 1)
  }
  fn optional(value : String?) -> Bool {
    value.map(text).unwrap_or(true)
  }
  fn origin(value : @flowchart.SourceBinding?) -> Bool {
    guard value is Some(value) else { return true }
    if !text(value.format) || !text(value.path) {
      return false
    }
    if value.span is Some(span) &&
      (
        span.start.offset < 0 ||
        span.end.offset < span.start.offset ||
        span.start.line < 1 ||
        span.start.column < 1 ||
        span.end.line < span.start.line ||
        span.end.column < 1 ||
        (
          span.start.line == span.end.line &&
          span.end.column < span.start.column
        )
      ) {
      error("source-binding", "Invalid source span: " + value.path)
    }
    true
  }
  if nodes_limit <= 0 ||
    edges_limit <= 0 ||
    regions_limit <= 0 ||
    depth_limit <= 0 ||
    diagnostics_limit <= 0 ||
    work_limit <= 0 {
    return [
      @diagrams.Diagnostic::error(
        "state/invalid-limits", "State validation limits must be positive",
      ),
    ]
  }
  if model.states.length() > nodes_limit ||
    model.notes.length() > nodes_limit - model.states.length() ||
    model.regions.length() > regions_limit ||
    model.transitions.length() > edges_limit {
    error("model-limit", "State model size budget exhausted")
    return ReadOnlyArray::from_array(out)
  }
  if !text(model.id) ||
    !optional(model.title) ||
    !optional(model.accessibility_title) ||
    !optional(model.accessibility_description) {
    return ReadOnlyArray::from_array(out)
  }
  let ids : Map[String, Bool] = Map([])
  let states : Map[String, State] = Map([])
  let regions : Map[String, Region] = Map([])
  fn add(id : String) -> Bool {
    if !text(id) {
      return false
    }
    if id == "" || ids.contains(id) {
      error("duplicate-id", "Ambiguous state element identity: " + id)
    }
    ids[id] = true
    !halted
  }
  for state in model.states {
    if !add(state.id) ||
      !text(state.label) ||
      !text(state.region) ||
      !origin(state.origin) ||
      !charge(state.descriptions.length()) {
      return ReadOnlyArray::from_array(out)
    }
    if state.role is Unsupported(symbol) && !text(symbol) {
      return ReadOnlyArray::from_array(out)
    }
    for description in state.descriptions {
      if !text(description) {
        return ReadOnlyArray::from_array(out)
      }
    }
    states[state.id] = state
  }
  for region in model.regions {
    if !add(region.id) ||
      !optional(region.owner) ||
      !origin(region.origin) ||
      !charge(region.members.length()) {
      return ReadOnlyArray::from_array(out)
    }
    regions[region.id] = region
  }
  let membership : Map[String, String] = Map([])
  for region in model.regions {
    if region.owner is Some(owner) {
      match states.get(owner) {
        Some(state) =>
          if state.role != Composite {
            error(
              "invalid-owner",
              "Region owner must be a composite state: " + owner,
            )
          }
        None => error("missing-owner", "Missing region owner: " + owner)
      }
    }
    let members : Map[String, Bool] = Map([])
    for id in region.members {
      if !text(id) {
        return ReadOnlyArray::from_array(out)
      }
      if members.contains(id) {
        error("region-member", "Duplicate region member: " + id)
      }
      members[id] = true
      membership[id] = region.id
      match states.get(id) {
        Some(state) =>
          if state.region != region.id {
            error("region-member", "Inconsistent region membership: " + id)
          }
        None => error("region-member", "Missing state member: " + id)
      }
    }
  }
  for state in model.states {
    if !regions.contains(state.region) {
      error("missing-region", "Missing owning region for " + state.id)
    }
    if membership.get(state.id) != Some(state.region) {
      error(
        "region-member",
        "State is absent from its owning region membership: " + state.id,
      )
    }
    let mut cursor = Some(state.id)
    let seen : Map[String, Bool] = Map([])
    let mut depth = 0
    while cursor is Some(id) {
      if !charge(1) {
        return ReadOnlyArray::from_array(out)
      }
      if seen.contains(id) {
        error("ownership-cycle", "State ownership cycle at " + id)
        break
      }
      seen[id] = true
      if depth >= depth_limit {
        error("depth-limit", "State ownership depth budget exhausted")
        return ReadOnlyArray::from_array(out)
      }
      depth += 1
      guard states.get(id) is Some(current) else { break }
      match regions.get(current.region) {
        Some(region) => cursor = region.owner
        None => {
          error("missing-region", "Missing owning region for " + id)
          break
        }
      }
    }
  }
  let transition_ids : Map[String, Bool] = Map([])
  for transition in model.transitions {
    if !add(transition.id) ||
      !text(transition.source) ||
      !text(transition.target) ||
      !text(transition.label) ||
      !origin(transition.origin) {
      return ReadOnlyArray::from_array(out)
    }
    transition_ids[transition.id] = true
    if !states.contains(transition.source) ||
      !states.contains(transition.target) {
      error("missing-endpoint", "Missing transition endpoint: " + transition.id)
    }
    if transition.min_length < 1 {
      error(
        "invalid-length",
        "Transition length must be positive: " + transition.id,
      )
    }
  }
  for note in model.notes {
    if !add(note.id) ||
      !text(note.text) ||
      !text(note.placement) ||
      !text(note.region) ||
      !origin(note.origin) ||
      !charge(note.targets.length() + note.connection_targets.length()) {
      return ReadOnlyArray::from_array(out)
    }
    if !regions.contains(note.region) {
      error("missing-region", "Missing note region: " + note.id)
    }
    if !["left", "right", "top", "bottom", "floating"].contains(note.placement) {
      error("note-placement", "Invalid note placement: " + note.id)
    }
    for target in note.connection_targets {
      if !text(target) {
        return ReadOnlyArray::from_array(out)
      }
      if !transition_ids.contains(target) {
        error("note-target", "Missing note transition: " + target)
      }
    }
    for target in note.targets {
      if !text(target) {
        return ReadOnlyArray::from_array(out)
      }
      if !states.contains(target) {
        error("note-target", "Missing note target: " + target)
      }
    }
  }
  ReadOnlyArray::from_array(out)
}

///|
pub fn fresh_id(
  stem : String,
  used : ArrayView[String],
  work_limit~ : Int32 = 1000000,
) -> Result[String, @diagrams.Diagnostic] {
  @flowchart.fresh_id(stem, used, work_limit~).map_err(d => {
    ..d,
    code: d.code.replace_all(old="flowchart/", new="state/"),
  })
}