///|
pub(all) enum ImpactKind {
  Unchanged
  Changed
  Added
  Removed
  Unresolved
} derive(Eq)

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

///|
pub(all) enum FieldChangeKind {
  Added
  Removed
  ValueChanged
  ProvenanceChanged
} derive(Eq)

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

///|
pub struct FieldChange {
  priv key : String
  priv kind : FieldChangeKind
  priv before : Json?
  priv after : Json?
  priv before_origins : Array[FieldOrigin]
  priv after_origins : Array[FieldOrigin]
}

///|
pub fn FieldChange::key(self : FieldChange) -> String {
  self.key
}

///|
pub fn FieldChange::kind(self : FieldChange) -> FieldChangeKind {
  self.kind
}

///|
/// None means absent; Some("null") is a present JSON null. Number tokens matter.
pub fn FieldChange::before_json(self : FieldChange) -> String? {
  self.before.map(canonical_json)
}

///|
pub fn FieldChange::after_json(self : FieldChange) -> String? {
  self.after.map(canonical_json)
}

///|
pub fn FieldChange::before_trace(self : FieldChange) -> Array[FieldOrigin] {
  self.before_origins.copy()
}

///|
pub fn FieldChange::after_trace(self : FieldChange) -> Array[FieldOrigin] {
  self.after_origins.copy()
}

///|
pub struct ImpactEntry {
  priv path : String
  priv kind : ImpactKind
  priv before : AuditEntry?
  priv after : AuditEntry?
  priv changes : Array[FieldChange]
  priv sources_changed : Bool
}

///|
pub fn ImpactEntry::path(self : ImpactEntry) -> String {
  self.path
}

///|
pub fn ImpactEntry::kind(self : ImpactEntry) -> ImpactKind {
  self.kind
}

///|
pub fn ImpactEntry::before(self : ImpactEntry) -> AuditEntry? {
  self.before
}

///|
pub fn ImpactEntry::after(self : ImpactEntry) -> AuditEntry? {
  self.after
}

///|
pub fn ImpactEntry::changes(self : ImpactEntry) -> Array[FieldChange] {
  self.changes.copy()
}

///|
/// Meaningful only when neither side has a resolution error.
pub fn ImpactEntry::sources_changed(self : ImpactEntry) -> Bool {
  self.sources_changed
}

///|
pub struct ImpactReport {
  priv entries : Array[ImpactEntry]
}

///|
pub fn ImpactReport::entries(self : ImpactReport) -> Array[ImpactEntry] {
  self.entries.copy()
}

///|
pub fn ImpactReport::changed_count(self : ImpactReport) -> Int32 {
  self.entries
  .filter(e => e.kind == Changed || e.kind == Added || e.kind == Removed)
  .length()
}

///|
/// Count indeterminate scan paths, not individual diagnostics.
pub fn ImpactReport::error_count(self : ImpactReport) -> Int32 {
  self.entries.filter(e => e.kind == Unresolved).length()
}

///|
fn origins_json(origins : Array[FieldOrigin]) -> Json {
  Json::array(
    origins.map(o => {
      Json::object({ "path": Json::string(o.path), "value": o.value })
    }),
  )
}

///|
fn field_changes(
  before : ResolvedMetadata?,
  after : ResolvedMetadata?,
) -> Array[FieldChange] {
  let keys : Map[String, Bool] = Map([])
  for result in [before, after] {
    if result is Some(result) {
      for key in result.keys() {
        keys[key] = true
      }
    }
  }
  let changes : Array[FieldChange] = []
  for key in sorted_keys(keys) {
    let old = match before {
      Some(r) => r.get(key)
      None => None
    }
    let new = match after {
      Some(r) => r.get(key)
      None => None
    }
    let old_origins = match before {
      Some(r) => r.trace(key)
      None => []
    }
    let new_origins = match after {
      Some(r) => r.trace(key)
      None => []
    }
    let kind = match (old, new) {
      (None, Some(_)) => Some(FieldChangeKind::Added)
      (Some(_), None) => Some(FieldChangeKind::Removed)
      (Some(a), Some(b)) =>
        if canonical_json(a) != canonical_json(b) {
          Some(ValueChanged)
        } else if canonical_json(origins_json(old_origins)) !=
          canonical_json(origins_json(new_origins)) {
          Some(ProvenanceChanged)
        } else {
          None
        }
      (None, None) => None
    }
    if kind is Some(kind) {
      changes.push({
        key,
        kind,
        before: old,
        after: new,
        before_origins: old_origins,
        after_origins: new_origins,
      })
    }
  }
  changes
}

