///|
/// One path-addressable payload validation problem.
pub(all) struct ValidationIssue {
  code : String
  path : String
  message : String
} derive(Eq, Debug)

///|
/// Result of validating one JSON payload against one object type.
pub(all) struct ValidationResult {
  object_name : String
  issues : Array[ValidationIssue]
} derive(Eq, Debug)

///|
/// Failure that prevents validation from starting.
pub(all) struct InstanceError {
  code : String
  message : String
} derive(Eq, Debug)

///|
/// Independent validation results for a generated compatibility witness.
pub(all) struct WitnessVerification {
  source_valid : Bool
  target_valid : Bool
  source_issues : Array[ValidationIssue]
  target_issues : Array[ValidationIssue]
} derive(Eq, Debug)

///|
pub fn ValidationResult::is_valid(self : ValidationResult) -> Bool {
  self.issues.is_empty()
}

///|
/// Parse and validate a JSON payload against a named object in the contract.
pub fn validate_json(
  contract : Contract,
  object_name : String,
  input : String,
) -> Result[ValidationResult, InstanceError] {
  if !@json.valid(input) {
    return Err({ code: "INVALID_JSON", message: "payload is not valid JSON" })
  }
  let value = @json.parse(input) catch {
    _ =>
      return Err({ code: "INVALID_JSON", message: "payload is not valid JSON" })
  }
  validate_value(contract, object_name, value)
}

///|
/// Validate an already parsed JSON value.
pub fn validate_value(
  contract : Contract,
  object_name : String,
  value : Json,
) -> Result[ValidationResult, InstanceError] {
  guard contract.find_object(object_name) is Some(object) else {
    return Err({
      code: "UNKNOWN_ROOT_TYPE",
      message: "contract does not define object '" + object_name + "'",
    })
  }
  let issues : Array[ValidationIssue] = []
  validate_object_value(contract, object, value, "$." + object_name, issues)
  Ok({ object_name, issues })
}

///|
/// Re-run a generated witness through both contracts.
pub fn verify_witness(
  source : Contract,
  target : Contract,
  object_name : String,
  witness : Witness,
) -> Result[WitnessVerification, InstanceError] {
  let source_result = match
    validate_json(source, object_name, witness.payload) {
    Ok(result) => result
    Err(error) => return Err(error)
  }
  let target_result = match
    validate_json(target, object_name, witness.payload) {
    Ok(result) => result
    Err(error) => return Err(error)
  }
  Ok({
    source_valid: source_result.is_valid(),
    target_valid: target_result.is_valid(),
    source_issues: source_result.issues,
    target_issues: target_result.issues,
  })
}

///|
fn validate_object_value(
  contract : Contract,
  object : ObjectType,
  value : Json,
  path : String,
  issues : Array[ValidationIssue],
) -> Unit {
  guard value is Object(members) else {
    issues.push({
      code: "EXPECTED_OBJECT",
      path,
      message: "expected object '" + object.name + "'",
    })
    return
  }
  for field in object.fields {
    match members.get(field.name) {
      None =>
        if field.required {
          issues.push({
            code: "REQUIRED_FIELD_MISSING",
            path: path + "." + field.name,
            message: "required field is missing",
          })
        }
      Some(field_value) =>
        validate_field_value(
          contract,
          field,
          field_value,
          path + "." + field.name,
          issues,
        )
    }
  }
  if !object.open {
    for name in members.keys() {
      if !object.has_field(name) {
        issues.push({
          code: "UNKNOWN_FIELD",
          path: path + "." + name,
          message: "closed object does not declare this field",
        })
      }
    }
  }
}

