///|
pub(all) struct RetentionChange {
  id : String
  kind : String
  name : String
  status : String
  before_bytes : Int64?
  after_bytes : Int64?
  before_retained : Int64?
  after_retained : Int64?
  retained_delta : Int64?
}

///|
pub(all) struct RetentionDiff {
  aggregate : SnapshotDiff
  changes : Array[RetentionChange]
}

///|
fn delta_optional(before : Int64?, after : Int64?) -> Int64? {
  match (before, after) {
    (Some(b), Some(a)) => Some(a - b)
    _ => None
  }
}

///|
pub fn Analysis::compare(
  self : Analysis,
  after : Analysis,
  same_isolate~ : Bool = false,
  exclude_conflicts~ : Bool = false,
) -> RetentionDiff raise HeapError {
  let before_s = self.graph.snapshot
  let after_s = after.graph.snapshot
  let aggregate = compare(before_s, after_s, same_isolate~, exclude_conflicts~)
  let excluded : Map[String, Bool] = Map([])
  for id in aggregate.excluded_ids {
    excluded[id] = true
  }
  let changes : Array[RetentionChange] = []
  if same_isolate {
    for n in after_s.nodes {
      if excluded.contains(n.id) {
        continue
      }
      let current = after.retained_bytes(n.index)
      match before_s.find_id(n.id) {
        None =>
          changes.push({
            id: n.id,
            kind: n.kind,
            name: n.name,
            status: "added",
            before_bytes: None,
            after_bytes: Some(n.self_size),
            before_retained: None,
            after_retained: current,
            retained_delta: current,
          })
        Some(i) => {
          let previous = self.retained_bytes(i)
          let old = before_s.nodes[i]
          if old.self_size != n.self_size || previous != current {
            changes.push({
              id: n.id,
              kind: n.kind,
              name: n.name,
              status: if old.self_size != n.self_size {
                "resized"
              } else {
                "retention_changed"
              },
              before_bytes: Some(old.self_size),
              after_bytes: Some(n.self_size),
              before_retained: previous,
              after_retained: current,
              retained_delta: delta_optional(previous, current),
            })
          }
        }
      }
    }
    for n in before_s.nodes {
      if excluded.contains(n.id) {
        continue
      }
      if !after_s.ids.contains(n.id) {
        let previous = self.retained_bytes(n.index)
        changes.push({
          id: n.id,
          kind: n.kind,
          name: n.name,
          status: "removed",
          before_bytes: Some(n.self_size),
          after_bytes: None,
          before_retained: previous,
          after_retained: None,
          retained_delta: previous.map(v => -v),
        })
      }
    }
    changes.sort_by((a, b) => {
      let av = a.retained_delta.unwrap_or(0L)
      let bv = b.retained_delta.unwrap_or(0L)
      if av > bv {
        -1
      } else if av < bv {
        1
      } else {
        a.id.compare(b.id)
      }
    })
  }
  { aggregate, changes, }
}

///|
pub(all) struct ResidueGroup {
  kind : String
  name : String
  before_count : Int32
  growth_count : Int32
  cleanup_count : Int32
  before_bytes : Int64
  growth_bytes : Int64
  cleanup_bytes : Int64
}

///|
pub(all) struct Investigation {
  mode : String
  groups : Array[ResidueGroup]
  survivors : Array[Node]
  created : Int32?
  released : Int32?
  excluded_ids : Array[String]
}

///|
/// Candidates are newly observed after the workload and still present after cleanup.
/// This is evidence of survival, not a proof of unintended retention.
pub fn investigate(
  before : Analysis,
  growth : Analysis,
  cleanup : Analysis,
  same_isolate~ : Bool = false,
  exclude_conflicts~ : Bool = false,
) -> Investigation raise HeapError {
  let first = compare(
    before.graph.snapshot,
    growth.graph.snapshot,
    same_isolate~,
    exclude_conflicts~,
  )
  let second = compare(
    growth.graph.snapshot,
    cleanup.graph.snapshot,
    same_isolate~,
    exclude_conflicts~,
  )
  let third = compare(
    before.graph.snapshot,
    cleanup.graph.snapshot,
    same_isolate~,
    exclude_conflicts~,
  )
  let excluded : Map[String, Bool] = Map([])
  for result in [first, second, third] {
    for id in result.excluded_ids {
      excluded[id] = true
    }
  }
  let groups : Map[(String, String), ResidueGroup] = Map([])
  for g in before.groups() {
    groups[(g.kind, g.name)] = {
      kind: g.kind,
      name: g.name,
      before_count: g.count,
      before_bytes: g.shallow_bytes,
      growth_count: 0,
      cleanup_count: 0,
      growth_bytes: 0L,
      cleanup_bytes: 0L,
    }
  }
  for g in growth.groups() {
    let old = groups
      .get((g.kind, g.name))
      .unwrap_or({
        kind: g.kind,
        name: g.name,
        before_count: 0,
        before_bytes: 0L,
        growth_count: 0,
        cleanup_count: 0,
        growth_bytes: 0L,
        cleanup_bytes: 0L,
      })
    groups[(g.kind, g.name)] = {
      ..old,
      growth_count: g.count,
      growth_bytes: g.shallow_bytes,
    }
  }
  for g in cleanup.groups() {
    let old = groups
      .get((g.kind, g.name))
      .unwrap_or({
        kind: g.kind,
        name: g.name,
        before_count: 0,
        before_bytes: 0L,
        growth_count: 0,
        cleanup_count: 0,
        growth_bytes: 0L,
        cleanup_bytes: 0L,
      })
    groups[(g.kind, g.name)] = {
      ..old,
      cleanup_count: g.count,
      cleanup_bytes: g.shallow_bytes,
    }
  }
  let rows = groups.values().to_array()
  rows.sort_by((a, b) => {
    let av = a.cleanup_bytes - a.before_bytes
    let bv = b.cleanup_bytes - b.before_bytes
    if av > bv {
      -1
    } else if av < bv {
      1
    } else {
      a.name.compare(b.name)
    }
  })
  let survivors : Array[Node] = []
  let mut released = 0
  if same_isolate {
    for n in growth.graph.snapshot.nodes {
      if excluded.contains(n.id) {
        continue
      }
      if !before.graph.snapshot.ids.contains(n.id) {
        match cleanup.graph.snapshot.ids.get(n.id) {
          Some(i) => survivors.push(cleanup.graph.snapshot.nodes[i])
          None => released += 1
        }
      }
    }
    survivors.sort_by((a, b) => {
      let av = cleanup.retained[a.index]
      let bv = cleanup.retained[b.index]
      if av > bv {
        -1
      } else if av < bv {
        1
      } else {
        a.index - b.index
      }
    })
  }
  {
    mode: if same_isolate {
      "identity_asserted"
    } else {
      "aggregate"
    },
    groups: rows,
    survivors,
    created: if same_isolate {
      Some(survivors.length() + released)
    } else {
      None
    },
    released: if same_isolate {
      Some(released)
    } else {
      None
    },
    excluded_ids: excluded.keys().to_array(),
  }
}