///|
fn parse_json_bounded(text : String) -> Json raise RunoffError {
  if text.length() > 1048576 {
    raise Parse("JSON input exceeds 1 Mi code units")
  }
  @json.parse(text[:], max_nesting_depth=16, reject_duplicate_keys=true) catch {
    _ => raise Parse("invalid JSON syntax, duplicate key or nesting depth")
  }
}

///|
fn json_rows(value : Json) -> Array[Array[Double]] raise RunoffError {
  let rows : Array[Array[Double]] = @json.from_json(value) catch {
    _ => raise Parse("triangle must contain numeric arrays")
  }
  rows
}

///|
/// Bare JSON array-of-arrays, without hidden origin labels or coercion.
pub fn Triangle::from_json(
  text : String,
  incremental~ : Bool = false,
) -> Triangle raise RunoffError {
  let rows = json_rows(parse_json_bounded(text))
  if incremental {
    Triangle::from_incremental(rows)
  } else {
    Triangle::new(rows)
  }
}

///|
pub fn Triangle::to_json(
  self : Triangle,
  incremental~ : Bool = false,
) -> String {
  let rows = if incremental { self.incremental() } else { self.cumulative() }
  Json::Json(rows).stringify()
}

///|
pub fn Pattern::to_json(self : Pattern) -> String {
  Json::object({
    "factors": Json::Json(self.factors),
    "tail": Json::Json(self.tail),
  }).stringify()
}

///|
pub fn Pattern::from_json(text : String) -> Pattern raise RunoffError {
  match parse_json_bounded(text) {
    Object(fields) => {
      if fields.length() != 2 {
        raise Parse("pattern requires exactly factors and tail")
      }
      let factors : Array[Double] = match fields.get("factors") {
        Some(v) =>
          @json.from_json(v) catch {
            _ => raise Parse("factors must be a number array")
          }
        None => raise Parse("missing factors")
      }
      let tail : Double = match fields.get("tail") {
        Some(v) =>
          @json.from_json(v) catch {
            _ => raise Parse("tail must be numeric")
          }
        None => raise Parse("missing tail")
      }
      Pattern::new(factors, tail~)
    }
    _ => raise Parse("pattern must be a JSON object")
  }
}

///|
/// Versioned result schema uses zero-based origins, ages and calendars.
pub fn Estimate::to_json(self : Estimate) -> String raise RunoffError {
  let rows : Array[Json] = []
  for row in self.origins {
    rows.push(
      Json::object({
        "origin": Json::Json(row.origin),
        "age": Json::Json(row.age),
        "latest": Json::Json(row.latest),
        "ultimate": Json::Json(row.ultimate),
        "reserve": Json::Json(row.reserve),
      }),
    )
  }
  let payments : Array[Json] = []
  for row in self.payments() {
    payments.push(
      Json::object({
        "origin": Json::Json(row.origin),
        "age": Json::Json(row.age),
        "calendar": Json::Json(row.calendar),
        "amount": Json::Json(row.amount),
        "is_tail": Json::Json(row.is_tail),
      }),
    )
  }
  Json::object({
    "schema": Json::Json("moonrunoff/v1"),
    "estimator": Json::Json(self.estimator),
    "origins": Json::Json(rows),
    "payments": Json::Json(payments),
    "total_latest": Json::Json(self.total_latest()),
    "total_ultimate": Json::Json(self.total_ultimate()),
    "total_reserve": Json::Json(self.total_reserve()),
  }).stringify()
}