///|
pub(all) struct InvariantCheck {
  name : String
  passed : Bool
  observed : Double
  tolerance : Double
} derive(Debug, ToJson)

///|
pub fn invariant(
  name : String,
  observed : Double,
  tolerance : Double,
) -> InvariantCheck {
  { name, passed: observed <= tolerance, observed, tolerance }
}

///|
pub fn invariant_shape(
  name : String,
  actual : Int,
  expected : Int,
) -> InvariantCheck {
  invariant(name, (actual - expected).abs().to_double(), 0.0)
}

///|
pub fn invariant_close(
  name : String,
  actual : Double,
  expected : Double,
  tolerance : Double,
) -> InvariantCheck {
  invariant(name, (actual - expected).abs(), tolerance)
}

///|
pub fn invariant_charge(
  grid : Grid1D,
  density : ArrayView[Double],
  expected : Double,
  tolerance : Double,
) -> InvariantCheck {
  invariant("charge", (total_charge(grid, density) - expected).abs(), tolerance)
}

///|
pub fn invariant_zero_mean(
  name : String,
  values : ArrayView[Double],
  tolerance : Double,
) -> InvariantCheck {
  invariant(name, mean(values).abs(), tolerance)
}

///|
pub fn invariant_positive(
  name : String,
  values : ArrayView[Double],
) -> InvariantCheck {
  invariant(name, (-min_value(values, 0.0)).max(0.0), 0.0)
}

///|
pub fn invariant_bounds(
  name : String,
  values : ArrayView[Double],
  low : Double,
  high : Double,
) -> InvariantCheck {
  let lower_error = (low - min_value(values, low)).max(0.0)
  let upper_error = (max_value(values, high) - high).max(0.0)
  invariant(name, lower_error.max(upper_error), 0.0)
}

///|
pub fn invariant_all(checks : ArrayView[InvariantCheck]) -> Bool {
  checks.all(fn(check) { check.passed })
}

///|
pub fn invariant_max_error(checks : ArrayView[InvariantCheck]) -> Double {
  max_value(checks.map(fn(check) { check.observed }), 0.0)
}

///|
pub fn invariant_to_csv(checks : ArrayView[InvariantCheck]) -> String {
  let output = StringBuilder()
  output.write_string("name,passed,observed,tolerance\n")
  for check in checks {
    output.write_string(
      "\{check.name},\{check.passed},\{check.observed},\{check.tolerance}\n",
    )
  }
  output.to_string()
}

///|
pub fn pic_invariants(
  state : PicState,
  expected_charge : Double,
) -> Array[InvariantCheck] {
  [
    invariant_shape(
      "field_shape",
      state.field.electric.length(),
      state.grid.cells,
    ),
    invariant_charge(
      state.grid,
      state.field.charge_density,
      expected_charge,
      1.0e-8,
    ),
    invariant_bounds(
      "particle_position",
      state.particles.map(fn(particle) { particle.x }),
      0.0,
      state.grid.length,
    ),
  ]
}

///|
pub fn vlasov_invariants(
  state : VlasovState,
  expected_mass : Double,
) -> Array[InvariantCheck] {
  [
    invariant_close(
      "distribution_mass",
      distribution_integral(state),
      expected_mass,
      1.0e-8,
    ),
    invariant_shape(
      "distribution_density_shape",
      state.charge_density.length(),
      state.config.grid.cells,
    ),
  ]
}

///|
pub fn field_invariants(field : Field1D) -> Array[InvariantCheck] {
  [
    invariant_shape("electric_shape", field.electric.length(), field.grid.cells),
    invariant_shape(
      "potential_shape",
      field.potential.length(),
      field.grid.cells,
    ),
    invariant_zero_mean("electric_mean", field.electric, 1.0e-8),
    invariant_zero_mean("potential_mean", field.potential, 1.0e-8),
  ]
}

///|
pub fn invariant_report(checks : ArrayView[InvariantCheck]) -> String {
  "passed=\{invariant_all(checks)}\nmax_error=\{invariant_max_error(checks)}\n\{invariant_to_csv(checks)}"
}