///|
fn object_fields(
  value : Json,
  path : String,
  keys : Array[String],
) -> Map[String, Json] raise PlanError {
  let fields = match value {
    Object(fields) => fields
    _ => raise Invalid(path + " must be an object")
  }
  for key, _ in fields {
    if !keys.contains(key) {
      raise Invalid(path + " contains an unknown field: " + key)
    }
  }
  fields
}

///|
fn field_text(
  fields : Map[String, Json],
  key : String,
  path : String,
) -> String raise PlanError {
  match fields.get(key) {
    Some(String(value)) => value
    _ => raise Invalid(path + "." + key + " must be a string")
  }
}

///|
fn field_integer(
  fields : Map[String, Json],
  key : String,
  path : String,
  minimum : Int32,
  maximum : Int32,
) -> Int32 raise PlanError {
  let name = path + "." + key
  let digits = match fields.get(key) {
    Some(Number(_, repr=Some(digits))) => digits
    _ => raise Invalid(name + " must be a decimal integer")
  }
  let mut value = 0L
  for c in digits.iter() {
    if c < '0' || c > '9' {
      raise Invalid(name + " must be a nonnegative decimal integer")
    }
    value = value * 10L + (c.to_int() - '0'.to_int()).to_int64()
    if value > maximum.to_int64() {
      raise Invalid(name + " exceeds " + maximum.to_string())
    }
  }
  if value < minimum.to_int64() {
    raise Invalid(name + " must be at least " + minimum.to_string())
  }
  value.to_int()
}

///|
fn field_array(
  fields : Map[String, Json],
  key : String,
  path : String,
  minimum : Int32,
  maximum : Int32,
) -> Array[Json] raise PlanError {
  match fields.get(key) {
    Some(Array(items)) if items.length() >= minimum && items.length() <= maximum =>
      items
    _ =>
      raise Invalid(
        path +
        "." +
        key +
        " must contain " +
        minimum.to_string() +
        ".." +
        maximum.to_string() +
        " items",
      )
  }
}

///|
fn decode_pack(value : Json, path : String) -> PackOption raise PlanError {
  let row = object_fields(value, path, ["id", "quantity", "cost_cents"])
  {
    id: field_text(row, "id", path),
    quantity: field_integer(row, "quantity", path, 1, max_quantity),
    cost_cents: field_integer(row, "cost_cents", path, 0, 1_000_000_000),
  }
}

///|
fn decode_part(value : Json, path : String) -> Part raise PlanError {
  let row = object_fields(value, path, ["id", "stock", "packs"])
  let packs = []
  for i, item in field_array(row, "packs", path, 0, 16) {
    packs.push(decode_pack(item, path + ".packs[" + i.to_string() + "]"))
  }
  {
    id: field_text(row, "id", path),
    stock: field_integer(row, "stock", path, 0, max_quantity),
    packs,
  }
}

///|
fn decode_kit(value : Json, path : String) -> Kit raise PlanError {
  let row = object_fields(value, path, ["id", "items"])
  let items = []
  for i, value in field_array(row, "items", path, 1, 256) {
    let at = path + ".items[" + i.to_string() + "]"
    let item = object_fields(value, at, ["item", "quantity"])
    items.push({
      item: field_text(item, "item", at),
      quantity: field_integer(item, "quantity", at, 1, max_quantity),
    })
  }
  { id: field_text(row, "id", path), items, }
}

///|
fn decode_order(value : Json, path : String) -> Order raise PlanError {
  let row = object_fields(value, path, ["id", "kit", "quantity"])
  {
    id: field_text(row, "id", path),
    kit: field_text(row, "kit", path),
    quantity: field_integer(row, "quantity", path, 1, max_quantity),
  }
}

///|
/// Decode the versioned worksheet. The planner separately checks identities,
/// references, aggregate quantities and purchase feasibility.
pub fn parse_manifest(text : String) -> Manifest raise PlanError {
  if text.length() > 2_097_152 {
    raise Invalid("manifest text exceeds the input limit")
  }
  let value = @json.parse(
    text,
    max_nesting_depth=12,
    reject_duplicate_keys=true,
    preserve_number_repr=true,
  ) catch {
    _ =>
      raise Invalid("invalid JSON, duplicate object key, or excessive nesting")
  }
  let root = object_fields(value, "manifest", [
    "schema", "parts", "kits", "orders",
  ])
  if field_text(root, "schema", "manifest") != "benchkitmap/v1" {
    raise Invalid("manifest.schema must be benchkitmap/v1")
  }
  let parts = []
  let kits = []
  let orders = []
  for i, value in field_array(root, "parts", "manifest", 1, 256) {
    parts.push(decode_part(value, "parts[" + i.to_string() + "]"))
  }
  for i, value in field_array(root, "kits", "manifest", 1, 256) {
    kits.push(decode_kit(value, "kits[" + i.to_string() + "]"))
  }
  for i, value in field_array(root, "orders", "manifest", 0, 256) {
    orders.push(decode_order(value, "orders[" + i.to_string() + "]"))
  }
  { parts, kits, orders, }
}