///|
/// Category of an observable difference.
pub(all) enum DifferenceKind {
  MissingCase
  ExtraCase
  ValueMismatch
  TypeMismatch
  MissingField
  ExtraField
} derive(Eq, ToJson)

///|
/// A difference at a JSON Pointer path within a case. The empty path is its root.
pub(all) struct Difference {
  case_id : String
  path : String
  kind : DifferenceKind
  expected : Json?
  actual : Json?
} derive(ToJson)

///|
/// Comparison result. An error reading inputs is raised, never represented as a match.
pub(all) struct Report {
  suite : String
  total_cases : Int
  matched_cases : Int
  abs_tol : Double
  rel_tol : Double
  differences : Array[Difference]
} derive(ToJson)

///|
pub fn Report::is_match(self : Report) -> Bool {
  self.differences.is_empty()
}

///|
fn keys_union(a : Map[String, Json], b : Map[String, Json]) -> Array[String] {
  let ids = a.keys().to_array()
  for id, _ in b {
    if !a.contains(id) {
      ids.push(id)
    }
  }
  ids.sort_by((a, b) => a.lexical_compare(b))
  ids
}

///|
/// Compare case IDs, ignoring observation order. Missing and extra cases are differences.
pub fn compare(
  expected : Suite,
  actual : Suite,
  abs_tol? : Double = 0.0,
  rel_tol? : Double = 0.0,
) -> Report raise CaseError {
  for tolerance in [abs_tol, rel_tol] {
    if tolerance < 0.0 || tolerance.is_nan() || tolerance.is_inf() {
      raise Invalid("tolerances must be finite and nonnegative")
    }
  }
  if expected.name != actual.name {
    raise Invalid("suite names differ")
  }
  if expected.size() == 0 || actual.size() == 0 {
    raise Invalid("cannot compare empty suites")
  }
  let ids = keys_union(expected.values, actual.values)
  let differences : Array[Difference] = []
  let mut matched = 0
  for id in ids {
    let left = expected.values.get(id)
    let right = actual.values.get(id)
    match (left, right) {
      (Some(a), Some(b)) => {
        validate_value(a, 0)
        validate_value(b, 0)
        let before = differences.length()
        diff_values(id, "", a, b, differences, abs_tol, rel_tol)
        if differences.length() == before {
          matched += 1
        }
      }
      (Some(_), None) =>
        differences.push({
          case_id: id,
          path: "",
          kind: MissingCase,
          expected: left,
          actual: right,
        })
      (None, Some(_)) =>
        differences.push({
          case_id: id,
          path: "",
          kind: ExtraCase,
          expected: left,
          actual: right,
        })
      (None, None) => ()
    }
  }
  {
    suite: expected.name,
    abs_tol,
    rel_tol,
    total_cases: ids.length(),
    matched_cases: matched,
    differences,
  }
}

///|
pub extend DifferenceKind with Eq::{equal, not_equal}

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

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

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