///|
fn required_string(
  fields : Map[String, Json],
  key : String,
) -> String raise AssessmentError {
  match fields.get(key) {
    Some(String(value)) if !value.is_empty() => value
    Some(String(_)) => raise AssessmentError("Field `\{key}` must not be empty")
    Some(_) => raise AssessmentError("Field `\{key}` must be a string")
    None => raise AssessmentError("Missing required field `\{key}`")
  }
}

///|
fn optional_string(
  fields : Map[String, Json],
  key : String,
  default_value : String,
) -> String raise AssessmentError {
  match fields.get(key) {
    Some(String(value)) => value
    Some(_) => raise AssessmentError("Field `\{key}` must be a string")
    None => default_value
  }
}

///|
fn required_positive_int(
  fields : Map[String, Json],
  key : String,
) -> Int32 raise AssessmentError {
  match fields.get(key) {
    Some(Number(value, ..)) => {
      if value < 1.0 || value > 1_000_000.0 {
        raise AssessmentError("Field `\{key}` must be a positive integer")
      }
      value.to_int()
    }
    Some(_) => raise AssessmentError("Field `\{key}` must be a number")
    None => raise AssessmentError("Missing required field `\{key}`")
  }
}

///|
fn optional_bool(
  fields : Map[String, Json],
  key : String,
  default_value : Bool,
) -> Bool raise AssessmentError {
  match fields.get(key) {
    Some(True) => true
    Some(False) => false
    Some(_) => raise AssessmentError("Field `\{key}` must be a boolean")
    None => default_value
  }
}

///|
fn string_array(
  value : Json,
  field_name : String,
) -> Array[String] raise AssessmentError {
  match value {
    Array(items) => {
      let result = []
      for item in items {
        match item {
          String(text) if !text.is_empty() => result.push(text)
          String(_) =>
            raise AssessmentError(
              "`\{field_name}` commands must not contain empty strings",
            )
          _ =>
            raise AssessmentError("`\{field_name}` must contain only strings")
        }
      }
      result
    }
    _ => raise AssessmentError("`\{field_name}` must be an array")
  }
}

///|
fn parse_check_kind(value : String) -> CheckKind raise AssessmentError {
  match value {
    "check" | "moon_check" => Compile
    "test" | "moon_test" => Test
    "command" | "custom" => Custom
    _ => raise AssessmentError("Unsupported check kind `\{value}`")
  }
}

///|
fn same_kind(left : CheckKind, right : CheckKind) -> Bool {
  match (left, right) {
    (Compile, Compile) | (Test, Test) | (Custom, Custom) => true
    _ => false
  }
}

///|
fn default_command(kind : CheckKind) -> Array[String] {
  match kind {
    Compile => ["moon", "check"]
    Test => ["moon", "test"]
    Custom => []
  }
}

///|
fn parse_check(value : Json, index : Int32) -> CheckSpec raise AssessmentError {
  guard value is Object(fields) else {
    raise AssessmentError("Check #\{index + 1} must be an object")
  }
  let id = required_string(fields, "id")
  let title = optional_string(fields, "title", id)
  let kind = parse_check_kind(optional_string(fields, "kind", "check"))
  let points = required_positive_int(fields, "points")
  let timeout_seconds = match fields.get("timeout_seconds") {
    Some(_) => required_positive_int(fields, "timeout_seconds")
    None => 120
  }
  guard timeout_seconds <= 3600 else {
    raise AssessmentError("Check `\{id}` timeout must not exceed 3600 seconds")
  }
  let command = match fields.get("command") {
    Some(value) => string_array(value, "check `\{id}` command")
    None => default_command(kind)
  }
  if same_kind(kind, Custom) && command.is_empty() {
    raise AssessmentError("Custom check `\{id}` requires a non-empty command")
  }
  CheckSpec::{
    id,
    title,
    kind,
    command,
    points,
    timeout_seconds,
    required: optional_bool(fields, "required", true),
    hint: optional_string(fields, "hint", ""),
  }
}

///|
pub fn assignment_from_json(
  source : StringView,
) -> Assignment raise AssessmentError {
  let document = @json.parse(source) catch {
    error =>
      raise AssessmentError("Invalid assignment JSON: \{error.to_string()}")
  }
  guard document is Object(fields) else {
    raise AssessmentError("Assignment JSON must be an object")
  }
  let name = required_string(fields, "name")
  let description = optional_string(fields, "description", "")
  let pass_score = required_positive_int(fields, "pass_score")
  let raw_checks = match fields.get("checks") {
    Some(Array(items)) if !items.is_empty() => items
    Some(Array(_)) =>
      raise AssessmentError("Assignment must contain at least one check")
    Some(_) => raise AssessmentError("`checks` must be an array")
    None => raise AssessmentError("Missing required field `checks`")
  }
  let checks = []
  let ids : Map[String, Bool] = Map([])
  let mut max_score = 0
  for index, raw_check in raw_checks {
    let check = parse_check(raw_check, index)
    if ids.contains(check.id) {
      raise AssessmentError("Duplicate check id `\{check.id}`")
    }
    ids.set(check.id, true)
    max_score += check.points
    checks.push(check)
  }
  guard pass_score <= max_score else {
    raise AssessmentError(
      "`pass_score` (\{pass_score}) exceeds total available points (\{max_score})",
    )
  }
  Assignment::{ name, description, pass_score, max_score, checks, }
}