///|
fn validate_field_value(
  contract : Contract,
  field : Field,
  value : Json,
  path : String,
  issues : Array[ValidationIssue],
) -> Unit {
  match field.type_expr {
    StringType =>
      match value {
        String(text) => validate_length(field, text.char_length(), path, issues)
        _ => type_issue("string", path, issues)
      }
    IntType =>
      match value {
        Number(number, ..) =>
          if number.floor() != number {
            type_issue("integer", path, issues)
          } else {
            validate_number(field, number, path, issues)
          }
        _ => type_issue("integer", path, issues)
      }
    NumberType =>
      match value {
        Number(number, ..) => validate_number(field, number, path, issues)
        _ => type_issue("number", path, issues)
      }
    BoolType =>
      match value {
        True | False => ()
        _ => type_issue("boolean", path, issues)
      }
    EnumType(values) =>
      match value {
        String(text) =>
          if !values.contains(text) {
            issues.push({
              code: "ENUM_VALUE_NOT_ALLOWED",
              path,
              message: "value '" + text + "' is not in enum:" + values.join("|"),
            })
          }
        _ => type_issue("enum string", path, issues)
      }
    RefType(name) =>
      match contract.find_object(name) {
        Some(object) =>
          validate_object_value(contract, object, value, path, issues)
        None =>
          issues.push({
            code: "UNKNOWN_REFERENCED_TYPE",
            path,
            message: "referenced type '" + name + "' is unavailable",
          })
      }
    ListType(item) =>
      match value {
        Array(values) => {
          validate_length(field, values.length(), path, issues)
          for index, item_value in values {
            validate_list_item(
              contract,
              item,
              item_value,
              path + "[" + index.to_string() + "]",
              issues,
            )
          }
        }
        _ => type_issue("array", path, issues)
      }
  }
}

///|
fn validate_list_item(
  contract : Contract,
  item : String,
  value : Json,
  path : String,
  issues : Array[ValidationIssue],
) -> Unit {
  match item {
    "string" => if !(value is String(_)) { type_issue("string", path, issues) }
    "int" =>
      match value {
        Number(number, ..) if number.floor() == number => ()
        _ => type_issue("integer", path, issues)
      }
    "number" =>
      if !(value is Number(_, ..)) {
        type_issue("number", path, issues)
      }
    "bool" =>
      if !(value is True || value is False) {
        type_issue("boolean", path, issues)
      }
    _ => {
      let name = item[4:].to_owned()
      match contract.find_object(name) {
        Some(object) =>
          validate_object_value(contract, object, value, path, issues)
        None =>
          issues.push({
            code: "UNKNOWN_REFERENCED_TYPE",
            path,
            message: "referenced type '" + name + "' is unavailable",
          })
      }
    }
  }
}

///|
fn validate_number(
  field : Field,
  number : Double,
  path : String,
  issues : Array[ValidationIssue],
) -> Unit {
  match field.constraints.min_int {
    Some(minimum) =>
      if number < minimum.to_double() {
        issues.push({
          code: "NUMBER_BELOW_MINIMUM",
          path,
          message: "number is below minimum " + minimum.to_string(),
        })
      }
    None => ()
  }
  match field.constraints.max_int {
    Some(maximum) =>
      if number > maximum.to_double() {
        issues.push({
          code: "NUMBER_ABOVE_MAXIMUM",
          path,
          message: "number is above maximum " + maximum.to_string(),
        })
      }
    None => ()
  }
}

///|
fn validate_length(
  field : Field,
  length : Int,
  path : String,
  issues : Array[ValidationIssue],
) -> Unit {
  match field.constraints.min_len {
    Some(minimum) =>
      if length < minimum {
        issues.push({
          code: "LENGTH_BELOW_MINIMUM",
          path,
          message: "length is below minimum " + minimum.to_string(),
        })
      }
    None => ()
  }
  match field.constraints.max_len {
    Some(maximum) =>
      if length > maximum {
        issues.push({
          code: "LENGTH_ABOVE_MAXIMUM",
          path,
          message: "length is above maximum " + maximum.to_string(),
        })
      }
    None => ()
  }
}

///|
fn type_issue(
  expected : String,
  path : String,
  issues : Array[ValidationIssue],
) -> Unit {
  issues.push({ code: "TYPE_MISMATCH", path, message: "expected " + expected })
}