///|
fn validation_error(
  row : Int,
  column : String,
  code : String,
  message : String,
) -> ValidationError {
  { row, column, code, message }
}

///|
fn kind_name(kind : FieldKind) -> String {
  match kind {
    Text => "text"
    NonEmpty => "non_empty"
    Integer => "integer"
    Decimal => "decimal"
    Boolean => "boolean"
  }
}

///|
fn is_digit(c : Char) -> Bool {
  c >= '0' && c <= '9'
}

///|
fn is_integer_text(text : String) -> Bool {
  let s = text.trim().to_owned()
  if s.is_empty() {
    return false
  }
  let mut i = 0
  if s.get_char(0) == Some('-') || s.get_char(0) == Some('+') {
    if s.length() == 1 {
      return false
    }
    i = 1
  }
  while i < s.length() {
    match s.get_char(i) {
      Some(c) => if !is_digit(c) { return false }
      None => return false
    }
    i = i + 1
  }
  true
}

///|
fn is_decimal_text(text : String) -> Bool {
  let s = text.trim().to_owned()
  if s.is_empty() {
    return false
  }
  let mut i = 0
  let mut dots = 0
  let mut digits = 0
  if s.get_char(0) == Some('-') || s.get_char(0) == Some('+') {
    if s.length() == 1 {
      return false
    }
    i = 1
  }
  while i < s.length() {
    match s.get_char(i) {
      Some('.') => {
        dots = dots + 1
        if dots > 1 {
          return false
        }
      }
      Some(c) => if is_digit(c) { digits = digits + 1 } else { return false }
      None => return false
    }
    i = i + 1
  }
  digits > 0
}

///|
fn is_boolean_text(text : String) -> Bool {
  let s = text.trim().to_owned().to_lower()
  s == "true" || s == "false" || s == "1" || s == "0" || s == "yes" || s == "no"
}

///|
fn matches_kind(text : String, kind : FieldKind) -> Bool {
  match kind {
    Text => true
    NonEmpty => !text.trim().is_empty()
    Integer => is_integer_text(text)
    Decimal => is_decimal_text(text)
    Boolean => is_boolean_text(text)
  }
}

///|
fn has_rule(schema : Schema, name : String) -> Bool {
  for rule in schema.columns {
    if rule.name == name {
      return true
    }
  }
  false
}

///|
/// Validate a parsed table against a header-based schema.
pub fn validate(table : Table, schema : Schema) -> ValidationReport {
  let errors : Array[ValidationError] = []
  if table.header.length() == 0 {
    errors.push(
      validation_error(
        0, "", "missing_header", "schema validation requires a header row",
      ),
    )
    return { errors, }
  }
  for rule in schema.columns {
    match table.find_column(rule.name) {
      Some(column) => {
        let mut r = 0
        while r < table.rows.length() {
          let value = match table.rows[r].get(column) {
            Some(v) => v
            None => ""
          }
          if rule.required && value.trim().is_empty() {
            let msg =
              $|column '\{rule.name}' is required
            errors.push(validation_error(r + 1, rule.name, "required", msg))
          } else if !value.trim().is_empty() && !matches_kind(value, rule.kind) {
            let msg =
              $|column '\{rule.name}' expects \{kind_name(rule.kind)}
            errors.push(validation_error(r + 1, rule.name, "type", msg))
          }
          r = r + 1
        }
      }
      None =>
        if rule.required {
          let msg =
            $|required column '\{rule.name}' is missing
          errors.push(validation_error(0, rule.name, "missing_column", msg))
        }
    }
  }
  if !schema.allow_extra_columns {
    for name in table.header {
      if !has_rule(schema, name) {
        let msg =
          $|column '\{name}' is not declared in schema
        errors.push(validation_error(0, name, "extra_column", msg))
      }
    }
  }
  { errors, }
}

///|
/// Whether a validation report has no errors.
pub fn ValidationReport::is_valid(self : ValidationReport) -> Bool {
  self.errors.is_empty()
}

///|
/// Return a compact summary for logs and examples.
pub fn ValidationReport::summary(self : ValidationReport) -> String {
  if self.is_valid() {
    "valid"
  } else {
    let text =
      $|\{self.errors.length()} validation error(s)
    text
  }
}