///|
/// Validation rules for common CBOR application payloads.
pub(all) enum ValidationRule {
  Required(String)
  RequiredText(String)
  RequiredBool(String)
  RequiredInteger(String)
  IntegerRange(String, Int64, Int64)
  TextLength(String, Int, Int)
  ArrayLength(String, Int, Int)
  ObjectFields(String, Array[ValidationRule])
  ArrayItems(String, Array[ValidationRule])
  OneOfText(String, Array[String])
} derive(Eq, Debug)

///|
/// One machine-readable issue found during validation.
pub(all) struct ValidationIssue {
  path : String
  code : String
  message : String
} derive(Eq, Debug)

///|
/// A complete validation result; all independent issues are retained.
pub(all) struct ValidationReport {
  valid : Bool
  issues : Array[ValidationIssue]
} derive(Eq, Debug)

///|
/// Construct a required-field rule.
pub fn validation_required(name : String) -> ValidationRule {
  Required(name)
}

///|
/// Construct a required non-empty text rule.
pub fn validation_required_text(name : String) -> ValidationRule {
  RequiredText(name)
}

///|
/// Construct a required boolean rule.
pub fn validation_required_bool(name : String) -> ValidationRule {
  RequiredBool(name)
}

///|
/// Construct a required integer rule.
pub fn validation_required_integer(name : String) -> ValidationRule {
  RequiredInteger(name)
}

///|
/// Construct an inclusive integer range rule.
pub fn validation_int_range(
  name : String,
  minimum : Int64,
  maximum : Int64,
) -> ValidationRule {
  IntegerRange(name, minimum, maximum)
}

///|
/// Construct an inclusive UTF-16 string length rule.
pub fn validation_text_length(
  name : String,
  minimum : Int,
  maximum : Int,
) -> ValidationRule {
  TextLength(name, minimum, maximum)
}

///|
/// Construct an inclusive array length rule.
pub fn validation_array_length(
  name : String,
  minimum : Int,
  maximum : Int,
) -> ValidationRule {
  ArrayLength(name, minimum, maximum)
}

///|
/// Construct a nested object rule.
pub fn validation_object(
  name : String,
  rules : Array[ValidationRule],
) -> ValidationRule {
  ObjectFields(name, rules)
}

///|
/// Construct a rule applied to every item in an array.
pub fn validation_items(
  name : String,
  rules : Array[ValidationRule],
) -> ValidationRule {
  ArrayItems(name, rules)
}

///|
/// Construct a finite text enumeration rule.
pub fn validation_one_of(
  name : String,
  allowed : Array[String],
) -> ValidationRule {
  OneOfText(name, allowed)
}

///|
fn validation_issue(
  path : String,
  code : String,
  message : String,
) -> ValidationIssue {
  { path, code, message }
}

///|
fn validation_report(issues : Array[ValidationIssue]) -> ValidationReport {
  { valid: issues.length() == 0, issues }
}

///|
/// Return a stable single-line representation for log aggregation.
pub fn validation_issue_to_string(issue : ValidationIssue) -> String {
  issue.code + " at " + issue.path + ": " + issue.message
}

///|
/// Render all issues in deterministic order.
pub fn validation_report_to_string(report : ValidationReport) -> String {
  if report.valid {
    "valid"
  } else {
    let builder = StringBuilder::new()
    for i = 0; i < report.issues.length(); i = i + 1 {
      if i > 0 {
        builder.write_string("\n")
      }
      builder.write_string(validation_issue_to_string(report.issues[i]))
    }
    builder.to_string()
  }
}

///|
/// Return the number of errors in a validation report.
pub fn validation_error_count(report : ValidationReport) -> Int {
  report.issues.length()
}

///|
fn value_field(value : CborValue, name : String) -> CborValue? {
  match value {
    Map(entries) => {
      for entry in entries {
        match entry.0 {
          Text(key) => if key == name { return Some(entry.1) }
          _ => ()
        }
      }
      None
    }
    _ => None
  }
}

///|
fn value_at_rule(
  value : CborValue,
  name : String,
  parent : String,
) -> (CborValue, String)? {
  match value_field(value, name) {
    Some(found) => Some((found, parent + "." + name))
    None => None
  }
}

