// Copyright (C) 2026 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import type {Engine} from '../../trace_processor/engine';
import {
  BLOB_NULL,
  LONG,
  LONG_NULL,
  NUM,
  NUM_NULL,
  STR,
  STR_NULL,
  type QueryResult,
} from '../../trace_processor/query_result';
import type {
  OverviewData,
  HeapInfo,
  InstanceRow,
  InstanceDetail,
  PathEntry,
  PrimOrRef,
  BitmapListRow,
  StringListRow,
  DuplicateBitmapGroup,
  DuplicateStringGroup,
  DuplicateArrayGroup,
  ClassRow,
} from './types';
import {fmtHex} from './format';
import {shortClassName, SQL_PREAMBLE} from './components';

export interface HeapDump {
  readonly upid: number;
  readonly ts: bigint;
  readonly processName: string | null;
  readonly pid: number;
}

export async function loadDumpsList(engine: Engine): Promise<HeapDump[]> {
  const res = await engine.query(`
    SELECT
      o.upid AS upid,
      o.graph_sample_ts AS ts,
      coalesce(p.cmdline, p.name) AS pname,
      p.pid AS pid
    FROM heap_graph_object o
    JOIN process p USING (upid)
    GROUP BY o.upid, o.graph_sample_ts
    ORDER BY o.graph_sample_ts ASC
  `);
  const result: HeapDump[] = [];
  for (
    const it = res.iter({
      upid: NUM,
      ts: LONG,
      pname: STR_NULL,
      pid: NUM_NULL,
    });
    it.valid();
    it.next()
  ) {
    result.push({
      upid: it.upid,
      ts: it.ts,
      processName: it.pname,
      pid: it.pid ?? 0,
    });
  }
  return result;
}

export function dumpFilterSql(dump: HeapDump, alias: string = 'o'): string {
  return (
    `${alias}.upid = ${dump.upid} ` +
    `AND ${alias}.graph_sample_ts = ${dump.ts}`
  );
}

async function requireDominatorTree(engine: Engine): Promise<void> {
  await engine.query(SQL_PREAMBLE);
}

function className(name: string | null, deobfuscated: string | null): string {
  return deobfuscated ?? name ?? '???';
}

function makeDisplay(cls: string, id: number): string {
  return `${shortClassName(cls)} ${fmtHex(id)}`;
}

