///|
pub(all) struct GroupDelta {
  kind : String
  name : String
  before_count : Int32
  after_count : Int32
  before_bytes : Int64
  after_bytes : Int64
}

///|
pub(all) struct ObjectChange {
  id : String
  kind : String
  name : String
  status : String
  before_bytes : Int64?
  after_bytes : Int64?
}

///|
pub(all) struct SnapshotDiff {
  mode : String
  groups : Array[GroupDelta]
  added : Int32?
  removed : Int32?
  survived : Int32?
  changes : Array[ObjectChange]
  excluded_ids : Array[String]
}

///|
/// Identity is never inferred from IDs. same_isolate is a caller assertion.
pub fn compare(
  before : Snapshot,
  after : Snapshot,
  same_isolate~ : Bool = false,
  exclude_conflicts~ : Bool = false,
) -> SnapshotDiff raise HeapError {
  let groups : Map[(String, String), GroupDelta] = Map([])
  for n in before.nodes {
    let key = (n.kind, n.name)
    let g = groups
      .get(key)
      .unwrap_or({
        kind: n.kind,
        name: n.name,
        before_count: 0,
        after_count: 0,
        before_bytes: 0L,
        after_bytes: 0L,
      })
    groups[key] = {
      ..g,
      before_count: g.before_count + 1,
      before_bytes: g.before_bytes + n.self_size,
    }
  }
  for n in after.nodes {
    let key = (n.kind, n.name)
    let g = groups
      .get(key)
      .unwrap_or({
        kind: n.kind,
        name: n.name,
        before_count: 0,
        after_count: 0,
        before_bytes: 0L,
        after_bytes: 0L,
      })
    groups[key] = {
      ..g,
      after_count: g.after_count + 1,
      after_bytes: g.after_bytes + n.self_size,
    }
  }
  let rows = groups.values().to_array()
  rows.sort_by((a, b) => {
    let ad = a.after_bytes - a.before_bytes
    let bd = b.after_bytes - b.before_bytes
    if ad > bd {
      -1
    } else if ad < bd {
      1
    } else {
      let c = a.kind.compare(b.kind)
      if c != 0 {
        c
      } else {
        a.name.compare(b.name)
      }
    }
  })
  if !same_isolate {
    return {
      mode: "aggregate",
      groups: rows,
      added: None,
      removed: None,
      survived: None,
      changes: [],
      excluded_ids: [],
    }
  }
  let changes : Array[ObjectChange] = []
  let excluded_ids : Array[String] = []
  let mut added = 0
  let mut removed = 0
  let mut survived = 0
  for n in after.nodes {
    match before.ids.get(n.id) {
      None => {
        added += 1
        changes.push({
          id: n.id,
          kind: n.kind,
          name: n.name,
          status: "added",
          before_bytes: None,
          after_bytes: Some(n.self_size),
        })
      }
      Some(i) => {
        let old = before.nodes[i]
        if old.kind != n.kind || old.name != n.name {
          if exclude_conflicts {
            excluded_ids.push(n.id)
            continue
          }
          raise Identity(
            "overlapping id \{n.id} changed type/name; identity comparison rejected",
          )
        }
        survived += 1
        if old.self_size != n.self_size {
          changes.push({
            id: n.id,
            kind: n.kind,
            name: n.name,
            status: "resized",
            before_bytes: Some(old.self_size),
            after_bytes: Some(n.self_size),
          })
        }
      }
    }
  }
  for n in before.nodes {
    if !after.ids.contains(n.id) {
      removed += 1
      changes.push({
        id: n.id,
        kind: n.kind,
        name: n.name,
        status: "removed",
        before_bytes: Some(n.self_size),
        after_bytes: None,
      })
    }
  }
  {
    mode: "identity_asserted",
    groups: rows,
    added: Some(added),
    removed: Some(removed),
    survived: Some(survived),
    changes,
    excluded_ids,
  }
}