///|
fn object(value : Json) -> Map[String, Json] raise FakeError {
  match value {
    Object(fields) => fields
    _ => raise Invalid("JSON object required")
  }
}

///|
fn text_option(
  fields : Map[String, Json],
  key : String,
  fallback : String,
) -> String raise FakeError {
  match fields.get(key) {
    None => fallback
    Some(String(value)) => value
    _ => raise Invalid("string option required: " + key)
  }
}

///|
fn int_option(
  fields : Map[String, Json],
  key : String,
  fallback : Int,
) -> Int raise FakeError {
  match fields.get(key) {
    None => fallback
    Some(value) =>
      @json.from_json(value) catch {
        _ => raise Invalid("integer option required: " + key)
      }
  }
}

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

///|
fn string_options(
  fields : Map[String, Json],
  key : String,
) -> Array[String] raise FakeError {
  @json.from_json(required(fields, key)) catch {
    _ => raise Invalid("string array required: " + key)
  }
}

///|
fn reject_unknown(
  fields : Map[String, Json],
  allowed : Array[String],
) -> Unit raise FakeError {
  for key, _ in fields {
    if !allowed.contains(key) {
      raise Invalid("unknown option: " + key)
    }
  }
}

///|
fn parse_column(value : Json) -> Column raise FakeError {
  let fields = object(value)
  let name = text_option(fields, "name", "")
  let kind = text_option(fields, "provider", "")
  let extra = match kind {
    "localized" => ["locale", "method"]
    "integer" => ["min", "max"]
    "decimal" => ["min", "max", "places"]
    "date" => ["first", "last"]
    "boolean" => ["percent"]
    "sentence" => ["words"]
    "template" => ["template"]
    "choice" => ["values"]
    "weighted_choice" => ["values", "weights"]
    "constant" => ["value"]
    "sequence" => ["start", "step"]
    _ => []
  }
  let allowed = ["name", "provider", "unique", "null_percent"]
  for key in extra {
    allowed.push(key)
  }
  reject_unknown(fields, allowed)
  let provider = match kind {
    "localized" =>
      Localized(
        text_option(fields, "locale", "en_US"),
        text_option(fields, "method", "name"),
      )
    "name" => Name
    "address" => Address
    "email" => Email
    "username" => Username
    "url" => Url
    "uuid4" => Uuid4
    "ipv4" => Ipv4
    "ipv6" => Ipv6
    "mac" => MacAddress
    "color" => HexColor
    "company" => Company
    "job" => Job
    "word" => Word
    "sentence" => Sentence(int_option(fields, "words", 6))
    "boolean" => Boolean(int_option(fields, "percent", 50))
    "integer" =>
      Integer(int_option(fields, "min", 0), int_option(fields, "max", 100))
    "decimal" =>
      Decimal(
        int_option(fields, "min", 0),
        int_option(fields, "max", 10000),
        int_option(fields, "places", 2),
      )
    "date" =>
      DateBetween(
        text_option(fields, "first", "2000-01-01"),
        text_option(fields, "last", "2030-12-31"),
      )
    "ean13" => Ean13
    "isbn13" => Isbn13
    "template" => Template(text_option(fields, "template", "????-####"))
    "choice" => Choice(string_options(fields, "values"))
    "weighted_choice" => {
      let weights : Array[Int] = @json.from_json(required(fields, "weights")) catch {
        _ => raise Invalid("integer weight array required")
      }
      WeightedChoice(string_options(fields, "values"), weights)
    }
    "constant" => Constant(required(fields, "value"))
    "sequence" =>
      Sequence(int_option(fields, "start", 0), int_option(fields, "step", 1))
    _ => raise Invalid("unknown provider: " + kind)
  }
  let unique = match fields.get("unique") {
    None | Some(False) => false
    Some(True) => true
    _ => raise Invalid("unique must be boolean")
  }
  Column::new(
    name,
    provider,
    unique~,
    null_percent=int_option(fields, "null_percent", 0),
  )
}

///|
/// JSON request with seed/count/columns/format; output is json, jsonl, or CSV.
pub fn generate_request(request : String) -> String raise FakeError {
  if request.length() > 1000000 {
    raise Invalid("request length budget")
  }
  let input = @json.parse(request) catch {
    _ => raise Invalid("invalid JSON request")
  }
  let fields = object(input)
  if fields.contains("schema") {
    reject_unknown(fields, ["seed", "count", "schema", "tables", "format"])
    let count = int_option(fields, "count", 10)
    let format = text_option(fields, "format", "json")
    if count < 0 || count > 10000 || !["json", "jsonl"].contains(format) {
      raise Invalid("nested batch count/format")
    }
    let session = session_from_fields(fields)
    let rows = []
    let mut size = 0
    for _ in 0.. 8000000 {
        raise Invalid("batch output budget")
      }
      rows.push(row)
    }
    return if format == "json" {
      rows.to_json().stringify()
    } else if rows.is_empty() {
      ""
    } else {
      rows.map(r => r.stringify()).join("\n") + "\n"
    }
  }
  reject_unknown(fields, ["seed", "count", "columns", "format"])
  let seed : UInt = match fields.get("seed") {
    None => 0U
    Some(value) =>
      @json.from_json(value) catch {
        _ => raise Invalid("seed must be UInt32")
      }
  }
  let count = int_option(fields, "count", 10)
  let format = text_option(fields, "format", "json")
  if !["json", "jsonl", "csv"].contains(format) {
    raise Invalid("invalid output format")
  }
  let entries = match required(fields, "columns") {
    Array(columns) => columns
    _ => raise Invalid("columns array required")
  }
  let columns = []
  for column in entries {
    columns.push(parse_column(column))
  }
  render_records(columns, records(seed, columns, count), format)
}