function sqlEsc(s: string): string {
  return s.replace(/'/g, "''");
}

function heapFilter(heap: string | null): string {
  return heap ? `AND o.heap_type = '${sqlEsc(heap)}'` : '';
}

const KIND_TO_REACHABILITY: Record<string, string> = {
  KIND_WEAK_REFERENCE: 'weak',
  KIND_SOFT_REFERENCE: 'soft',
  KIND_PHANTOM_REFERENCE: 'phantom',
  KIND_FINALIZER_REFERENCE: 'finalizer',
};

function rowFromIter(it: {
  id: number;
  cls: string;
  deob: string | null;
  self_size: number;
  native_size: number;
  heap_type: string | null;
  root_type: string | null;
  dominated_size: number | null;
  dominated_native: number | null;
  dominated_obj_count: number | null;
  value_string: string | null;
  class_kind?: string;
}): InstanceRow {
  const cls = className(it.cls, it.deob);
  const retainedJava = it.dominated_size ?? it.self_size;
  const retainedNative = it.dominated_native ?? it.native_size;
  const heap = it.heap_type ?? 'default';
  const reachabilityName =
    (it.class_kind && KIND_TO_REACHABILITY[it.class_kind]) ?? 'strong';
  return {
    id: it.id,
    display: makeDisplay(cls, it.id),
    className: cls,
    isRoot: it.root_type !== null,
    rootTypeNames: it.root_type !== null ? [it.root_type] : null,
    reachabilityName,
    heap,
    shallowJava: it.self_size,
    shallowNative: it.native_size,
    retainedTotal: retainedJava + retainedNative,
    retainedCount: it.dominated_obj_count ?? 1,
    reachableSize: null,
    reachableNative: null,
    reachableCount: null,
    retainedByHeap: [{heap, java: retainedJava, native_: retainedNative}],
    str: it.value_string,
    referent: null,
  };
}

const INSTANCE_COLS = `
  o.id, c.name AS cls, c.deobfuscated_name AS deob,
  o.self_size, o.native_size, o.heap_type, o.root_type,
  d.dominated_size_bytes AS dominated_size,
  d.dominated_native_size_bytes AS dominated_native,
  d.dominated_obj_count,
  od.value_string, c.kind AS class_kind`;

const INSTANCE_ITER_SPEC = {
  id: NUM,
  cls: STR,
  deob: STR_NULL,
  self_size: NUM,
  native_size: NUM,
  heap_type: STR_NULL,
  root_type: STR_NULL,
  dominated_size: NUM_NULL,
  dominated_native: NUM_NULL,
  dominated_obj_count: NUM_NULL,
  value_string: STR_NULL,
  class_kind: STR,
};

function collectRows(res: QueryResult): InstanceRow[] {
  const rows: InstanceRow[] = [];
  for (const it = res.iter(INSTANCE_ITER_SPEC); it.valid(); it.next()) {
    rows.push(rowFromIter(it));
  }
  return rows;
}

/**
 * Batch-fetch content hashes for bitmap pixel buffers via DumpData.
 * Uses the pre-computed array_data_hash column from the trace processor,
 * avoiding expensive byte-level hashing in TypeScript.
 */
async function batchBitmapBufferHashes(
  engine: Engine,
  activeDump: HeapDump,
  bitmaps: Array<{objectId: number; nativePtr: bigint}>,
): Promise<Map<number, string>> {
  const result = new Map<number, string>();
  const dumpData = await loadBitmapDumpData(engine, activeDump);
  if (!dumpData) return result;

  // Build bufferObjId → [bitmapObjectId, ...] mapping.
  const bufToBitmaps = new Map<number, number[]>();
  for (const b of bitmaps) {
    const bufId = dumpData.bufferMap.get(b.nativePtr);
    if (bufId === undefined) continue;
    const existing = bufToBitmaps.get(bufId);
    if (existing) {
      existing.push(b.objectId);
    } else {
      bufToBitmaps.set(bufId, [b.objectId]);
    }
  }
  if (bufToBitmaps.size === 0) return result;

  const ids = [...bufToBitmaps.keys()].join(',');
  const bufRes = await engine.query(`
    SELECT o.id AS buf_id, od.array_data_hash AS hash
    FROM heap_graph_object o
    JOIN heap_graph_object_data od ON o.object_data_id = od.id
    WHERE o.id IN (${ids})
      AND od.array_data_hash IS NOT NULL
  `);
  for (
    const it = bufRes.iter({buf_id: NUM, hash: LONG_NULL});
    it.valid();
    it.next()
  ) {
    if (it.hash === null) continue;
    const hashStr = it.hash.toString();
    const bmpIds = bufToBitmaps.get(it.buf_id);
    if (bmpIds) {
      for (const id of bmpIds) {
        result.set(id, hashStr);
      }
    }
  }
  return result;
}

export async function getOverview(
  engine: Engine,
  activeDump: HeapDump,
): Promise<OverviewData> {
  const dumpFilter = dumpFilterSql(activeDump, 'o');
  const countRes = await engine.query(`
    SELECT
      sum(iif(o.reachable, 1, 0)) AS reachable,
      sum(iif(NOT o.reachable, 1, 0)) AS unreachable,
      count(DISTINCT o.type_id) AS classes
    FROM heap_graph_object o
    WHERE ${dumpFilter}
  `);
  const countIt = countRes.iter({
    reachable: NUM,
    unreachable: NUM,
    classes: NUM,
  });
  const reachableInstanceCount = countIt.reachable;
  const unreachableInstanceCount = countIt.unreachable;
  const classCount = countIt.classes;

  const heapRes = await engine.query(`
    SELECT
      ifnull(o.heap_type, 'default') AS heap,
      SUM(o.self_size) AS java,
      SUM(o.native_size) AS native_
    FROM heap_graph_object o
    WHERE o.reachable != 0 AND ${dumpFilter}
    GROUP BY heap
    ORDER BY heap
  `);
  const heaps: HeapInfo[] = [];
  for (
    const it = heapRes.iter({heap: STR, java: NUM, native_: NUM});
    it.valid();
    it.next()
  ) {
    heaps.push({name: it.heap, java: it.java, native_: it.native_});
  }

  // Duplicate bitmaps grouped by pixel content hash. Each bitmap's compressed
  // DumpData buffer is hashed to detect true content duplicates rather than
  // just matching on dimensions. Skipped for proto heap graphs (no HPROF data).
  const hasPrimitivesRes = await engine.query(
    `SELECT 1 FROM heap_graph_primitive LIMIT 1`,
  );
  const hasPrimitives = hasPrimitivesRes.iter({}).valid();
  const dupRes = hasPrimitives
    ? await engine.query(`
    SELECT
      o.id,
      MAX(CASE WHEN f.field_name GLOB '*mWidth' THEN f.int_value END) AS w,
      MAX(CASE WHEN f.field_name GLOB '*mHeight' THEN f.int_value END) AS h,
      MAX(CASE WHEN f.field_name GLOB '*mNativePtr' THEN f.long_value END)
        AS native_ptr,
      o.self_size + o.native_size AS total_bytes
    FROM heap_graph_object o
    JOIN heap_graph_class c ON o.type_id = c.id
    LEFT JOIN heap_graph_object_data od ON o.object_data_id = od.id
    LEFT JOIN heap_graph_primitive f ON f.field_set_id = od.field_set_id
    WHERE o.reachable != 0
      AND ${dumpFilter}
      AND (c.name = 'android.graphics.Bitmap'
        OR c.deobfuscated_name = 'android.graphics.Bitmap')
    GROUP BY o.id
    HAVING w IS NOT NULL AND h IS NOT NULL
  `)
    : null;

  // Collect bitmap info and compute content hashes.
  const bitmapInfos: Array<{
    id: number;
    w: number;
    h: number;
    totalBytes: number;
    nativePtr: bigint | null;
  }> = [];
  if (dupRes !== null) {
    for (
      const it = dupRes.iter({
        id: NUM,
        w: NUM,
        h: NUM,
        native_ptr: LONG_NULL,
        total_bytes: NUM,
      });
      it.valid();
      it.next()
    ) {
      bitmapInfos.push({
        id: it.id,
        w: it.w,
        h: it.h,
        totalBytes: it.total_bytes,
        nativePtr: it.native_ptr,
      });
    }
  }

  const hashInputs = bitmapInfos
    .filter((b) => b.nativePtr !== null)
    .map((b) => ({objectId: b.id, nativePtr: b.nativePtr!}));
  const hashes =
    hashInputs.length > 0
      ? await batchBitmapBufferHashes(engine, activeDump, hashInputs)
      : new Map<number, string>();

  const hashGroups = new Map<
    string,
    {w: number; h: number; cnt: number; total: number; min: number}
  >();
  for (const b of bitmapInfos) {
    const hash = hashes.get(b.id);
    if (!hash) continue;
    const existing = hashGroups.get(hash);
    if (existing) {
      existing.cnt++;
      existing.total += b.totalBytes;
      existing.min = Math.min(existing.min, b.totalBytes);
    } else {
      hashGroups.set(hash, {
        w: b.w,
        h: b.h,
        cnt: 1,
        total: b.totalBytes,
        min: b.totalBytes,
      });
    }
  }
  const duplicateBitmaps: DuplicateBitmapGroup[] = [];
  for (const [key, g] of hashGroups) {
    if (g.cnt < 2) continue;
    duplicateBitmaps.push({
      groupKey: key,
      width: g.w,
      height: g.h,
      count: g.cnt,
      totalBytes: g.total,
      wastedBytes: g.total - g.min,
    });
  }
  duplicateBitmaps.sort((a, b) => b.wastedBytes - a.wastedBytes);

  // Duplicate strings grouped by value. Only available for HPROF dumps
  // which populate heap_graph_object_data.value_string.
  const duplicateStrings: DuplicateStringGroup[] = [];
  if (hasPrimitives) {
    const strRes = await engine.query(`
      SELECT
        od.value_string AS value,
        COUNT(*) AS cnt,
        SUM(o.self_size) AS total_bytes,
        MIN(o.self_size) AS min_bytes
      FROM heap_graph_object o
      JOIN heap_graph_class c ON o.type_id = c.id
      LEFT JOIN heap_graph_object_data od ON o.object_data_id = od.id
      WHERE o.reachable != 0
        AND ${dumpFilter}
        AND od.value_string IS NOT NULL
        AND (c.name = 'java.lang.String'
          OR c.deobfuscated_name = 'java.lang.String')
      GROUP BY od.value_string
      HAVING cnt > 1
      ORDER BY total_bytes - min_bytes DESC
    `);
    for (
      const it = strRes.iter({
        value: STR,
        cnt: NUM,
        total_bytes: NUM,
        min_bytes: NUM,
      });
      it.valid();
      it.next()
    ) {
      duplicateStrings.push({
        value: it.value,
        count: it.cnt,
        totalBytes: it.total_bytes,
        wastedBytes: it.total_bytes - it.min_bytes,
      });
    }
  }

  const duplicateArrays: DuplicateArrayGroup[] = [];
  const dupArrRes = await engine.query(`
    SELECT
      ifnull(c.deobfuscated_name, c.name) AS cls,
      CAST(od.array_data_hash AS TEXT) AS hash,
      COUNT(*) AS cnt,
      SUM(o.self_size + o.native_size) AS total_bytes,
      MIN(o.self_size + o.native_size) AS min_bytes
    FROM heap_graph_object o
    JOIN heap_graph_class c ON o.type_id = c.id
    LEFT JOIN heap_graph_object_data od ON o.object_data_id = od.id
    WHERE o.reachable != 0
      AND ${dumpFilter}
      AND od.array_data_hash IS NOT NULL
    GROUP BY o.type_id, od.array_data_hash
    HAVING cnt > 1
    ORDER BY total_bytes - min_bytes DESC
  `);
  for (
    const it = dupArrRes.iter({
      cls: STR,
      hash: STR,
      cnt: NUM,
      total_bytes: NUM,
      min_bytes: NUM,
    });
    it.valid();
    it.next()
  ) {
    duplicateArrays.push({
      className: it.cls,
      arrayHash: it.hash,
      count: it.cnt,
      totalBytes: it.total_bytes,
      wastedBytes: it.total_bytes - it.min_bytes,
    });
  }

  return {
    reachableInstanceCount,
    unreachableInstanceCount,
    classCount,
    heaps,
    duplicateBitmaps:
      duplicateBitmaps.length > 0 ? duplicateBitmaps : undefined,
    duplicateStrings:
      duplicateStrings.length > 0 ? duplicateStrings : undefined,
    duplicateArrays: duplicateArrays.length > 0 ? duplicateArrays : undefined,
    hasFieldValues: hasPrimitives,
  };
}

export async function getAllocations(
  engine: Engine,
  activeDump: HeapDump,
  heap: string | null,
): Promise<ClassRow[]> {
  await requireDominatorTree(engine);
  const hf = heapFilter(heap);
  const res = await engine.query(`
    SELECT
      ifnull(c.deobfuscated_name, c.name) AS cls,
      COUNT(*) AS cnt,
      SUM(o.self_size) AS shallow,
      SUM(o.native_size) AS native_shallow,
      SUM(ifnull(d.dominated_size_bytes, o.self_size)) AS retained,
      SUM(ifnull(d.dominated_native_size_bytes, o.native_size))
        AS retained_native,
      SUM(ifnull(d.dominated_obj_count, 1)) AS retained_count,
      ifnull(o.heap_type, 'default') AS heap
    FROM heap_graph_object o
    JOIN heap_graph_class c ON o.type_id = c.id
    LEFT JOIN heap_graph_dominator_tree d ON d.id = o.id
    WHERE o.reachable != 0
      AND ${dumpFilterSql(activeDump, 'o')}
      ${hf}
    GROUP BY cls, heap
    ORDER BY retained DESC
  `);
  const rows: ClassRow[] = [];
  for (
    const it = res.iter({
      cls: STR,
      cnt: NUM,
      shallow: NUM,
      native_shallow: NUM,
      retained: NUM,
      retained_native: NUM,
      retained_count: NUM,
      heap: STR,
    });
    it.valid();
    it.next()
  ) {
    rows.push({
      className: it.cls,
      count: it.cnt,
      shallowSize: it.shallow,
      nativeSize: it.native_shallow,
      retainedSize: it.retained,
      retainedNativeSize: it.retained_native,
      retainedCount: it.retained_count,
      reachableSize: null,
      reachableNativeSize: null,
      reachableCount: null,
      heap: it.heap,
    });
  }
  return rows;
}

export async function getRooted(
  engine: Engine,
  activeDump: HeapDump,
): Promise<InstanceRow[]> {
  await requireDominatorTree(engine);
  const res = await engine.query(`
    SELECT ${INSTANCE_COLS}
    FROM heap_graph_dominator_tree d
    JOIN heap_graph_object o ON d.id = o.id
    JOIN heap_graph_class c ON o.type_id = c.id
    LEFT JOIN heap_graph_object_data od ON o.object_data_id = od.id
    WHERE d.idom_id IS NULL
      AND ${dumpFilterSql(activeDump, 'o')}
    ORDER BY d.dominated_size_bytes + d.dominated_native_size_bytes DESC
  `);
  return collectRows(res);
}

type FieldEntry = {name: string; typeName: string; value: PrimOrRef};

/** Fetch primitive and reference field values for an object. */
async function fetchFieldValues(
  engine: Engine,
  activeDump: HeapDump,
  refSetId: number | null,
  fieldSetId: number | null,
): Promise<FieldEntry[]> {
  const fields: FieldEntry[] = [];

  if (fieldSetId !== null) {
    const fRes = await engine.query(`
      SELECT field_name, field_type,
        bool_value, byte_value, char_value, short_value,
        int_value, long_value, float_value, double_value
      FROM heap_graph_primitive
      WHERE field_set_id = ${fieldSetId}
      ORDER BY field_name
    `);
    for (
      const fit = fRes.iter({
        field_name: STR,
        field_type: STR,
        bool_value: NUM_NULL,
        byte_value: NUM_NULL,
        char_value: NUM_NULL,
        short_value: NUM_NULL,
        int_value: NUM_NULL,
        long_value: LONG_NULL,
        float_value: NUM_NULL,
        double_value: NUM_NULL,
      });
      fit.valid();
      fit.next()
    ) {
      fields.push({
        name: fit.field_name,
        typeName: fit.field_type,
        value: primFieldValue(fit),
      });
    }
  }

  if (refSetId !== null) {
    // heap_graph_class is not dump-scoped; restrict and dedup by name to
    // avoid cross-dump row multiplication when deobfuscating field_type_name.
    const rRes = await engine.query(`
      WITH class_in_dump AS (
        SELECT c.name, MIN(c.deobfuscated_name) AS deobfuscated_name
        FROM heap_graph_class c
        JOIN heap_graph_object o ON o.type_id = c.id
        WHERE ${dumpFilterSql(activeDump, 'o')}
        GROUP BY c.name
      )
      SELECT
        ifnull(r.deobfuscated_field_name, r.field_name) AS fname,
        ifnull(ct.deobfuscated_name, r.field_type_name) AS ftype,
        r.owned_id,
        c2.name AS ref_cls,
        c2.deobfuscated_name AS ref_deob,
        od2.value_string AS ref_str,
        o2.self_size AS ref_self_size,
        o2.native_size AS ref_native_size,
        d2.dominated_size_bytes AS ref_dominated_size,
        d2.dominated_native_size_bytes AS ref_dominated_native
      FROM heap_graph_reference r
      LEFT JOIN class_in_dump ct ON r.field_type_name = ct.name
      LEFT JOIN heap_graph_object o2 ON r.owned_id = o2.id
      LEFT JOIN heap_graph_class c2 ON o2.type_id = c2.id
      LEFT JOIN heap_graph_object_data od2 ON o2.object_data_id = od2.id
      LEFT JOIN heap_graph_dominator_tree d2 ON d2.id = o2.id
      WHERE r.reference_set_id = ${refSetId}
      ORDER BY fname
    `);
    for (
      const rit = rRes.iter({
        fname: STR,
        ftype: STR_NULL,
        owned_id: NUM_NULL,
        ref_cls: STR_NULL,
        ref_deob: STR_NULL,
        ref_str: STR_NULL,
        ref_self_size: NUM_NULL,
        ref_native_size: NUM_NULL,
        ref_dominated_size: NUM_NULL,
        ref_dominated_native: NUM_NULL,
      });
      rit.valid();
      rit.next()
    ) {
      if (rit.owned_id === null || rit.owned_id === 0) {
        fields.push({
          name: rit.fname,
          typeName: rit.ftype ?? '',
          value: {kind: 'prim', v: 'null'},
        });
      } else {
        const refCls = className(rit.ref_cls, rit.ref_deob);
        fields.push({
          name: rit.fname,
          typeName: rit.ftype ?? '',
          value: {
            kind: 'ref',
            id: rit.owned_id,
            display: makeDisplay(refCls, rit.owned_id),
            str: rit.ref_str,
            shallowJava: rit.ref_self_size ?? 0,
            shallowNative: rit.ref_native_size ?? 0,
            retainedJava: rit.ref_dominated_size ?? rit.ref_self_size ?? 0,
            retainedNative:
              rit.ref_dominated_native ?? rit.ref_native_size ?? 0,
          },
        });
      }
    }
  }

  return fields;
}

/** Fetch dominator-tree path from GC root to the given object. */
export async function fetchDominatorPath(
  engine: Engine,
  id: number,
): Promise<InstanceDetail['dominatorPath']> {
  return (await fetchDominatorPaths(engine, [id])).get(id) ?? null;
}

/** Fetch shortest reference path from a GC root to the given object. */
export async function fetchShortestPathFromRoot(
  engine: Engine,
  id: number,
): Promise<InstanceDetail['shortestPath']> {
  return (await fetchShortestPaths(engine, [id])).get(id) ?? null;
}

/** Batch-fetch shortest reference paths for multiple objects. */
export async function fetchShortestPaths(
  engine: Engine,
  ids: number[],
): Promise<Map<number, PathEntry[]>> {
  const result = new Map<number, PathEntry[]>();
  if (ids.length === 0) return result;

  await requireDominatorTree(engine);

  // Walk UP the BFS tree (id→parent_id) from each target.
  const seeds = ids
    .map((id) => `SELECT ${id} AS root_node_id, 1 AS root_target_weight`)
    .join(' UNION ALL ');
  const dfsRes = await engine.query(`
    INCLUDE PERFETTO MODULE graphs.search;
    SELECT root_node_id, node_id
    FROM graph_reachable_weight_bounded_dfs!(
      (
        SELECT id AS source_node_id, parent_id AS dest_node_id,
          0 AS edge_weight
        FROM _heap_graph_object_min_depth_tree
        WHERE parent_id IS NOT NULL
      ),
      (${seeds}),
      1
    )
  `);

  const allNodeIds = new Set<number>();
  for (const it = dfsRes.iter({node_id: NUM}); it.valid(); it.next()) {
    allNodeIds.add(it.node_id);
  }
  if (allNodeIds.size === 0) return result;

  // Fetch parent_id links to reconstruct ordered chains.
  const parentRes = await engine.query(`
    SELECT id, parent_id
    FROM _heap_graph_object_min_depth_tree
    WHERE id IN (${Array.from(allNodeIds).join(',')})
  `);
  const parentMap = new Map<number, number | null>();
  for (
    const it = parentRes.iter({id: NUM, parent_id: NUM_NULL});
    it.valid();
    it.next()
  ) {
    parentMap.set(it.id, it.parent_id ?? null);
  }

  // Reconstruct ordered chains: walk parent_id from each target up, reverse.
  const pathChains = new Map<number, number[]>();
  for (const id of ids) {
    const chain: number[] = [];
    let cur: number | null = id;
    while (cur !== null && parentMap.has(cur)) {
      chain.push(cur);
      cur = parentMap.get(cur) ?? null;
    }
    if (chain.length === 0) continue;
    chain.reverse();
    pathChains.set(id, chain);
  }
  if (pathChains.size === 0) return result;

  // Batch-fetch node metadata.
  const nodeRes = await engine.query(`
    SELECT
      o.id, c.name AS cls, c.deobfuscated_name AS deob,
      o.self_size, o.native_size, o.heap_type, o.root_type,
      dt.dominated_size_bytes AS dominated_size,
      dt.dominated_native_size_bytes AS dominated_native,
      dt.dominated_obj_count,
      od.value_string, c.kind AS class_kind
    FROM heap_graph_object o
    JOIN heap_graph_class c ON o.type_id = c.id
    LEFT JOIN heap_graph_object_data od ON o.object_data_id = od.id
    LEFT JOIN heap_graph_dominator_tree dt ON dt.id = o.id
    WHERE o.id IN (${Array.from(allNodeIds).join(',')})
  `);
  const nodeMap = new Map<number, InstanceRow>();
  for (
    const it = nodeRes.iter({
      id: NUM,
      cls: STR,
      deob: STR_NULL,
      self_size: NUM,
      native_size: NUM,
      heap_type: STR_NULL,
      root_type: STR_NULL,
      dominated_size: NUM_NULL,
      dominated_native: NUM_NULL,
      dominated_obj_count: NUM_NULL,
      value_string: STR_NULL,
      class_kind: STR,
    });
    it.valid();
    it.next()
  ) {
    nodeMap.set(it.id, rowFromIter(it));
  }

  // Batch-resolve field names for all parent→child edges.
  const edgePairs: string[] = [];
  for (const chain of pathChains.values()) {
    for (let i = 0; i < chain.length - 1; i++) {
      edgePairs.push(`SELECT ${chain[i]} AS pid, ${chain[i + 1]} AS cid`);
    }
  }
  const fieldMap = new Map<string, string>();
  if (edgePairs.length > 0) {
    const fRes = await engine.query(`
      SELECT e.pid, e.cid,
        ifnull(r.deobfuscated_field_name, r.field_name) AS fname
      FROM (${edgePairs.join(' UNION ALL ')}) e
      JOIN heap_graph_object o ON o.id = e.pid
      JOIN heap_graph_reference r
        ON r.reference_set_id = o.reference_set_id AND r.owned_id = e.cid
    `);
    for (
      const it = fRes.iter({pid: NUM, cid: NUM, fname: STR});
      it.valid();
      it.next()
    ) {
      fieldMap.set(`${it.pid}:${it.cid}`, '.' + it.fname);
    }
  }

  for (const [targetId, chain] of pathChains) {
    const path: PathEntry[] = [];
    for (let i = 0; i < chain.length; i++) {
      const row = nodeMap.get(chain[i]);
      if (!row) continue;
      const field =
        i < chain.length - 1
          ? fieldMap.get(`${chain[i]}:${chain[i + 1]}`) ?? ''
          : '';
      path.push({row, field, isDominator: false});
    }
    if (path.length > 0) result.set(targetId, path);
  }
  return result;
}

/** Batch-fetch dominator-tree paths for multiple objects. */
export async function fetchDominatorPaths(
  engine: Engine,
  ids: number[],
): Promise<Map<number, PathEntry[]>> {
  const result = new Map<number, PathEntry[]>();
  if (ids.length === 0) return result;

  await requireDominatorTree(engine);

  // Reversed edges (id→idom_id) so DFS walks UP towards the root.
  const seeds = ids
    .map((id) => `SELECT ${id} AS root_node_id, 1 AS root_target_weight`)
    .join(' UNION ALL ');
  const dfsRes = await engine.query(`
    INCLUDE PERFETTO MODULE graphs.search;
    SELECT root_node_id, node_id
    FROM graph_reachable_weight_bounded_dfs!(
      (
        SELECT id AS source_node_id, idom_id AS dest_node_id,
          0 AS edge_weight
        FROM heap_graph_dominator_tree
        WHERE idom_id IS NOT NULL
      ),
      (${seeds}),
      1
    )
  `);

  const allNodeIds = new Set<number>();
  for (const it = dfsRes.iter({node_id: NUM}); it.valid(); it.next()) {
    allNodeIds.add(it.node_id);
  }
  if (allNodeIds.size === 0) return result;

  const idomRes = await engine.query(`
    SELECT id, idom_id
    FROM heap_graph_dominator_tree
    WHERE id IN (${Array.from(allNodeIds).join(',')})
  `);
  const idomMap = new Map<number, number | null>();
  for (
    const it = idomRes.iter({id: NUM, idom_id: NUM_NULL});
    it.valid();
    it.next()
  ) {
    idomMap.set(it.id, it.idom_id ?? null);
  }

  // Reconstruct ordered chains: walk idom_id from each ID up, then reverse.
  const pathChains = new Map<number, number[]>();
  for (const id of ids) {
    const chain: number[] = [];
    let cur: number | null = id;
    while (cur !== null && idomMap.has(cur)) {
      chain.push(cur);
      cur = idomMap.get(cur) ?? null;
    }
    if (chain.length === 0) continue;
    chain.reverse();
    pathChains.set(id, chain);
  }
  if (pathChains.size === 0) return result;

  const nodeRes = await engine.query(`
    SELECT
      o.id, c.name AS cls, c.deobfuscated_name AS deob,
      o.self_size, o.native_size, o.heap_type, o.root_type,
      dt.dominated_size_bytes AS dominated_size,
      dt.dominated_native_size_bytes AS dominated_native,
      dt.dominated_obj_count,
      od.value_string, c.kind AS class_kind
    FROM heap_graph_object o
    JOIN heap_graph_class c ON o.type_id = c.id
    LEFT JOIN heap_graph_object_data od ON o.object_data_id = od.id
    LEFT JOIN heap_graph_dominator_tree dt ON dt.id = o.id
    WHERE o.id IN (${Array.from(allNodeIds).join(',')})
  `);
  const nodeMap = new Map<number, InstanceRow>();
  for (
    const it = nodeRes.iter({
      id: NUM,
      cls: STR,
      deob: STR_NULL,
      self_size: NUM,
      native_size: NUM,
      heap_type: STR_NULL,
      root_type: STR_NULL,
      dominated_size: NUM_NULL,
      dominated_native: NUM_NULL,
      dominated_obj_count: NUM_NULL,
      value_string: STR_NULL,
      class_kind: STR,
    });
    it.valid();
    it.next()
  ) {
    nodeMap.set(it.id, rowFromIter(it));
  }

  const edgePairs: string[] = [];
  for (const chain of pathChains.values()) {
    for (let i = 0; i < chain.length - 1; i++) {
      edgePairs.push(`SELECT ${chain[i]} AS pid, ${chain[i + 1]} AS cid`);
    }
  }
  const fieldMap = new Map<string, string>();
  if (edgePairs.length > 0) {
    const fRes = await engine.query(`
      SELECT e.pid, e.cid,
        ifnull(r.deobfuscated_field_name, r.field_name) AS fname
      FROM (${edgePairs.join(' UNION ALL ')}) e
      JOIN heap_graph_object o ON o.id = e.pid
      JOIN heap_graph_reference r
        ON r.reference_set_id = o.reference_set_id AND r.owned_id = e.cid
    `);
    for (
      const it = fRes.iter({pid: NUM, cid: NUM, fname: STR});
      it.valid();
      it.next()
    ) {
      fieldMap.set(`${it.pid}:${it.cid}`, '.' + it.fname);
    }
  }

  for (const [targetId, chain] of pathChains) {
    const path: PathEntry[] = [];
    for (let i = 0; i < chain.length; i++) {
      const row = nodeMap.get(chain[i]);
      if (!row) continue;
      const field =
        i < chain.length - 1
          ? fieldMap.get(`${chain[i]}:${chain[i + 1]}`) ?? ''
          : '';
      path.push({row, field, isDominator: true});
    }
    if (path.length > 0) result.set(targetId, path);
  }
  return result;
}

export async function getInstance(
  engine: Engine,
  activeDump: HeapDump,
  id: number,
): Promise<InstanceDetail | null> {
  await requireDominatorTree(engine);
  const objRes = await engine.query(`
    SELECT
      o.id,
      o.type_id,
      c.name AS cls,
      c.deobfuscated_name AS deob,
      c.kind AS class_kind,
      o.self_size,
      o.native_size,
      o.heap_type,
      o.root_type,
      od.value_string,
      o.reference_set_id,
      od.field_set_id,
      od.array_element_type,
      od.array_data_id,
      d.dominated_size_bytes AS dominated_size,
      d.dominated_native_size_bytes AS dominated_native,
      d.dominated_obj_count
    FROM heap_graph_object o
    JOIN heap_graph_class c ON o.type_id = c.id
    LEFT JOIN heap_graph_dominator_tree d ON d.id = o.id
    LEFT JOIN heap_graph_object_data od ON o.object_data_id = od.id
    WHERE o.id = ${id}
  `);

  const oit = objRes.iter({
    id: NUM,
    type_id: NUM,
    cls: STR,
    deob: STR_NULL,
    class_kind: STR,
    self_size: NUM,
    native_size: NUM,
    heap_type: STR_NULL,
    root_type: STR_NULL,
    value_string: STR_NULL,
    reference_set_id: NUM_NULL,
    field_set_id: NUM_NULL,
    array_element_type: STR_NULL,
    array_data_id: NUM_NULL,
    dominated_size: NUM_NULL,
    dominated_native: NUM_NULL,
    dominated_obj_count: NUM_NULL,
  });
  if (!oit.valid()) return null;

  const fullClassName = className(oit.cls, oit.deob);
  const classKind = oit.class_kind;
  const typeId = oit.type_id;
  const refSetId = oit.reference_set_id;
  const fieldSetId = oit.field_set_id;
  const arrayDataId = oit.array_data_id;
  const arrayElementType = oit.array_element_type;

  // Class objects in the C++ parser are named "java.lang.Class<ClassName>".
  const isClassObj = fullClassName.startsWith('java.lang.Class<');
  const isArrayInstance = fullClassName.endsWith('[]');

  const reachabilityName = KIND_TO_REACHABILITY[classKind] ?? 'strong';

  const row = rowFromIter({...oit, class_kind: classKind});
  row.reachabilityName = reachabilityName;

  // Detect referent for Reference subclasses.
  if (reachabilityName !== 'strong' && refSetId !== null) {
    const refResult = await engine.query(`
      SELECT
        r.owned_id,
        c2.name AS ref_cls, c2.deobfuscated_name AS ref_deob,
        od2.value_string AS ref_str
      FROM heap_graph_reference r
      LEFT JOIN heap_graph_object o2 ON r.owned_id = o2.id
      LEFT JOIN heap_graph_class c2 ON o2.type_id = c2.id
      LEFT JOIN heap_graph_object_data od2 ON o2.object_data_id = od2.id
      WHERE r.reference_set_id = ${refSetId}
        AND (r.field_name GLOB '*referent'
          OR r.deobfuscated_field_name GLOB '*referent')

    `);
    const rit = refResult.iter({
      owned_id: NUM_NULL,
      ref_cls: STR_NULL,
      ref_deob: STR_NULL,
      ref_str: STR_NULL,
    });
    if (rit.valid() && rit.owned_id !== null && rit.owned_id !== 0) {
      const refCls = className(rit.ref_cls, rit.ref_deob);
      row.referent = {
        id: rit.owned_id,
        display: makeDisplay(refCls, rit.owned_id),
        className: refCls,
        isRoot: false,
        rootTypeNames: null,
        reachabilityName: 'strong',
        heap: row.heap,
        shallowJava: 0,
        shallowNative: 0,
        retainedTotal: 0,
        retainedCount: 1,
        reachableCount: null,
        reachableSize: null,
        reachableNative: null,
        retainedByHeap: [],
        str: rit.ref_str,
        referent: null,
      };
    }
  }

  // Look up the java.lang.Class<X> object for this class.
  let classObjRow: InstanceRow | null = null;
  {
    const classObjName = sqlEsc(`java.lang.Class<${oit.cls}>`);
    const cRes = await engine.query(`
      SELECT ${INSTANCE_COLS}
      FROM heap_graph_object o
      JOIN heap_graph_class c ON o.type_id = c.id
      LEFT JOIN heap_graph_dominator_tree d ON d.id = o.id
      LEFT JOIN heap_graph_object_data od ON o.object_data_id = od.id
      WHERE ${dumpFilterSql(activeDump, 'o')}
        AND (c.name = '${classObjName}'
          OR c.deobfuscated_name = '${classObjName}')
    `);
    const rows = collectRows(cRes);
    if (rows.length > 0) classObjRow = rows[0];
  }

  const instanceFields =
    !isArrayInstance && !isClassObj
      ? await fetchFieldValues(engine, activeDump, refSetId, fieldSetId)
      : [];

  let arrayLength = 0;
  let elemTypeName: string | null = null;
  const arrayElems: InstanceDetail['arrayElems'] = [];
  if (isArrayInstance) {
    elemTypeName = fullClassName.slice(0, -2); // "int[]" → "int"
    if (arrayDataId !== null && arrayElementType !== null) {
      // Primitive array: decode via JSON SQL function.
      const jsonRes = await engine.query(`
        SELECT __intrinsic_heap_graph_array_json(${arrayDataId}) AS data
      `);
      const jit = jsonRes.iter({data: STR_NULL});
      if (jit.valid() && jit.data !== null) {
        const values = JSON.parse(jit.data) as Array<number | string | boolean>;
        for (let i = 0; i < values.length; i++) {
          arrayElems.push({
            idx: i,
            value: {
              kind: 'prim',
              v: formatPrimValue(arrayElementType, values[i]),
            },
          });
        }
        arrayLength = values.length;
      }
    } else if (refSetId !== null) {
      // Object array elements (String[], Object[], etc.)
      // For HPROF, field_name is "[0]", "[1]", etc.
      // For perfetto heap graph, field_name may be empty or a plain name.
      // We use the row order as fallback index.
      const oaRes = await engine.query(`
        SELECT
          r.field_name AS fname,
          r.owned_id,
          c2.name AS ref_cls,
          c2.deobfuscated_name AS ref_deob,
          od2.value_string AS ref_str,
          o2.self_size AS ref_shallow,
          o2.native_size AS ref_native,
          ifnull(d2.dominated_size_bytes, o2.self_size) AS ref_retained,
          ifnull(d2.dominated_native_size_bytes, o2.native_size)
            AS ref_retained_native
        FROM heap_graph_reference r
        LEFT JOIN heap_graph_object o2 ON r.owned_id = o2.id
        LEFT JOIN heap_graph_object_data od2 ON o2.object_data_id = od2.id
        LEFT JOIN heap_graph_class c2 ON o2.type_id = c2.id
        LEFT JOIN heap_graph_dominator_tree d2 ON d2.id = o2.id
        WHERE r.reference_set_id = ${refSetId}
        ORDER BY r.id
      `);
      let seqIdx = 0;
      for (
        const oait = oaRes.iter({
          fname: STR,
          owned_id: NUM_NULL,
          ref_cls: STR_NULL,
          ref_deob: STR_NULL,
          ref_str: STR_NULL,
          ref_shallow: NUM_NULL,
          ref_native: NUM_NULL,
          ref_retained: NUM_NULL,
          ref_retained_native: NUM_NULL,
        });
        oait.valid();
        oait.next()
      ) {
        const raw = oait.fname;
        let idx: number;
        if (raw.startsWith('[') && raw.endsWith(']')) {
          idx = parseInt(raw.slice(1, raw.length - 1), 10);
        } else {
          const parsed = parseInt(raw, 10);
          idx = Number.isNaN(parsed) ? seqIdx : parsed;
        }
        seqIdx++;
        let value: PrimOrRef;
        if (oait.owned_id === null || oait.owned_id === 0) {
          value = {kind: 'prim', v: 'null'};
        } else {
          const refCls = className(oait.ref_cls, oait.ref_deob);
          value = {
            kind: 'ref',
            id: oait.owned_id,
            display: makeDisplay(refCls, oait.owned_id),
            str: oait.ref_str,
            shallowJava: oait.ref_shallow ?? 0,
            shallowNative: oait.ref_native ?? 0,
            retainedJava: oait.ref_retained ?? 0,
            retainedNative: oait.ref_retained_native ?? 0,
          };
        }
        arrayElems.push({idx, value});
        arrayLength = Math.max(arrayLength, idx + 1);
      }
    }
  }

  const revRes = await engine.query(`
    SELECT ${INSTANCE_COLS}
    FROM heap_graph_reference r
    JOIN heap_graph_object o ON r.owner_id = o.id
    JOIN heap_graph_class c ON o.type_id = c.id
    LEFT JOIN heap_graph_dominator_tree d ON d.id = o.id
    LEFT JOIN heap_graph_object_data od ON o.object_data_id = od.id
    WHERE r.owned_id = ${id}
    ORDER BY (ifnull(d.dominated_size_bytes, 0)
      + ifnull(d.dominated_native_size_bytes, 0)) DESC
  `);
  const reverseRefs = collectRows(revRes);

  const domRes = await engine.query(`
    SELECT ${INSTANCE_COLS}
    FROM heap_graph_dominator_tree d
    JOIN heap_graph_object o ON d.id = o.id
    JOIN heap_graph_class c ON o.type_id = c.id
    LEFT JOIN heap_graph_object_data od ON o.object_data_id = od.id
    WHERE d.idom_id = ${id}
    ORDER BY (d.dominated_size_bytes + d.dominated_native_size_bytes) DESC
  `);
  const dominated = collectRows(domRes);

  const dominatorPath = await fetchDominatorPath(engine, id);
  const shortestPath = await fetchShortestPathFromRoot(engine, id);

  const staticFields = isClassObj
    ? await fetchFieldValues(engine, activeDump, refSetId, fieldSetId)
    : [];

  let bitmap: InstanceDetail['bitmap'] = null;
  if (fullClassName === 'android.graphics.Bitmap') {
    bitmap = await extractBitmapPixels(engine, activeDump, fieldSetId);
  }
  if (bitmap === null) {
    const deobName = className(oit.cls, oit.deob);
    if (deobName === 'android.graphics.Bitmap') {
      bitmap = await extractBitmapPixels(engine, activeDump, fieldSetId);
    }
  }

  const classHierarchy = await getClassHierarchy(engine, typeId);

  return {
    row,
    isClassObj,
    isArrayInstance,
    isClassInstance: !isClassObj && !isArrayInstance,
    classObjRow,
    instanceSize: row.shallowJava,
    classHierarchy,
    staticFields,
    instanceFields,
    elemTypeName,
    arrayLength,
    arrayElems,
    bitmap,
    reverseRefs,
    dominated,
    dominatorPath,
    shortestPath,
  };
}

/**
 * Superclass chain of `startClassId`, ordered starting-class first. The DFS
 * over `id → superclass_id` is a linear chain under single inheritance, so
 * we walk it by following each row's `parent_node_id` (the DFS predecessor,
 * i.e. the subclass that discovered this ancestor).
 */
export async function getClassHierarchy(
  engine: Engine,
  startClassId: number,
): Promise<string[]> {
  const res = await engine.query(`
    INCLUDE PERFETTO MODULE graphs.search;

    SELECT
      dfs.node_id AS class_id,
      dfs.parent_node_id AS child_class_id,
      coalesce(c.deobfuscated_name, c.name) AS name
    FROM graph_reachable_dfs!(
      (SELECT id AS source_node_id, superclass_id AS dest_node_id
       FROM heap_graph_class WHERE superclass_id IS NOT NULL),
      (SELECT ${startClassId} AS node_id)
    ) AS dfs
    JOIN heap_graph_class c ON c.id = dfs.node_id
  `);

  type Row = {classId: number; childClassId: number | null; className: string};
  const rows: Row[] = [];
  for (
    const it = res.iter({
      class_id: NUM,
      child_class_id: NUM_NULL,
      name: STR,
    });
    it.valid();
    it.next()
  ) {
    rows.push({
      classId: it.class_id,
      childClassId: it.child_class_id,
      className: it.name,
    });
  }

  const byChildId = new Map<number, Row>();
  let start: Row | undefined;
  for (const r of rows) {
    if (r.classId === startClassId) start = r;
    else if (r.childClassId !== null) byChildId.set(r.childClassId, r);
  }

  const chain: string[] = [];
  for (let cur = start; cur !== undefined; cur = byChildId.get(cur.classId)) {
    chain.push(cur.className);
  }
  return chain;
}

/** Transitive subclass names of `rootName` (including the root itself). */
export async function getSubclassNames(
  engine: Engine,
  activeDump: HeapDump,
  rootName: string,
): Promise<string[]> {
  const res = await engine.query(`
    INCLUDE PERFETTO MODULE graphs.search;

    WITH dump_classes AS (
      SELECT DISTINCT c.id, c.name, c.deobfuscated_name, c.superclass_id
      FROM heap_graph_class c
      JOIN heap_graph_object o ON o.type_id = c.id
      WHERE ${dumpFilterSql(activeDump, 'o')}
    )
    SELECT coalesce(c.deobfuscated_name, c.name) AS name
    FROM graph_reachable_dfs!(
      (SELECT superclass_id AS source_node_id, id AS dest_node_id
       FROM dump_classes WHERE superclass_id IS NOT NULL),
      (SELECT id AS node_id FROM dump_classes
       WHERE coalesce(deobfuscated_name, name) = '${sqlEsc(rootName)}'
       LIMIT 1)
    ) AS dfs
    JOIN dump_classes c ON c.id = dfs.node_id
  `);
  const names: string[] = [];
  for (const it = res.iter({name: STR}); it.valid(); it.next()) {
    names.push(it.name);
  }
  return names;
}

export async function getRawArrayBlob(
  engine: Engine,
  objectId: number,
): Promise<Uint8Array | null> {
  const res = await engine.query(`
    SELECT __intrinsic_heap_graph_array(od.array_data_id) AS data
    FROM heap_graph_object o
    JOIN heap_graph_object_data od ON o.object_data_id = od.id
    WHERE o.id = ${objectId} AND od.array_data_id IS NOT NULL
  `);
  const it = res.iter({data: BLOB_NULL});
  if (it.valid() && it.data !== null) return it.data;
  return null;
}

export async function getRawBitmapBlob(
  engine: Engine,
  activeDump: HeapDump,
  objectId: number,
): Promise<{data: Uint8Array; format: string} | null> {
  const fieldRes = await engine.query(`
    SELECT f.long_value
    FROM heap_graph_object o
    JOIN heap_graph_object_data od ON o.object_data_id = od.id
    JOIN heap_graph_primitive f ON f.field_set_id = od.field_set_id
    WHERE o.id = ${objectId}
      AND f.field_name GLOB '*mNativePtr'
  `);
  const fit = fieldRes.iter({long_value: LONG_NULL});
  if (!fit.valid() || fit.long_value === null) return null;
  const nativePtr = fit.long_value;

  const dumpData = await loadBitmapDumpData(engine, activeDump);
  if (!dumpData) return null;
  const bufferObjId = dumpData.bufferMap.get(nativePtr);
  if (bufferObjId === undefined) return null;

  const bufRes = await engine.query(`
    SELECT __intrinsic_heap_graph_array(od.array_data_id) AS data
    FROM heap_graph_object o
    JOIN heap_graph_object_data od ON o.object_data_id = od.id
    WHERE o.id = ${bufferObjId}
  `);
  const bit = bufRes.iter({data: BLOB_NULL});
  if (!bit.valid() || bit.data === null) return null;

  const format = DUMP_DATA_FORMAT_NAMES[dumpData.format] ?? 'png';
  return {data: bit.data, format};
}

/** Format a single JSON-decoded primitive value for display. */
function formatPrimValue(type: string, v: number | string | boolean): string {
  if (type === 'boolean') return Number(v) !== 0 ? 'true' : 'false';
  if (type === 'char') {
    const c = v as number;
    return c >= 32 && c < 127 ? `'${String.fromCharCode(c)}'` : String(c);
  }
  return String(v);
}

//
// Modern Android (API 26+) stores bitmap pixel data via a static field
// `android.graphics.Bitmap.dumpData` pointing to a `Bitmap$DumpData` instance.
// DumpData contains: format (int: 0=JPEG, 1=PNG, 2-4=WEBP), natives (long[])
// mapping native pointers, and buffers (Object[] of byte[]) with compressed
// image data. Each Bitmap instance has mNativePtr (long) used to index into
// the natives/buffers arrays.

interface BitmapDumpData {
  format: number; // 0=JPEG, 1=PNG, 2-4=WEBP
  // Map from native pointer (as bigint) to buffer object ID.
  bufferMap: Map<bigint, number>;
}

const bitmapDumpDataByDump = new WeakMap<
  HeapDump,
  Promise<BitmapDumpData | null>
>();

function loadBitmapDumpData(
  engine: Engine,
  activeDump: HeapDump,
): Promise<BitmapDumpData | null> {
  const cached = bitmapDumpDataByDump.get(activeDump);
  if (cached !== undefined) return cached;
  const promise = computeBitmapDumpData(engine, activeDump);
  bitmapDumpDataByDump.set(activeDump, promise);
  return promise;
}

async function computeBitmapDumpData(
  engine: Engine,
  activeDump: HeapDump,
): Promise<BitmapDumpData | null> {
  const classObjRes = await engine.query(`
    SELECT o.reference_set_id
    FROM heap_graph_object o
    JOIN heap_graph_class c ON o.type_id = c.id
    WHERE ${dumpFilterSql(activeDump, 'o')}
      AND (c.name LIKE '%Class<android.graphics.Bitmap>'
        OR c.deobfuscated_name LIKE '%Class<android.graphics.Bitmap>')
  `);
  const classIt = classObjRes.iter({reference_set_id: NUM_NULL});
  if (!classIt.valid() || classIt.reference_set_id === null) return null;

  // Step 2: Follow dumpData reference from the class object.
  const ddRes = await engine.query(`
    SELECT r.owned_id AS dump_data_id
    FROM heap_graph_reference r
    WHERE r.reference_set_id = ${classIt.reference_set_id}
      AND r.field_name GLOB '*dumpData'

  `);
  const ddIt = ddRes.iter({dump_data_id: NUM_NULL});
  if (!ddIt.valid() || ddIt.dump_data_id === null) return null;
  const dumpDataId = ddIt.dump_data_id;

  // Step 3: Read format from DumpData's fields.
  const fmtRes = await engine.query(`
    SELECT int_value FROM heap_graph_primitive
    WHERE field_set_id = (
      SELECT od.field_set_id FROM heap_graph_object o
      JOIN heap_graph_object_data od ON o.object_data_id = od.id
      WHERE o.id = ${dumpDataId}
    )
      AND field_name GLOB '*format'

  `);
  const fmtIt = fmtRes.iter({int_value: NUM_NULL});
  const format = fmtIt.valid() ? fmtIt.int_value ?? 1 : 1;

  // Step 4: Get DumpData's references — natives (long[]) and buffers (Object[]).
  const refsRes = await engine.query(`
    SELECT r.field_name, r.owned_id
    FROM heap_graph_reference r
    WHERE r.reference_set_id = (SELECT reference_set_id FROM heap_graph_object WHERE id = ${dumpDataId})
      AND (r.field_name GLOB '*natives' OR r.field_name GLOB '*buffers')
  `);
  let nativesObjId: number | null = null;
  let buffersObjId: number | null = null;
  for (
    const it = refsRes.iter({field_name: STR, owned_id: NUM});
    it.valid();
    it.next()
  ) {
    if (it.field_name.endsWith('natives')) nativesObjId = it.owned_id;
    if (it.field_name.endsWith('buffers')) buffersObjId = it.owned_id;
  }
  if (nativesObjId === null || buffersObjId === null) return null;

  // Step 5: Decode natives long[] via JSON.
  const nativesRes = await engine.query(`
    SELECT __intrinsic_heap_graph_array_json(od.array_data_id) AS data
    FROM heap_graph_object o
    JOIN heap_graph_object_data od ON o.object_data_id = od.id
    WHERE o.id = ${nativesObjId}
  `);
  const nativesIt = nativesRes.iter({data: STR_NULL});
  if (!nativesIt.valid() || nativesIt.data === null) return null;
  // Native pointers need BigInt for 64-bit precision in Map lookups.
  const nativesPtrs = (JSON.parse(nativesIt.data) as string[]).map(BigInt);

  // Step 6: Get buffers Object[] references — array index → byte[] object ID.
  const bufsRes = await engine.query(`
    SELECT r.field_name, r.owned_id
    FROM heap_graph_reference r
    WHERE r.reference_set_id = (SELECT reference_set_id FROM heap_graph_object WHERE id = ${buffersObjId})
    ORDER BY CAST(SUBSTR(r.field_name, 2) AS INTEGER)
  `);
  const bufferObjIds: number[] = [];
  for (
    const it = bufsRes.iter({field_name: STR, owned_id: NUM});
    it.valid();
    it.next()
  ) {
    // field_name is "[0]", "[1]", etc.
    const idx = parseInt(it.field_name.slice(1, -1), 10);
    bufferObjIds[idx] = it.owned_id;
  }

  // Step 7: Build nativePtr → buffer object ID map.
  const bufferMap = new Map<bigint, number>();
  const count = Math.min(nativesPtrs.length, bufferObjIds.length);
  for (let i = 0; i < count; i++) {
    if (bufferObjIds[i] !== undefined && bufferObjIds[i] !== 0) {
      bufferMap.set(nativesPtrs[i], bufferObjIds[i]);
    }
  }

  return {format, bufferMap};
}

const DUMP_DATA_FORMAT_NAMES: Record<number, string> = {
  0: 'jpeg',
  1: 'png',
  2: 'webp',
  3: 'webp',
  4: 'webp',
};

async function extractBitmapPixels(
  engine: Engine,
  activeDump: HeapDump,
  fieldSetId: number | null,
): Promise<InstanceDetail['bitmap']> {
  if (fieldSetId === null) return null;

  const dimRes = await engine.query(`
    SELECT field_name, int_value, long_value
    FROM heap_graph_primitive
    WHERE field_set_id = ${fieldSetId}
      AND (field_name GLOB '*mWidth' OR field_name GLOB '*mHeight'
        OR field_name GLOB '*mNativePtr')
  `);
  let width = 0;
  let height = 0;
  let nativePtr = 0n;
  for (
    const it = dimRes.iter({
      field_name: STR,
      int_value: NUM_NULL,
      long_value: LONG_NULL,
    });
    it.valid();
    it.next()
  ) {
    if (it.field_name.endsWith('mWidth')) width = it.int_value ?? 0;
    if (it.field_name.endsWith('mHeight')) height = it.int_value ?? 0;
    if (it.field_name.endsWith('mNativePtr')) {
      nativePtr = it.long_value ?? 0n;
    }
  }
  if (width <= 0 || height <= 0 || nativePtr === 0n) return null;

  const dumpData = await loadBitmapDumpData(engine, activeDump);
  if (dumpData === null) return null;

  const bufferObjId = dumpData.bufferMap.get(nativePtr);
  if (bufferObjId === undefined) return null;

  const bufRes = await engine.query(`
    SELECT __intrinsic_heap_graph_array(od.array_data_id) AS data
    FROM heap_graph_object o
    JOIN heap_graph_object_data od ON o.object_data_id = od.id
    WHERE o.id = ${bufferObjId}
  `);
  const bufIt = bufRes.iter({data: BLOB_NULL});
  if (!bufIt.valid() || bufIt.data === null) return null;

  const format = DUMP_DATA_FORMAT_NAMES[dumpData.format] ?? 'png';
  return {width, height, format, data: bufIt.data};
}

export async function getBitmapPixels(
  engine: Engine,
  activeDump: HeapDump,
  objectId: number,
): Promise<InstanceDetail['bitmap']> {
  const res = await engine.query(`
    SELECT od.field_set_id
    FROM heap_graph_object o
    JOIN heap_graph_object_data od ON o.object_data_id = od.id
    WHERE o.id = ${objectId}
`);
  const row = res.maybeFirstRow({field_set_id: NUM_NULL});
  return extractBitmapPixels(engine, activeDump, row?.field_set_id ?? null);
}

export async function search(
  engine: Engine,
  activeDump: HeapDump,
  query: string,
): Promise<InstanceRow[]> {
  await requireDominatorTree(engine);
  if (query.startsWith('0x') || query.startsWith('0X')) {
    const numId = parseInt(query, 16);
    if (!isNaN(numId)) {
      const res = await engine.query(`
        SELECT ${INSTANCE_COLS}
        FROM heap_graph_object o
        JOIN heap_graph_class c ON o.type_id = c.id
        LEFT JOIN heap_graph_dominator_tree d ON d.id = o.id
        LEFT JOIN heap_graph_object_data od ON o.object_data_id = od.id
        WHERE o.id = ${numId}
      `);
      return collectRows(res);
    }
  }

  const escaped = query.replace(/[%_\\]/g, '\\$&').replace(/'/g, "''");
  const res = await engine.query(`
    SELECT ${INSTANCE_COLS}
    FROM heap_graph_object o
    JOIN heap_graph_class c ON o.type_id = c.id
    LEFT JOIN heap_graph_dominator_tree d ON d.id = o.id
    LEFT JOIN heap_graph_object_data od ON o.object_data_id = od.id
    WHERE o.reachable != 0
      AND ${dumpFilterSql(activeDump, 'o')}
      AND (c.name LIKE '%${escaped}%' ESCAPE '\\'
        OR c.deobfuscated_name LIKE '%${escaped}%' ESCAPE '\\')
    ORDER BY (ifnull(d.dominated_size_bytes, 0)
      + ifnull(d.dominated_native_size_bytes, 0)) DESC
  `);
  return collectRows(res);
}

export async function getObjects(
  engine: Engine,
  activeDump: HeapDump,
  cls: string,
  heap: string | null,
): Promise<InstanceRow[]> {
  await requireDominatorTree(engine);
  const escaped = sqlEsc(cls);
  const hf = heapFilter(heap);
  const res = await engine.query(`
    SELECT ${INSTANCE_COLS}
    FROM heap_graph_object o
    JOIN heap_graph_class c ON o.type_id = c.id
    LEFT JOIN heap_graph_dominator_tree d ON d.id = o.id
    LEFT JOIN heap_graph_object_data od ON o.object_data_id = od.id
    WHERE o.reachable != 0
      AND ${dumpFilterSql(activeDump, 'o')}
      AND (c.name = '${escaped}' OR c.deobfuscated_name = '${escaped}')
      ${hf}
    ORDER BY o.self_size + o.native_size DESC
  `);
  return collectRows(res);
}

export async function getObjectsByFlamegraphSelection(
  engine: Engine,
  pathHashes: string,
  isDominator: boolean,
): Promise<InstanceRow[]> {
  await requireDominatorTree(engine);
  // Query objects matching the given path hashes from the flamegraph.
  // Path hashes are comma-separated integers identifying class tree nodes.
  const hashTable = isDominator
    ? '_heap_graph_dominator_path_hashes'
    : '_heap_graph_path_hashes';
  const values = pathHashes
    .split(',')
    .map((v) => `(${v.trim()})`)
    .join(', ');
  const res = await engine.query(`
    WITH _hde_sel(path_hash) AS (VALUES ${values})
    SELECT ${INSTANCE_COLS}
    FROM _hde_sel f
    JOIN ${hashTable} h ON h.path_hash = f.path_hash
    JOIN heap_graph_object o ON o.id = h.id
    JOIN heap_graph_class c ON o.type_id = c.id
    LEFT JOIN heap_graph_dominator_tree d ON d.id = o.id
    LEFT JOIN heap_graph_object_data od ON o.object_data_id = od.id
    ORDER BY o.self_size + o.native_size DESC
  `);
  return collectRows(res);
}

export async function getStringList(
  engine: Engine,
  activeDump: HeapDump,
): Promise<StringListRow[]> {
  await requireDominatorTree(engine);
  const res = await engine.query(`
    SELECT
      o.id,
      od.value_string AS value,
      o.self_size,
      o.native_size,
      o.heap_type,
      c.name AS cls,
      c.deobfuscated_name AS deob,
      d.dominated_size_bytes AS dominated_size,
      d.dominated_native_size_bytes AS dominated_native
    FROM heap_graph_object o
    JOIN heap_graph_class c ON o.type_id = c.id
    LEFT JOIN heap_graph_object_data od ON o.object_data_id = od.id
    LEFT JOIN heap_graph_dominator_tree d ON d.id = o.id
    WHERE o.reachable != 0
      AND ${dumpFilterSql(activeDump, 'o')}
      AND od.value_string IS NOT NULL
      AND (c.name = 'java.lang.String'
        OR c.deobfuscated_name = 'java.lang.String')
    ORDER BY (ifnull(d.dominated_size_bytes, 0)
      + ifnull(d.dominated_native_size_bytes, 0)) DESC
  `);

  const rows: StringListRow[] = [];
  for (
    const it = res.iter({
      id: NUM,
      value: STR,
      self_size: NUM,
      native_size: NUM,
      heap_type: STR_NULL,
      cls: STR,
      deob: STR_NULL,
      dominated_size: NUM_NULL,
      dominated_native: NUM_NULL,
    });
    it.valid();
    it.next()
  ) {
    const fullCls = className(it.cls, it.deob);
    rows.push({
      id: it.id,
      value: it.value,
      length: it.value.length,
      retainedSize: it.dominated_size ?? it.self_size,
      reachableSize: null,
      reachableNativeSize: null,
      reachableCount: null,
      shallowSize: it.self_size,
      nativeSize: it.native_size,
      heap: it.heap_type ?? 'default',
      className: fullCls,
      display: makeDisplay(fullCls, it.id),
    });
  }
  return rows;
}

export async function getBitmapList(
  engine: Engine,
  activeDump: HeapDump,
): Promise<BitmapListRow[]> {
  await requireDominatorTree(engine);
  const dumpData = await loadBitmapDumpData(engine, activeDump);

  await engine.query(
    `INCLUDE PERFETTO MODULE android.memory.heap_graph.bitmap;`,
  );
  const res = await engine.query(`
    SELECT
      o.id,
      c.name AS cls,
      c.deobfuscated_name AS deob,
      o.self_size,
      o.native_size,
      o.heap_type,
      o.root_type,
      d.dominated_size_bytes AS dominated_size,
      d.dominated_native_size_bytes AS dominated_native,
      d.dominated_obj_count,
      od.value_string,
      c.kind AS class_kind,
      cast_int!(b.width) AS width,
      cast_int!(b.height) AS height,
      cast_int!(b.density) AS density,
      MAX(CASE WHEN f.field_name GLOB '*mNativePtr' THEN f.long_value END) AS native_ptr,
      b.bitmap_id,
      b.bitmap_storage_type,
      b.source_id,
      cast_int!(b.source_pid) AS source_pid,
      b.source_storage_type,
      b.source_process_name
    FROM heap_graph_object o
    JOIN heap_graph_class c ON o.type_id = c.id
    LEFT JOIN heap_graph_object_data od ON o.object_data_id = od.id
    LEFT JOIN heap_graph_dominator_tree d ON d.id = o.id
    LEFT JOIN heap_graph_primitive f ON f.field_set_id = od.field_set_id
    LEFT JOIN heap_graph_bitmaps b ON b.object_id = o.id
    WHERE o.reachable != 0
      AND ${dumpFilterSql(activeDump, 'o')}
      AND (c.name = 'android.graphics.Bitmap'
        OR c.deobfuscated_name = 'android.graphics.Bitmap')
    GROUP BY o.id
    ORDER BY (ifnull(d.dominated_size_bytes, 0)
      + ifnull(d.dominated_native_size_bytes, 0)) DESC
  `);

  // Collect rows and native pointers for hash lookup.
  const rawRows: Array<{
    row: InstanceRow;
    w: number;
    h: number;
    hasPixelData: boolean;
    density: number;
    nativePtr: bigint | null;
    bitmapId: bigint | null;
    storageType: string | null;
    sourceId: bigint | null;
    sourcePid: number | null;
    sourceStorageType: string | null;
    sourceProcessName: string | null;
  }> = [];
  const hashInputs: Array<{objectId: number; nativePtr: bigint}> = [];
  for (
    const it = res.iter({
      id: NUM,
      cls: STR,
      deob: STR_NULL,
      self_size: NUM,
      native_size: NUM,
      heap_type: STR_NULL,
      root_type: STR_NULL,
      dominated_size: NUM_NULL,
      dominated_native: NUM_NULL,
      dominated_obj_count: NUM_NULL,
      value_string: STR_NULL,
      class_kind: STR,
      width: NUM_NULL,
      height: NUM_NULL,
      density: NUM_NULL,
      native_ptr: LONG_NULL,
      bitmap_id: LONG_NULL,
      bitmap_storage_type: STR_NULL,
      source_id: LONG_NULL,
      source_pid: NUM_NULL,
      source_storage_type: STR_NULL,
      source_process_name: STR_NULL,
    });
    it.valid();
    it.next()
  ) {
    const w = it.width ?? 0;
    const h = it.height ?? 0;
    let hasPixelData = false;
    if (dumpData !== null && it.native_ptr !== null && w > 0 && h > 0) {
      hasPixelData = dumpData.bufferMap.has(it.native_ptr);
      if (hasPixelData) {
        hashInputs.push({objectId: it.id, nativePtr: it.native_ptr});
      }
    }
    rawRows.push({
      row: rowFromIter(it),
      w,
      h,
      hasPixelData,
      density: it.density ?? 0,
      nativePtr: it.native_ptr,
      bitmapId: it.bitmap_id,
      storageType: it.bitmap_storage_type,
      sourceId: it.source_id,
      sourcePid: it.source_pid,
      sourceStorageType: it.source_storage_type,
      sourceProcessName: it.source_process_name,
    });
  }

  // Look up pre-computed content hashes for bitmaps with pixel data.
  const hashes =
    hashInputs.length > 0
      ? await batchBitmapBufferHashes(engine, activeDump, hashInputs)
      : new Map<number, string>();

  const rows: BitmapListRow[] = rawRows.map((r) => ({
    row: r.row,
    width: r.w,
    height: r.h,
    pixelCount: r.w * r.h,
    hasPixelData: r.hasPixelData,
    density: r.density,
    bufferHash: hashes.get(r.row.id) ?? null,
    storageType: r.storageType,
    bitmapId: r.bitmapId,
    sourceId: r.sourceId,
    sourcePid: r.sourcePid,
    sourceStorageType: r.sourceStorageType,
    sourceProcessName: r.sourceProcessName,
  }));
  return rows;
}

function primFieldValue(it: {
  field_type: string;
  bool_value: number | null;
  byte_value: number | null;
  char_value: number | null;
  short_value: number | null;
  int_value: number | null;
  long_value: bigint | null;
  float_value: number | null;
  double_value: number | null;
}): PrimOrRef {
  switch (it.field_type) {
    case 'boolean':
      return {
        kind: 'prim',
        v: it.bool_value !== null && it.bool_value !== 0 ? 'true' : 'false',
      };
    case 'byte':
      return {kind: 'prim', v: String(it.byte_value ?? 0)};
    case 'char': {
      const code = it.char_value ?? 0;
      const ch =
        code >= 32 && code < 127
          ? `'${String.fromCharCode(code)}'`
          : String(code);
      return {kind: 'prim', v: ch};
    }
    case 'short':
      return {kind: 'prim', v: String(it.short_value ?? 0)};
    case 'int':
      return {kind: 'prim', v: String(it.int_value ?? 0)};
    case 'long':
      return {kind: 'prim', v: String(it.long_value ?? 0)};
    case 'float':
      return {kind: 'prim', v: String(it.float_value ?? 0)};
    case 'double':
      return {kind: 'prim', v: String(it.double_value ?? 0)};
    default:
      return {kind: 'prim', v: '???'};
  }
}

//
// The _heap_graph_object_tree_aggregation table computes cumulative reachable
// sizes via a BFS tree.  The table materialisation is expensive on first access,
// so we load it asynchronously and fill in reachable columns after the initial
// render.

/** Fetch reachable (cumulative) sizes for a set of object IDs. */
async function getReachableSizes(
  engine: Engine,
  ids: number[],
): Promise<Map<number, {size: number; native: number; count: number}>> {
  await engine.query(
    `INCLUDE PERFETTO MODULE android.memory.heap_graph.object_tree`,
  );
  if (ids.length === 0) return new Map();
  const res = await engine.query(`
    SELECT id,
      cumulative_size AS size,
      cumulative_native_size AS native_size,
      cumulative_count AS count
    FROM _heap_graph_object_tree_aggregation
    WHERE id IN (${ids.join(',')})
  `);
  const map = new Map<number, {size: number; native: number; count: number}>();
  for (
    const it = res.iter({id: NUM, size: NUM, native_size: NUM, count: NUM});
    it.valid();
    it.next()
  ) {
    map.set(it.id, {size: it.size, native: it.native_size, count: it.count});
  }
  return map;
}

/**
 * Enrich InstanceRow[] with reachable sizes.  Call after initial data load;
 * the caller should trigger a re-render when the returned promise resolves.
 */
export async function enrichWithReachable(
  engine: Engine,
  rows: InstanceRow[],
): Promise<void> {
  const unenriched = rows.filter((r) => r.reachableSize === null);
  if (unenriched.length === 0) return;
  const ids = unenriched.map((r) => r.id);
  const map = await getReachableSizes(engine, ids);
  for (const row of unenriched) {
    const s = map.get(row.id);
    row.reachableSize = s?.size ?? 0;
    row.reachableNative = s?.native ?? 0;
    row.reachableCount = s?.count ?? 0;
  }
}

/**
 * Enrich PrimOrRef fields with reachable sizes (for ref-kind fields only).
 */
export async function enrichFieldsWithReachable(
  engine: Engine,
  fields: {name: string; typeName: string; value: PrimOrRef}[],
): Promise<void> {
  const ids: number[] = [];
  for (const f of fields) {
    if (f.value.kind === 'ref' && f.value.reachableJava === undefined) {
      ids.push(f.value.id);
    }
  }
  if (ids.length === 0) return;
  const map = await getReachableSizes(engine, ids);
  for (const f of fields) {
    if (f.value.kind === 'ref' && f.value.reachableJava === undefined) {
      const s = map.get(f.value.id);
      f.value.reachableJava = s?.size ?? 0;
      f.value.reachableNative = s?.native ?? 0;
      f.value.reachableCount = s?.count ?? 0;
    }
  }
}

/**
 * Enrich array elements with reachable sizes (for ref-kind values only).
 */
export async function enrichArrayElemsWithReachable(
  engine: Engine,
  elems: {idx: number; value: PrimOrRef}[],
): Promise<void> {
  const ids: number[] = [];
  for (const e of elems) {
    if (e.value.kind === 'ref' && e.value.reachableJava === undefined) {
      ids.push(e.value.id);
    }
  }
  if (ids.length === 0) return;
  const map = await getReachableSizes(engine, ids);
  for (const e of elems) {
    if (e.value.kind === 'ref' && e.value.reachableJava === undefined) {
      const s = map.get(e.value.id);
      e.value.reachableJava = s?.size ?? 0;
      e.value.reachableNative = s?.native ?? 0;
      e.value.reachableCount = s?.count ?? 0;
    }
  }
}
