///|
fn encode_demand(d : Demand) -> Json {
  Json::object({
    "id": Json(d.id),
    "material": Json(d.material),
    "length": Json(d.length),
    "quantity": Json(d.quantity),
  })
}

///|
fn encode_stock(s : Stock) -> Json {
  Json::object({
    "id": Json(s.id),
    "material": Json(s.material),
    "length": Json(s.length),
    "cost": Json(s.cost),
    "quantity": Json(s.quantity),
    "trim_start": Json(s.trim_start),
    "trim_end": Json(s.trim_end),
  })
}

///|
pub fn encode_problem(p : Problem) -> String {
  Json::object({
    "schema": Json("mooncutstock.problem/1"),
    "demands": Json::array(p.demands.map(encode_demand)),
    "stocks": Json::array(p.stocks.map(encode_stock)),
    "kerf": Json(p.kerf),
    "min_remnant": Json(p.min_remnant),
  }).stringify()
}

///|
pub fn status_name(status : Status) -> String {
  match status {
    Optimal => "optimal"
    FeasibleBudget => "feasible-budget"
    Infeasible => "infeasible"
    UnknownBudget => "unknown-budget"
  }
}

///|
fn totals_json(t : Totals) -> Json {
  Json::object({
    "cost": Json(t.cost),
    "purchased": Json(t.purchased),
    "useful": Json(t.useful),
    "kerf_loss": Json(t.kerf_loss),
    "trim_loss": Json(t.trim_loss),
    "reusable": Json(t.reusable),
    "scrap": Json(t.scrap),
    "bars": Json(t.bars),
    "pieces": Json(t.pieces),
  })
}

///|
fn bar_json(a : Pattern) -> Json {
  Json::object({ "stock": Json(a.stock), "counts": Json(a.counts) })
}

///|
/// External plan format stores only source decisions; cached geometry is never trusted.
pub fn encode_plan(p : Problem, plan : Plan) -> Result[String, String] {
  let totals = match audit(p, plan) {
    Err(e) => return Err(e)
    Ok(t) => t
  }
  Ok(
    Json::object({
      "schema": Json("mooncutstock.plan/1"),
      "bars": Json::array(plan.bars.map(bar_json)),
      "totals": totals_json(totals),
    }).stringify(),
  )
}

///|
fn plan_json(p : Problem, value : Json) -> Plan raise CodecError {
  let f = codec_fields(value, ["schema", "bars", "totals"])
  if codec_string(required(f, "schema")) != "mooncutstock.plan/1" {
    raise Invalid("unsupported plan schema")
  }
  let bars = codec_array(required(f, "bars"), 128).map(v => {
    let f = codec_fields(v, ["stock", "counts"])
    let counts = codec_array(required(f, "counts"), 12).map(codec_int)
    match pattern(p, codec_int(required(f, "stock")), counts) {
      Ok(a) => a
      Err(e) => raise Invalid(e)
    }
  })
  let plan = match plan_from_patterns(p, bars) {
    Ok(v) => v
    Err(e) => raise Invalid(e)
  }
  // Totals are optional, but when supplied every field must match independent audit.
  match f.get("totals") {
    None => ()
    Some(t) => {
      let fields = codec_fields(t, [
        "cost", "purchased", "useful", "kerf_loss", "trim_loss", "reusable", "scrap",
        "bars", "pieces",
      ])
      let actual = audit(p, plan).unwrap()
      let expected = match totals_json(actual) {
        Object(v) => v
        _ => abort("internal totals object")
      }
      for key, value in expected {
        // Larger totals than 1e6 are valid, so compare JSON values rather than bounded input integers.
        if required(fields, key) != value {
          raise Invalid("plan total mismatch: " + key)
        }
      }
    }
  }
  plan
}

///|
pub fn decode_plan(p : Problem, text : String) -> Result[Plan, String] {
  Ok(plan_json(p, parse_json(text))) catch {
    Invalid(e) => Err(e)
  }
}