///|
fn excluded_sample(ids : Array[String]) -> Json {
  let sample : Array[String] = []
  for i in 0.. Int64 raise HeapError {
  match optional(req, key) {
    None => fallback
    Some(v) => safe_bytes(v, key)
  }
}

///|
fn change_json(c : RetentionChange) -> Json {
  {
    "id": @json.to_json(c.id),
    "kind": @json.to_json(c.kind),
    "name": @json.to_json(c.name),
    "status": @json.to_json(c.status),
    "before_bytes": optional_bytes(c.before_bytes),
    "after_bytes": optional_bytes(c.after_bytes),
    "before_retained_bytes": optional_bytes(c.before_retained),
    "after_retained_bytes": optional_bytes(c.after_retained),
    "retained_delta": optional_bytes(c.retained_delta),
  }
}

///|
fn retention_diff_json(
  before : Analysis,
  after : Analysis,
  req : Json,
) -> Json raise HeapError {
  let offset = int_option(req, "offset", 0)
  let go = int_option(req, "group_offset", offset)
  let co = int_option(req, "change_offset", offset)
  let limit = int_option(req, "limit", 100)
  page_bounds(go, limit)
  page_bounds(co, limit)
  let result = before.compare(
    after,
    same_isolate=bool_option(req, "same_isolate", false),
    exclude_conflicts=bool_option(req, "exclude_conflicts", false),
  )
  let query = string_option(req, "query", "")
  let kind = string_option(req, "kind", "")
  let status = string_option(req, "status", "")
  guard ["", "added", "removed", "resized", "retention_changed"].contains(
    status,
  ) else {
    raise Invalid("invalid change status")
  }
  let min_growth = size_option(req, "min_growth", -9007199254740991L)
  let rows = result.aggregate.groups.filter(g => {
    (query == "" || g.name.contains(query)) &&
    (kind == "" || g.kind == kind) &&
    g.after_bytes - g.before_bytes >= min_growth
  })
  let items : Array[Json] = []
  for i in 0..= go && items.length() < limit {
      let g = rows[i]
      items.push({
        "kind": @json.to_json(g.kind),
        "name": @json.to_json(g.name),
        "before_count": @json.to_json(g.before_count),
        "after_count": @json.to_json(g.after_count),
        "count_delta": @json.to_json(g.after_count - g.before_count),
        "before_bytes": bytes_json(g.before_bytes),
        "after_bytes": bytes_json(g.after_bytes),
        "bytes_delta": bytes_json(g.after_bytes - g.before_bytes),
      })
    }
  }
  let filtered = result.changes.filter(c => {
    (query == "" || c.name.contains(query) || c.id == query) &&
    (kind == "" || c.kind == kind) &&
    (status == "" || c.status == status) &&
    c.retained_delta.unwrap_or(0L) >= min_growth
  })
  let changes : Array[Json] = []
  for i in 0..= co && changes.length() < limit {
      changes.push(change_json(filtered[i]))
    }
  }
  {
    "mode": @json.to_json(result.aggregate.mode),
    "groups": @json.to_json(items),
    "total_groups": @json.to_json(rows.length()),
    "changes": @json.to_json(changes),
    "total_changes": @json.to_json(filtered.length()),
    "group_offset": @json.to_json(go),
    "change_offset": @json.to_json(co),
    "offset": @json.to_json(offset),
    "added": optional_count(result.aggregate.added),
    "removed": optional_count(result.aggregate.removed),
    "survived": optional_count(result.aggregate.survived),
    "excluded_id_count": @json.to_json(result.aggregate.excluded_ids.length()),
    "excluded_ids_sample": excluded_sample(result.aggregate.excluded_ids),
    "interpretation": "Retained changes include unchanged-size containers. Identity relies on confirmed same-isolate provenance; retention is not a leak verdict.",
  }
}

///|
pub fn Inspector::investigate(
  self : Inspector,
  before : Int32,
  growth : Int32,
  cleanup : Int32,
  input : String,
) -> Json raise HeapError {
  let req = request(input)
  let offset = int_option(req, "offset", 0)
  let limit = int_option(req, "limit", 100)
  page_bounds(offset, limit)
  let a = self.get(before)
  let b = self.get(growth)
  let c = self.get(cleanup)
  let result = investigate(
    a,
    b,
    c,
    same_isolate=bool_option(req, "same_isolate", false),
    exclude_conflicts=bool_option(req, "exclude_conflicts", false),
  )
  let q = string_option(req, "query", "")
  let kind = string_option(req, "kind", "")
  let min_retained = size_option(req, "min_retained", -1L)
  let only_residual = bool_option(req, "residual_only", true)
  let surviving_groups : Map[(String, String), Bool] = Map([])
  for node in result.survivors {
    surviving_groups[(node.kind, node.name)] = true
  }
  let groups = result.groups.filter(g => {
    (q == "" || g.name.contains(q)) &&
    (kind == "" || g.kind == kind) &&
    (
      !only_residual ||
      (g.growth_count > g.before_count && g.cleanup_count > g.before_count) ||
      surviving_groups.contains((g.kind, g.name))
    )
  })
  let rows : Array[Json] = []
  for i in 0..= offset && rows.length() < limit {
      let g = groups[i]
      rows.push({
        "kind": @json.to_json(g.kind),
        "name": @json.to_json(g.name),
        "before_count": @json.to_json(g.before_count),
        "growth_count": @json.to_json(g.growth_count),
        "cleanup_count": @json.to_json(g.cleanup_count),
        "remaining_count": @json.to_json(g.cleanup_count - g.before_count),
        "before_bytes": bytes_json(g.before_bytes),
        "growth_bytes": bytes_json(g.growth_bytes),
        "cleanup_bytes": bytes_json(g.cleanup_bytes),
        "remaining_bytes": bytes_json(g.cleanup_bytes - g.before_bytes),
      })
    }
  }
  let survivors = result.survivors.filter(n => {
    (q == "" || n.name.contains(q) || n.id == q) &&
    (kind == "" || n.kind == kind) &&
    c.retained[n.index] >= min_retained
  })
  let items : Array[Json] = []
  for i in 0..= offset && items.length() < limit {
      items.push(node_json(c, survivors[i]))
    }
  }
  {
    "mode": @json.to_json(result.mode),
    "groups": @json.to_json(rows),
    "total_groups": @json.to_json(groups.length()),
    "items": @json.to_json(items),
    "total": @json.to_json(survivors.length()),
    "offset": @json.to_json(offset),
    "created": optional_count(result.created),
    "excluded_id_count": @json.to_json(result.excluded_ids.length()),
    "excluded_ids_sample": excluded_sample(result.excluded_ids),
    "released": optional_count(result.released),
    "survivor_count": if result.mode == "aggregate" {
      Json::null()
    } else {
      @json.to_json(result.survivors.length())
    },
    "interpretation": "Candidates were absent from baseline, present after workload, and still present after cleanup. Intentional caches and runtime activity can also survive.",
  }
}