///|
/// Compare the sorted union of indexed scans. Errors remain indeterminate;
/// nothing is inferred about omitted sidecars or image content.
pub fn DatasetIndex::compare(
  self : DatasetIndex,
  after : DatasetIndex,
) -> ImpactReport {
  let old : Map[String, AuditEntry] = Map([])
  let new : Map[String, AuditEntry] = Map([])
  let paths : Map[String, Bool] = Map([])
  for e in self.audit_all().entries {
    old[e.path] = e
    paths[e.path] = true
  }
  for e in after.audit_all().entries {
    new[e.path] = e
    paths[e.path] = true
  }
  let entries = sorted_keys(paths).map(path => {
    let before = old.get(path)
    let after = new.get(path)
    let old_result = match before {
      Some(e) => e.result
      None => None
    }
    let new_result = match after {
      Some(e) => e.result
      None => None
    }
    if before is Some({ error: Some(_), .. }) ||
      after is Some({ error: Some(_), .. }) {
      {
        path,
        kind: Unresolved,
        before,
        after,
        changes: [],
        sources_changed: false,
      }
    } else {
      let changes = field_changes(old_result, new_result)
      let old_sources = match old_result {
        Some(r) => r.applied
        None => []
      }
      let new_sources = match new_result {
        Some(r) => r.applied
        None => []
      }
      let sources_changed = old_sources != new_sources
      let kind = match (before, after) {
        (None, Some(_)) => ImpactKind::Added
        (Some(_), None) => ImpactKind::Removed
        _ =>
          if changes.is_empty() && !sources_changed {
            Unchanged
          } else {
            Changed
          }
      }
      { path, kind, before, after, changes, sources_changed, }
    }
  })
  { entries, }
}

///|
fn impact_kind_name(kind : ImpactKind) -> String {
  match kind {
    Unchanged => "unchanged"
    Changed => "changed"
    Added => "added"
    Removed => "removed"
    Unresolved => "unresolved"
  }
}

///|
fn change_kind_name(kind : FieldChangeKind) -> String {
  match kind {
    Added => "added"
    Removed => "removed"
    ValueChanged => "value_changed"
    ProvenanceChanged => "provenance_changed"
  }
}

///|
fn AuditEntry::impact_value(self : AuditEntry) -> Json {
  match (self.result, self.error) {
    (Some(r), None) =>
      Json::object({
        "status": Json::string("resolved"),
        "result": r.report_value(),
      })
    (None, Some(d)) =>
      Json::object({
        "status": Json::string("error"),
        "diagnostic": d.to_json(),
      })
    _ => abort("AuditEntry invariant")
  }
}

///|
/// Includes full before/after evidence even for unchanged and unresolved scans.
fn ImpactReport::report_value(self : ImpactReport) -> Json {
  Json::object({
    "changed_count": Json::number(self.changed_count().to_double()),
    "error_count": Json::number(self.error_count().to_double()),
    "entries": Json::array(
      self.entries.map(e => {
        Json::object({
          "data_path": Json::string(e.path),
          "kind": Json::string(impact_kind_name(e.kind)),
          "before": e.before
          .map(AuditEntry::impact_value)
          .unwrap_or(Json::null()),
          "after": e.after.map(AuditEntry::impact_value).unwrap_or(Json::null()),
          "sources_changed": Json::boolean(e.sources_changed),
          "changes": Json::array(
            e.changes.map(c => {
              Json::object({
                "field": Json::string(c.key),
                "kind": Json::string(change_kind_name(c.kind)),
                "before_present": Json::boolean(c.before is Some(_)),
                "after_present": Json::boolean(c.after is Some(_)),
                "before": c.before.unwrap_or(Json::null()),
                "after": c.after.unwrap_or(Json::null()),
                "before_trace": origins_json(c.before_origins),
                "after_trace": origins_json(c.after_origins),
              })
            }),
          ),
        })
      }),
    ),
  })
}

///|
pub fn ImpactReport::to_json(self : ImpactReport) -> String {
  canonical_json(self.report_value())
}