///|
fn validate_text_value(
  value : CborValue,
  path : String,
  issues : Array[ValidationIssue],
  required : Bool,
) -> Unit {
  match value {
    Text(text) =>
      if required && text.length() == 0 {
        issues.push(
          validation_issue(path, "empty_text", "text must not be empty"),
        )
      }
    _ => issues.push(validation_issue(path, "type", "expected text"))
  }
}

///|
fn validate_bool_value(
  value : CborValue,
  path : String,
  issues : Array[ValidationIssue],
) -> Unit {
  match value {
    Simple(20) | Simple(21) => ()
    _ => issues.push(validation_issue(path, "type", "expected boolean"))
  }
}

///|
fn integer_value(value : CborValue) -> Int64? {
  match value {
    Integer(number) => Some(number)
    Unsigned(number) if number <= 0x7FFFFFFFFFFFFFFFUL =>
      Some(number.reinterpret_as_int64())
    _ => None
  }
}

///|
fn validate_integer_value(
  value : CborValue,
  path : String,
  issues : Array[ValidationIssue],
) -> Int64? {
  match integer_value(value) {
    Some(number) => Some(number)
    None => {
      issues.push(
        validation_issue(path, "type", "expected Int64-compatible integer"),
      )
      None
    }
  }
}

///|
fn validate_rule(
  value : CborValue,
  rule : ValidationRule,
  path : String,
  issues : Array[ValidationIssue],
) -> Unit {
  match rule {
    Required(name) =>
      match value_at_rule(value, name, path) {
        Some(_) => ()
        None =>
          issues.push(
            validation_issue(path + "." + name, "required", "field is required"),
          )
      }
    RequiredText(name) =>
      match value_at_rule(value, name, path) {
        Some((found, field_path)) =>
          validate_text_value(found, field_path, issues, true)
        None =>
          issues.push(
            validation_issue(
              path + "." + name,
              "required",
              "text field is required",
            ),
          )
      }
    RequiredBool(name) =>
      match value_at_rule(value, name, path) {
        Some((found, field_path)) =>
          validate_bool_value(found, field_path, issues)
        None =>
          issues.push(
            validation_issue(
              path + "." + name,
              "required",
              "boolean field is required",
            ),
          )
      }
    RequiredInteger(name) =>
      match value_at_rule(value, name, path) {
        Some((found, field_path)) =>
          ignore(validate_integer_value(found, field_path, issues))
        None =>
          issues.push(
            validation_issue(
              path + "." + name,
              "required",
              "integer field is required",
            ),
          )
      }
    IntegerRange(name, minimum, maximum) =>
      match value_at_rule(value, name, path) {
        Some((found, field_path)) =>
          match validate_integer_value(found, field_path, issues) {
            Some(number) =>
              if number < minimum || number > maximum {
                issues.push(
                  validation_issue(
                    field_path, "range", "integer is outside the inclusive range",
                  ),
                )
              }
            None => ()
          }
        None =>
          issues.push(
            validation_issue(
              path + "." + name,
              "required",
              "integer field is required",
            ),
          )
      }
    TextLength(name, minimum, maximum) =>
      match value_at_rule(value, name, path) {
        Some((found, field_path)) =>
          match found {
            Text(text) =>
              if text.length() < minimum || text.length() > maximum {
                issues.push(
                  validation_issue(
                    field_path, "length", "text length is outside the inclusive range",
                  ),
                )
              }
            _ =>
              issues.push(validation_issue(field_path, "type", "expected text"))
          }
        None =>
          issues.push(
            validation_issue(
              path + "." + name,
              "required",
              "text field is required",
            ),
          )
      }
    ArrayLength(name, minimum, maximum) =>
      match value_at_rule(value, name, path) {
        Some((found, field_path)) =>
          match found {
            Array(items) =>
              if items.length() < minimum || items.length() > maximum {
                issues.push(
                  validation_issue(
                    field_path, "length", "array length is outside the inclusive range",
                  ),
                )
              }
            _ =>
              issues.push(
                validation_issue(field_path, "type", "expected array"),
              )
          }
        None =>
          issues.push(
            validation_issue(
              path + "." + name,
              "required",
              "array field is required",
            ),
          )
      }
    ObjectFields(name, nested_rules) =>
      match value_at_rule(value, name, path) {
        Some((found, field_path)) =>
          match found {
            Map(_) =>
              for nested_rule in nested_rules {
                validate_rule(found, nested_rule, field_path, issues)
              }
            _ =>
              issues.push(
                validation_issue(field_path, "type", "expected object"),
              )
          }
        None =>
          issues.push(
            validation_issue(
              path + "." + name,
              "required",
              "object field is required",
            ),
          )
      }
    ArrayItems(name, item_rules) =>
      match value_at_rule(value, name, path) {
        Some((found, field_path)) =>
          match found {
            Array(items) =>
              for i = 0; i < items.length(); i = i + 1 {
                let item_path = field_path + "[" + i.to_string() + "]"
                for item_rule in item_rules {
                  validate_rule(items[i], item_rule, item_path, issues)
                }
              }
            _ =>
              issues.push(
                validation_issue(field_path, "type", "expected array"),
              )
          }
        None =>
          issues.push(
            validation_issue(
              path + "." + name,
              "required",
              "array field is required",
            ),
          )
      }
    OneOfText(name, allowed) =>
      match value_at_rule(value, name, path) {
        Some((found, field_path)) =>
          match found {
            Text(text) => {
              let mut found_allowed = false
              for item in allowed {
                if item == text {
                  found_allowed = true
                }
              }
              if !found_allowed {
                issues.push(
                  validation_issue(
                    field_path, "enum", "text is not an allowed value",
                  ),
                )
              }
            }
            _ =>
              issues.push(validation_issue(field_path, "type", "expected text"))
          }
        None =>
          issues.push(
            validation_issue(
              path + "." + name,
              "required",
              "text field is required",
            ),
          )
      }
  }
}

