///|
fn graph_integer(value : Json) -> Int32 raise ZddError {
  match value {
    Number(number, ..) => {
      if number < 0.0 || number > 2147483647.0 {
        raise Invalid("graph integer outside range")
      }
      let integer = number.to_int()
      if integer.to_double() != number {
        raise Invalid("graph integer must be integral")
      }
      integer
    }
    _ => raise Invalid("graph integer expected")
  }
}

///|
fn required_field(
  fields : Map[String, Json],
  key : String,
) -> Json raise ZddError {
  match fields.get(key) {
    Some(value) => value
    None => raise Invalid("missing graph field: " + key)
  }
}

///|
/// Strict four-field JSON object, nodes are [variable,low,high] triples.
/// Duplicate/unknown keys, nonintegral IDs and excessive nesting are rejected.
pub fn Family::from_json(
  text : String,
  limits~ : Limits = Limits::default(),
) -> Family raise ZddError {
  // Validate budgets before parsing; input text is bounded independently of node count.
  ignore(engine(0, limits))
  if text.length() > 4000000 {
    raise BudgetExceeded
  }
  let document = @json.parse(
    text[:],
    max_nesting_depth=8,
    reject_duplicate_keys=true,
  ) catch {
    _ => raise Invalid("invalid graph JSON")
  }
  let fields = match document {
    Object(fields) => fields
    _ => raise Invalid("graph JSON must be an object")
  }
  if fields.length() != 4 {
    raise Invalid("exactly format, universe, root, nodes are required")
  }
  let format = graph_integer(required_field(fields, "format"))
  let universe = graph_integer(required_field(fields, "universe"))
  let root = graph_integer(required_field(fields, "root"))
  let rows = match required_field(fields, "nodes") {
    Array(rows) => rows
    _ => raise Invalid("nodes must be an array")
  }
  if rows.length() > limits.nodes - 2 {
    raise BudgetExceeded
  }
  let nodes : Array[NodeRecord] = []
  for row in rows {
    match row {
      Array(values) => {
        if values.length() != 3 {
          raise Invalid("node must be an integer triple")
        }
        let variable = graph_integer(values[0])
        let low = graph_integer(values[1])
        let high = graph_integer(values[2])
        nodes.push({ variable, low, high, })
      }
      _ => raise Invalid("node must be an array")
    }
  }
  Family::from_graph({ format, universe, root, nodes, }, limits~)
}

///|
/// Deterministic JSON, contains no external references or executable expressions.
pub fn Family::to_json(self : Family) -> String {
  let output = StringBuilder()
  output.write_string(
    "{\"format\":1,\"universe\":\{self.universe},\"root\":\{self.root},\"nodes\":[",
  )
  for id in 2..