///|
fn integer_control(value : Json) -> Unit raise RunoffError {
  match value {
    Number(n, ..) => {
      if !finite(n) || n < 0.0 || n > 1000.0 {
        raise Parse("integer control outside 0..1000")
      }
      if n.to_int().to_double() != n {
        raise Parse("integer controls cannot be fractional")
      }
    }
    _ => raise Parse("integer control must be a number")
  }
}

///|
fn validate_integer_controls(
  fields : Map[String, Json],
) -> Unit raise RunoffError {
  for key in ["recent", "iterations"] {
    match fields.get(key) {
      Some(v) => integer_control(v)
      None => ()
    }
  }
  for key in ["excluded", "overrides"] {
    match fields.get(key) {
      None => ()
      Some(Array(pairs)) => {
        if pairs.length() > 8256 {
          raise Parse("too many selection controls")
        }
        for pair in pairs {
          match pair {
            Array(columns) => {
              if columns.length() != 2 {
                raise Parse("selection controls require pairs")
              }
              integer_control(columns[0])
              if key == "excluded" {
                integer_control(columns[1])
              }
            }
            _ => raise Parse("selection control must be a pair")
          }
        }
      }
      Some(_) => raise Parse("selection controls must be arrays")
    }
  }
}

///|
fn[T : @json.FromJson] field(
  fields : Map[String, Json],
  key : String,
) -> T raise RunoffError {
  match fields.get(key) {
    None => raise Parse("missing request field: " + key)
    Some(value) =>
      @json.from_json(value) catch {
        _ => raise Parse("invalid request field: " + key)
      }
  }
}

///|
fn[T : @json.FromJson] optional_field(
  fields : Map[String, Json],
  key : String,
  fallback : T,
) -> T raise RunoffError {
  if fields.contains(key) {
    field(fields, key)
  } else {
    fallback
  }
}

///|
fn request_average(fields : Map[String, Json]) -> Average raise RunoffError {
  let name : String = optional_field(fields, "average", "volume")
  match name {
    "volume" => Volume
    "simple" => Simple
    "regression" => Regression
    _ => raise Parse("average must be volume, simple or regression")
  }
}

///|
fn request_pattern(
  fields : Map[String, Json],
  triangle : Triangle,
  average : Average,
) -> Pattern raise RunoffError {
  let tail : Double = optional_field(fields, "tail", 1.0)
  match fields.get("factors") {
    Some(value) => {
      for key in ["recent", "excluded", "overrides"] {
        if fields.contains(key) {
          raise Parse("explicit factors cannot combine with selection controls")
        }
      }
      let factors : Array[Double] = @json.from_json(value) catch {
        _ => raise Parse("factors must be numeric array")
      }
      let p = Pattern::new(factors, tail~)
      compatible(triangle, p)
      p
    }
    None => {
      let recent : Int32 = optional_field(fields, "recent", 0)
      let excluded : Array[(Int32, Int32)] = optional_field(
        fields,
        "excluded",
        [],
      )
      let overrides : Array[(Int32, Double)] = optional_field(
        fields,
        "overrides",
        [],
      )
      let stats = selected_factors(
        triangle,
        average~,
        recent~,
        excluded~,
        overrides~,
      )
      Pattern::new(stats.map(fn(row) { row.factor }), tail~)
    }
  }
}

///|
/// Pure request engine shared by the CLI, WASM hosts and services.
/// Unknown fields and irrelevant model controls are errors, not silent fallbacks.
pub fn execute_request(text : String) -> String raise RunoffError {
  let fields = match parse_json_bounded(text) {
    Object(fields) => fields
    _ => raise Parse("request must be an object")
  }
  validate_integer_controls(fields)
  let action : String = optional_field(fields, "action", "reserve")
  let allowed = [
    "triangle", "incremental", "action", "average", "tail", "factors", "recent",
    "excluded", "overrides", "estimator", "prior", "exposure", "iterations", "shock",
  ]
  for (key, _) in fields {
    if !allowed.contains(key) {
      raise Parse("unknown request field: " + key)
    }
  }
  let rows : Array[Array[Double]] = field(fields, "triangle")
  let incremental : Bool = optional_field(fields, "incremental", false)
  let triangle = if incremental {
    Triangle::from_incremental(rows)
  } else {
    Triangle::new(rows)
  }
  let average = request_average(fields)
  if action == "backtest" {
    for
      key in [
        "factors", "tail", "recent", "excluded", "overrides", "estimator", "prior",
        "exposure", "iterations", "shock",
      ] {
      if fields.contains(key) {
        raise Parse(
          "backtest refits historical factors and rejects model controls",
        )
      }
    }
    return backtest_json(rolling_holdout(triangle, average~))
  }
  if action != "reserve" && action != "diagnostics" && action != "stress" {
    raise Parse("action must be reserve, diagnostics, backtest or stress")
  }
  if action != "reserve" {
    for key in ["estimator", "prior", "exposure", "iterations"] {
      if fields.contains(key) {
        raise Parse("model controls are only valid for reserve action")
      }
    }
  }
  if action != "stress" && fields.contains("shock") {
    raise Parse("shock is only valid for stress action")
  }
  let pattern = request_pattern(fields, triangle, average)
  if action == "diagnostics" {
    return diagnostics_json(triangle, pattern)
  }
  if action == "stress" {
    let shock : Double = optional_field(fields, "shock", 0.01)
    return stress_json(factor_sensitivity(triangle, pattern, shock~))
  }
  let estimator : String = optional_field(fields, "estimator", "chain-ladder")
  if estimator != "benktander" && fields.contains("iterations") {
    raise Parse("iterations only valid for Benktander")
  }
  if estimator != "cape-cod" && fields.contains("exposure") {
    raise Parse("exposure only valid for Cape Cod")
  }
  if estimator != "bornhuetter-ferguson" &&
    estimator != "benktander" &&
    fields.contains("prior") {
    raise Parse("prior only valid for BF or Benktander")
  }
  let estimate = match estimator {
    "chain-ladder" => chain_ladder(triangle, pattern)
    "bornhuetter-ferguson" =>
      bornhuetter_ferguson(triangle, pattern, field(fields, "prior"))
    "benktander" => {
      let iterations : Int32 = optional_field(fields, "iterations", 2)
      benktander(triangle, pattern, field(fields, "prior"), iterations~)
    }
    "cape-cod" =>
      cape_cod(triangle, pattern, field(fields, "exposure")).estimate()
    _ => raise Parse("unsupported estimator")
  }
  estimate.to_json()
}

///|
/// Error output remains valid JSON, even for quotes or non-ASCII messages.
pub fn execute_request_result(text : String) -> String {
  execute_request(text) catch {
    err =>
      Json::object({
        "schema": Json::string("moonrunoff/error/v1"),
        "error": Json::string(@debug.to_string(err)),
      }).stringify()
  }
}