///|
/// Validate an object and retain every independent field issue.
pub fn validate_object_payload(
  value : CborValue,
  rules : Array[ValidationRule],
) -> ValidationReport {
  let issues = []
  match value {
    Map(_) =>
      for rule in rules {
        validate_rule(value, rule, "$", issues)
      }
    _ => issues.push(validation_issue("$", "type", "payload must be an object"))
  }
  validation_report(issues)
}

///|
/// Validate a message payload and prefix its errors with the message ID.
pub fn validate_message_payload(
  message : AppMessage,
  rules : Array[ValidationRule],
) -> ValidationReport {
  let report = validate_object_payload(message.payload, rules)
  let issues = []
  for issue in report.issues {
    issues.push(
      validation_issue(
        "message[" + message.id + "]" + issue.path,
        issue.code,
        issue.message,
      ),
    )
  }
  validation_report(issues)
}

///|
/// Reject unknown text fields when an application has a closed schema.
pub fn validation_unknown_fields(
  value : CborValue,
  allowed : Array[String],
) -> ValidationReport {
  let issues = []
  match value {
    Map(entries) =>
      for entry in entries {
        match entry.0 {
          Text(name) => {
            let mut known = false
            for item in allowed {
              if item == name {
                known = true
              }
            }
            if !known {
              issues.push(
                validation_issue("$." + name, "unknown", "field is not allowed"),
              )
            }
          }
          _ =>
            issues.push(
              validation_issue("$", "key_type", "object key must be text"),
            )
        }
      }
    _ => issues.push(validation_issue("$", "type", "payload must be an object"))
  }
  validation_report(issues)
}

///|
/// Compose two reports without losing issue order.
pub fn validation_combine(
  first : ValidationReport,
  second : ValidationReport,
) -> ValidationReport {
  let issues = []
  for issue in first.issues {
    issues.push(issue)
  }
  for issue in second.issues {
    issues.push(issue)
  }
  validation_report(issues)
}

///|
/// Add a path prefix to every issue in a report.
pub fn validation_prefix(
  prefix : String,
  report : ValidationReport,
) -> ValidationReport {
  let issues = []
  for issue in report.issues {
    issues.push(
      validation_issue(prefix + issue.path, issue.code, issue.message),
    )
  }
  validation_report(issues)
}

///|
/// Return a report containing one explicit business-rule error.
pub fn validation_business_error(
  path : String,
  code : String,
  message : String,
) -> ValidationReport {
  validation_report([validation_issue(path, code, message)])
}

///|
/// Return true when a value passes a set of object rules.
pub fn validation_succeeds(
  value : CborValue,
  rules : Array[ValidationRule],
) -> Bool {
  validate_object_payload(value, rules).valid
}