///|
pub(all) struct AuditPolicy {
  require_origin : Bool
  require_owner : Bool
  check_extrema : Bool
  check_nmr_metadata : Bool
  max_diagnostics : Int32
} derive(@debug.Debug, ToJson)

///|
pub fn AuditPolicy::default() -> AuditPolicy {
  {
    require_origin: false,
    require_owner: false,
    check_extrema: true,
    check_nmr_metadata: true,
    max_diagnostics: 100,
  }
}

///|
pub(all) struct AuditReport {
  valid : Bool
  spectra : Int32
  errors : Int32
  warnings : Int32
  truncated : Bool
  diagnostics : Array[Diagnostic]
} derive(@debug.Debug, ToJson)

///|
pub extend AuditPolicy with @debug.Debug::{to_repr}

///|
pub extend AuditPolicy with ToJson::{to_json}

///|
pub extend AuditReport with @debug.Debug::{to_repr}

///|
pub extend AuditReport with ToJson::{to_json}

///|
priv struct Auditor {
  policy : AuditPolicy
  options : ParseOptions
  diagnostics : Array[Diagnostic]
  mut errors : Int32
  mut warnings : Int32
}

///|
fn Auditor::add(
  self : Auditor,
  severity : Severity,
  code : String,
  message : String,
  line : Int32,
) -> Unit {
  if severity == Error {
    self.errors += 1
  } else {
    self.warnings += 1
  }
  if self.diagnostics.length() < self.policy.max_diagnostics {
    self.diagnostics.push({ severity, code, message, line, column: 1, })
  }
}

///|
fn Auditor::declared(
  self : Auditor,
  spectrum : Spectrum,
  key : String,
  expected : Double?,
) -> Unit {
  for record in spectrum.records {
    if canonical_label(record.label) != key {
      continue
    }
    try {
      let value = number(record.value, line=record.line)
      match expected {
        None =>
          self.add(
            Error,
            "AUDIT_EMPTY_ORDINATES",
            key + " is declared but all corresponding values are missing",
            record.line,
          )
        Some(expected) =>
          if !close(value, expected, self.options) {
            self.add(
              Error,
              "AUDIT_DERIVED_VALUE",
              key + " differs from decoded data in " + spectrum.title,
              record.line,
            )
          }
      }
    } catch {
      Invalid(d) => self.add(d.severity, d.code, d.message, d.line)
    }
  }
}

///|
fn Auditor::spectrum(self : Auditor, spectrum : Spectrum) -> Unit {
  let seen : Map[String, Bool] = Map([])
  for record in spectrum.records {
    let key = canonical_label(record.label)
    if seen.contains(key) {
      self.add(
        Warning,
        "AUDIT_DUPLICATE_LABEL",
        "repeated descriptive label: " + record.label,
        record.line,
      )
    }
    seen[key] = true
    if trim(record.value) == "" {
      self.add(
        Warning,
        "AUDIT_EMPTY_LABEL",
        "empty descriptive value: " + record.label,
        record.line,
      )
    }
  }
  for
    (key, required) in [
      ("ORIGIN", self.policy.require_origin),
      ("OWNER", self.policy.require_owner),
    ] {
    if record_value(spectrum.records, key).map(trim).unwrap_or("") == "" {
      self.add(
        if required {
          Error
        } else {
          Warning
        },
        "AUDIT_PROVENANCE",
        "missing " + key + " in " + spectrum.title,
        spectrum.source_line,
      )
    }
  }
  if self.policy.check_extrema {
    let mut xmin = spectrum.points[0].x
    let mut xmax = xmin
    let mut ymin : Double? = None
    let mut ymax : Double? = None
    for p in spectrum.points {
      if p.x < xmin {
        xmin = p.x
      }
      if p.x > xmax {
        xmax = p.x
      }
      if p.y is Some(y) {
        ymin = match ymin {
          None => Some(y)
          Some(a) => Some(if y < a { y } else { a })
        }
        ymax = match ymax {
          None => Some(y)
          Some(a) => Some(if y > a { y } else { a })
        }
      }
    }
    self.declared(spectrum, "MINX", Some(xmin))
    self.declared(spectrum, "MAXX", Some(xmax))
    self.declared(spectrum, "MINY", ymin)
    self.declared(spectrum, "MAXY", ymax)
    self.declared(
      spectrum,
      "LASTY",
      spectrum.points[spectrum.points.length() - 1].y,
    )
  }
  if self.policy.check_nmr_metadata &&
    canonical_label(spectrum.data_type) == "NMRSPECTRUM" {
    match record_value(spectrum.records, ".OBSERVE FREQUENCY") {
      None =>
        self.add(
          Warning,
          "AUDIT_NMR_FREQUENCY",
          "NMR spectrum has no observe frequency; PPM/HZ calibration cannot be inferred",
          spectrum.source_line,
        )
      Some(raw) =>
        try {
          if number(raw) <= 0.0 {
            self.add(
              Error,
              "AUDIT_NMR_FREQUENCY",
              "observe frequency must be positive",
              spectrum.source_line,
            )
          }
        } catch {
          Invalid(d) =>
            self.add(
              Error,
              "AUDIT_NMR_FREQUENCY",
              d.message,
              spectrum.source_line,
            )
        }
    }
    if record_value(spectrum.records, ".OBSERVE NUCLEUS")
      .map(trim)
      .unwrap_or("") ==
      "" {
      self.add(
        Warning,
        "AUDIT_NMR_NUCLEUS",
        "NMR spectrum has no observe nucleus",
        spectrum.source_line,
      )
    }
  }
  for key in ["DATE", "TIME", "LONGDATE"] {
    if record_value(spectrum.records, key) is Some(value) {
      if trim(value) == "" || value.contains("\n") {
        self.add(
          Warning,
          "AUDIT_DATE",
          key + " is empty or multiline; timestamp not interpreted",
          spectrum.source_line,
        )
      }
    }
  }
}

///|
pub fn audit_document(
  document : Document,
  policy~ : AuditPolicy = AuditPolicy::default(),
  options~ : ParseOptions = ParseOptions::default(),
) -> AuditReport raise JcampError {
  guard policy.max_diagnostics >= 1 && policy.max_diagnostics <= 10000 else {
    reject("AUDIT_LIMIT", "max_diagnostics must be between 1 and 10000")
  }
  let spectra = spectra_from_document(document, options~)
  let auditor : Auditor = {
    policy,
    options,
    diagnostics: [],
    errors: 0,
    warnings: 0,
  }
  let titles : Map[String, Bool] = Map([])
  for spectrum in spectra {
    if titles.contains(spectrum.title) {
      auditor.add(
        Warning,
        "AUDIT_DUPLICATE_TITLE",
        "duplicate title; use document paths instead of title as identifier",
        spectrum.source_line,
      )
    }
    titles[spectrum.title] = true
    auditor.spectrum(spectrum)
  }
  {
    valid: auditor.errors == 0,
    spectra: spectra.length(),
    errors: auditor.errors,
    warnings: auditor.warnings,
    truncated: auditor.errors + auditor.warnings > auditor.diagnostics.length(),
    diagnostics: auditor.diagnostics,
  }
}

///|
pub fn audit(
  text : String,
  policy~ : AuditPolicy = AuditPolicy::default(),
  options~ : ParseOptions = ParseOptions::default(),
) -> AuditReport {
  audit_document(parse_document(text, options~), policy~, options~) catch {
    Invalid(d) =>
      {
        valid: false,
        spectra: 0,
        errors: 1,
        warnings: 0,
        truncated: false,
        diagnostics: [d],
      }
  }
}