///|
pub(all) struct Diagnostic {
  severity : String
  code : String
  message : String
  feature : Int32?
  column : String?
  byte_offset : Int32?
} derive(Eq, Debug, ToJson, @json.FromJson)

///|
pub(all) struct ValidationReport {
  valid : Bool
  checked_features : Int32
  diagnostics : Array[Diagnostic]
  truncated : Bool
} derive(Eq, Debug, ToJson, @json.FromJson)

///|
fn diagnostic_from_error(error : FgbError, feature : Int32?) -> Diagnostic {
  match error {
    InvalidData(offset, message) =>
      {
        severity: "error",
        code: "invalid_data",
        message,
        feature,
        column: None,
        byte_offset: Some(offset),
      }
    Unsupported(message) =>
      {
        severity: "error",
        code: "unsupported",
        message,
        feature,
        column: None,
        byte_offset: None,
      }
    LimitExceeded(message) =>
      {
        severity: "error",
        code: "limit_exceeded",
        message,
        feature,
        column: None,
        byte_offset: None,
      }
    InvalidArgument(message) =>
      {
        severity: "error",
        code: "invalid_argument",
        message,
        feature,
        column: None,
        byte_offset: None,
      }
  }
}

///|
pub fn Dataset::validate(
  self : Dataset,
  limits~ : Limits = Limits::default(),
  max_diagnostics~ : Int32 = 100,
) -> ValidationReport raise FgbError {
  limits.validate()
  if max_diagnostics < 1 || max_diagnostics > 10000 {
    raise InvalidArgument("diagnostic limit must be between 1 and 10000")
  }
  let diagnostics : Array[Diagnostic] = []
  validate_header(self.header, limits~) catch {
    error => diagnostics.push(diagnostic_from_error(error, None))
  }
  if self.header.features_count > 0 &&
    self.header.features_count != self.features.length() {
    diagnostics.push({
      severity: "error",
      code: "count_mismatch",
      message: "header count differs from dataset length",
      feature: None,
      column: None,
      byte_offset: None,
    })
  }
  enforce(self.features.length(), limits.max_features, "validation features")
  let unique_values : Array[Map[String, Bool]] = Array::makei(
    self.header.columns.length(),
    _ => Map([]),
  )
  let mut checked_features = 0
  let mut truncated = false
  for ordinal in 0..= max_diagnostics {
      truncated = true
      break
    }
    let feature = self.features[ordinal]
    checked_features += 1
    try {
      match feature.geometry {
        None =>
          if self.header.index_node_size > 0 {
            raise InvalidArgument(
              "indexed dataset cannot contain a null geometry",
            )
          }
        Some(geometry) => {
          geometry.validate(limits~)
          if self.header.geometry_kind != Unknown &&
            self.header.geometry_kind != geometry.kind {
            raise InvalidArgument(
              "geometry conflicts with declared dataset type",
            )
          }
          match (self.header.envelope, geometry.bounds()) {
            (Some(envelope), Some(bounds)) if !envelope.contains(bounds) =>
              raise InvalidArgument("geometry lies outside declared envelope")
            _ => ()
          }
        }
      }
      encode_properties(feature.properties, self.header.columns, limits~)
      |> ignore
    } catch {
      error => {
        diagnostics.push(diagnostic_from_error(error, Some(ordinal)))
        continue
      }
    }
    for column_index in 0..= max_diagnostics {
        truncated = true
        break
      }
      let column = self.header.columns[column_index]
      let value = feature.properties[column_index]
      if column.primary_key && value == Null {
        diagnostics.push({
          severity: "error",
          code: "null_primary_key",
          message: "primary key is null",
          feature: Some(ordinal),
          column: Some(column.name),
          byte_offset: None,
        })
      }
      if (column.unique || column.primary_key) && value != Null {
        let writer = ByteWriter::new()
        write_property(writer, value, column.type_code, limits)
        let bytes = writer.finish()
        let key = bytes.to_string()
        if unique_values[column_index].contains(key) {
          diagnostics.push({
            severity: "error",
            code: "duplicate_unique_value",
            message: "duplicate value in unique column",
            feature: Some(ordinal),
            column: Some(column.name),
            byte_offset: None,
          })
        } else {
          unique_values[column_index][key] = true
        }
      }
    }
  }
  {
    valid: diagnostics.is_empty() && !truncated,
    checked_features,
    diagnostics,
    truncated,
  }
}

///|
pub fn audit_archive(
  data : Bytes,
  limits~ : Limits = Limits::default(),
) -> ValidationReport {
  try {
    let reader = Reader::open(data, limits~)
    reader.validate()
    reader.read_all().validate(limits~)
  } catch {
    error =>
      {
        valid: false,
        checked_features: 0,
        diagnostics: [diagnostic_from_error(error, None)],
        truncated: false,
      }
  }
}

///|
pub fn Header::column(self : Header, name : String) -> Column? {
  for column in self.columns {
    if column.name == name {
      return Some(column)
    }
  }
  None
}

///|
pub fn Header::is_wgs84(self : Header) -> Bool {
  match self.crs {
    None => false
    Some(crs) =>
      crs.code == 4326 &&
      (crs.org == "" || crs.org == "EPSG" || crs.org == "epsg")
  }
}

///|
pub fn Header::validate_geojson_crs(
  self : Header,
  assume_wgs84~ : Bool = false,
) -> Unit raise FgbError {
  if self.is_wgs84() {
    return
  }
  if self.crs is None && assume_wgs84 {
    return
  }
  raise InvalidArgument(
    "GeoJSON needs WGS84 coordinates; missing or projected CRS requires explicit handling",
  )
}

///|
pub fn Geometry::validate_wgs84(
  self : Geometry,
  limits~ : Limits = Limits::default(),
) -> Unit raise FgbError {
  self.validate(limits~)
  fn check(geometry : Geometry) -> Unit raise FgbError {
    for coordinate in geometry.xy {
      if coordinate.x < -180.0 ||
        coordinate.x > 180.0 ||
        coordinate.y < -90.0 ||
        coordinate.y > 90.0 {
        raise InvalidArgument(
          "coordinate outside WGS84 longitude/latitude range",
        )
      }
    }
    for part in geometry.parts {
      check(part)
    }
  }
  check(self)
}