// 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 protos from '../../protos';
import {traceSummarySpecToPb} from '../../base/proto_utils_wasm';
import type {DataExplorerState} from './data_explorer';
import {
  type SerializedGraph,
  type SerializedNode,
  deserializeState,
} from './json_handler';
import {NodeType, singleNodeOperation} from './query_node';
import type {Trace} from '../../public/trace';
import type {SqlModules} from '../dev.perfetto.SqlModules/sql_modules';

// Counter for generating unique node IDs during import.
let importNodeCounter = 0;

function nextImportNodeId(): string {
  return `import_${++importNodeCounter}`;
}

// Layout constants for positioning imported graph nodes.
const NODE_GAP_Y = 80;
const METRIC_CHAIN_GAP_X = 350;
const START_X = 50;
const START_Y = 50;

// Accumulator for building up decompose results incrementally.
interface ResultAccumulator {
  nodes: SerializedNode[];
  rootNodeIds: string[];
  layouts: Map<string, {x: number; y: number}>;
}

// Merges a child result into a parent accumulator.
function mergeResult(
  target: ResultAccumulator,
  source: ResultAccumulator,
): void {
  target.nodes.push(...source.nodes);
  target.rootNodeIds.push(...source.rootNodeIds);
  for (const [id, pos] of source.layouts) {
    target.layouts.set(id, pos);
  }
}

// Appends a new node to a chain: wires the previous node's nextNodes,
// adds the node, sets its layout, and returns the new last node ID.
function chainNode(
  acc: ResultAccumulator,
  lastNodeId: string,
  nodeId: string,
  type: NodeType,
  state: Record<string, unknown>,
  x: number,
  y: number,
): void {
  const prevNode = acc.nodes.find((n) => n.nodeId === lastNodeId);
  if (prevNode !== undefined) {
    prevNode.nextNodes.push(nodeId);
  }
  acc.nodes.push({nodeId, type, state, nextNodes: []});
  // Dockable nodes (modifications, metrics, etc.) must NOT have a layout
  // position — the graph renderer docks them to their parent only when
  // they have no entry in nodeLayouts.
  if (!singleNodeOperation(type)) {
    acc.layouts.set(nodeId, {x, y});
  }
}

/**
 * Parses a pbtxt string (TraceSummarySpec or single metric_template_spec)
 * and converts it into a DataExplorerState with proper graph nodes.
 */
export async function parsePbtxtToState(
  pbtxtText: string,
  trace: Trace,
  sqlModules: SqlModules,
): Promise<DataExplorerState> {
  // Detect if this is a single metric_template_spec (no wrapping)
  // vs a full TraceSummarySpec.
  const wrappedText = detectAndWrapPbtxt(pbtxtText);

  const pbResult = await traceSummarySpecToPb(wrappedText);
  if (!pbResult.ok) {
    throw new Error(`Failed to parse pbtxt: ${pbResult.error}`);
  }

  const spec = protos.TraceSummarySpec.decode(pbResult.value);
  const allTemplateSpecs = spec.metricTemplateSpec ?? [];
  const allMetricSpecs = spec.metricSpec ?? [];

  // Build a map of shared queries from the top-level `query` repeated field.
  const sharedQueries = new Map<string, protos.IPerfettoSqlStructuredQuery>();
  for (const q of spec.query ?? []) {
    if (q.id !== undefined && q.id !== null && q.id !== '') {
      sharedQueries.set(q.id, q);
    }
  }

  if (allTemplateSpecs.length === 0 && allMetricSpecs.length === 0) {
    throw new Error(
      'No metric_template_spec or metric_spec found in the pbtxt file.',
    );
  }

  // Reset the import node counter for each import.
  importNodeCounter = 0;

  const acc: ResultAccumulator = {
    nodes: [],
    rootNodeIds: [],
    layouts: new Map(),
  };

  // Process metric_template_specs.
  for (let i = 0; i < allTemplateSpecs.length; i++) {
    const chainX = START_X + i * METRIC_CHAIN_GAP_X;
    mergeResult(
      acc,
      buildNodesFromMetricConfig(
        {
          query: allTemplateSpecs[i].query,
          metricsState: templateSpecToMetricsState(allTemplateSpecs[i]),
        },
        chainX,
        sharedQueries,
        sqlModules,
      ),
    );
  }

  // Process metric_specs (simpler, single-value metrics).
  const metricSpecOffset = allTemplateSpecs.length;
  for (let i = 0; i < allMetricSpecs.length; i++) {
    const chainX = START_X + (metricSpecOffset + i) * METRIC_CHAIN_GAP_X;
    mergeResult(
      acc,
      buildNodesFromMetricConfig(
        {
          query: allMetricSpecs[i].query,
          metricsState: metricSpecToMetricsState(allMetricSpecs[i]),
        },
        chainX,
        sharedQueries,
        sqlModules,
      ),
    );
  }

  // If there are multiple metrics, create a TraceSummaryNode.
  const metricsNodeIds = acc.nodes
    .filter((n) => n.type === NodeType.kMetrics)
    .map((n) => n.nodeId);

  if (metricsNodeIds.length > 1) {
    const traceSummaryId = nextImportNodeId();
    const maxX = Math.max(...[...acc.layouts.values()].map((p) => p.x));
    const maxY = Math.max(...[...acc.layouts.values()].map((p) => p.y));
    acc.nodes.push({
      nodeId: traceSummaryId,
      type: NodeType.kTraceSummary,
      state: {
        secondaryInputNodeIds: metricsNodeIds,
      },
      nextNodes: [],
    });
    // Add forward links from each Metrics node to the TraceSummary so
    // that deserialization wires up nextNodes (matching JSON export).
    for (const mId of metricsNodeIds) {
      const metricsNode = acc.nodes.find((n) => n.nodeId === mId);
      if (metricsNode !== undefined) {
        metricsNode.nextNodes.push(traceSummaryId);
      }
    }
    acc.layouts.set(traceSummaryId, {
      x: maxX / 2,
      y: maxY + NODE_GAP_Y,
    });
    acc.rootNodeIds.push(traceSummaryId);
  }

  const serializedGraph: SerializedGraph = {
    nodes: acc.nodes,
    rootNodeIds: acc.rootNodeIds,
    nodeLayouts: Object.fromEntries(acc.layouts),
  };

  const json = JSON.stringify(serializedGraph);
  return deserializeState(json, trace, sqlModules);
}

// ============================================================================
// Metric Config → Graph Nodes
// ============================================================================

interface MetricBuildConfig {
  query: protos.IPerfettoSqlStructuredQuery | undefined | null;
  metricsState: Record<string, unknown>;
}

interface BuildResult {
  nodes: SerializedNode[];
  rootNodeIds: string[];
  layouts: Map<string, {x: number; y: number}>;
  /** The final node ID in the chain (the MetricsNode). */
  finalNodeId: string;
}

/**
 * Builds graph nodes from a metric config (either template or simple spec).
 * Creates: source node → [modification nodes] → MetricsNode
 */
function buildNodesFromMetricConfig(
  config: MetricBuildConfig,
  startX: number,
  sharedQueries?: Map<string, protos.IPerfettoSqlStructuredQuery>,
  sqlModules?: SqlModules,
): BuildResult {
  const acc: ResultAccumulator = {
    nodes: [],
    rootNodeIds: [],
    layouts: new Map(),
  };
  let currentY = START_Y;

  let lastNodeId: string;
  if (config.query !== undefined && config.query !== null) {
    const queryResult = decomposeStructuredQuery(
      config.query,
      startX,
      currentY,
      sharedQueries,
      sqlModules,
    );
    mergeResult(acc, queryResult);
    lastNodeId = queryResult.finalNodeId;
    currentY = queryResult.nextY;
  } else {
    const sqlId = nextImportNodeId();
    acc.nodes.push({
      nodeId: sqlId,
      type: NodeType.kSqlSource,
      state: {sql: '-- No query specified in pbtxt'},
      nextNodes: [],
    });
    acc.rootNodeIds.push(sqlId);
    acc.layouts.set(sqlId, {x: startX, y: currentY});
    lastNodeId = sqlId;
    currentY += NODE_GAP_Y;
  }

  const metricsId = nextImportNodeId();
  const metricsState = {...config.metricsState, primaryInputId: lastNodeId};
  chainNode(
    acc,
    lastNodeId,
    metricsId,
    NodeType.kMetrics,
    metricsState,
    startX,
    currentY,
  );

  return {...acc, finalNodeId: metricsId};
}

/**
 * Builds graph nodes from a TraceMetricV2TemplateSpec.
 * Creates: source node → [modification nodes] → MetricsNode
 */
export function buildNodesFromTemplateSpec(
  spec: protos.ITraceMetricV2TemplateSpec,
  startX: number,
  sharedQueries?: Map<string, protos.IPerfettoSqlStructuredQuery>,
  sqlModules?: SqlModules,
): BuildResult {
  importNodeCounter = 0;
  return buildNodesFromMetricConfig(
    {query: spec.query, metricsState: templateSpecToMetricsState(spec)},
    startX,
    sharedQueries,
    sqlModules,
  );
}

// ============================================================================
// Generic Multi-Source Decomposition
// ============================================================================

export interface DecomposeResult {
  nodes: SerializedNode[];
  rootNodeIds: string[];
  layouts: Map<string, {x: number; y: number}>;
  finalNodeId: string;
  nextY: number;
}

// Describes one sub-query input to decompose.
interface SubQueryInput {
  query: protos.IPerfettoSqlStructuredQuery | undefined | null;
  xOffset: number; // relative to startX
}

// Configuration for decomposeMultiSource.
interface MultiSourceSpec {
  inputs: SubQueryInput[];
  nodeType: NodeType;
  buildState: (
    inputNodeIds: Array<string | undefined>,
  ) => Record<string, unknown>;
  // Override the output node's X position. Defaults to startX + GAP/2.
  layoutX?: number;
  // If true, omit layout entry (for dockable nodes like FilterDuring).
  skipLayout?: boolean;
}

// Node types that need the inputNodeIds field on SerializedNode.
function needsInputNodeIds(type: NodeType): boolean {
  return type === NodeType.kIntervalIntersect || type === NodeType.kUnion;
}

/**
 * Generic helper that decomposes N sub-queries and creates a single output
 * node wired to all of them. Replaces all the individual decompose*
 * functions that previously duplicated this pattern.
 */
function decomposeMultiSource(
  spec: MultiSourceSpec,
  startX: number,
  startY: number,
  sharedQueries?: Map<string, protos.IPerfettoSqlStructuredQuery>,
  sqlModules?: SqlModules,
): DecomposeResult {
  const acc: ResultAccumulator = {
    nodes: [],
    rootNodeIds: [],
    layouts: new Map(),
  };
  let maxY = startY;
  const resolvedIds: Array<string | undefined> = [];

  for (const input of spec.inputs) {
    if (input.query !== undefined && input.query !== null) {
      const r = decomposeStructuredQuery(
        input.query,
        startX + input.xOffset,
        startY,
        sharedQueries,
        sqlModules,
      );
      mergeResult(acc, r);
      resolvedIds.push(r.finalNodeId);
      maxY = Math.max(maxY, r.nextY);
    } else {
      resolvedIds.push(undefined);
    }
  }

  const nodeId = nextImportNodeId();
  const state = spec.buildState(resolvedIds);
  const definedIds = resolvedIds.filter((id): id is string => id !== undefined);

  acc.nodes.push({
    nodeId,
    type: spec.nodeType,
    state,
    nextNodes: [],
    ...(needsInputNodeIds(spec.nodeType) ? {inputNodeIds: definedIds} : {}),
  });

  // Wire input nodes' nextNodes to point to this node.
  for (const id of definedIds) {
    const node = acc.nodes.find((n) => n.nodeId === id);
    if (node !== undefined) node.nextNodes.push(nodeId);
  }

  if (spec.skipLayout !== true) {
    const x = spec.layoutX ?? startX + METRIC_CHAIN_GAP_X / 2;
    acc.layouts.set(nodeId, {x, y: maxY});
  }

  acc.rootNodeIds.push(nodeId);
  return {...acc, finalNodeId: nodeId, nextY: maxY + NODE_GAP_Y};
}

// ============================================================================
// Structured Query Decomposition
// ============================================================================

/**
 * Recursively decomposes a PerfettoSqlStructuredQuery into graph nodes.
 * Returns source nodes at the root and modification nodes chained on top.
 */
export function decomposeStructuredQuery(
  sq: protos.IPerfettoSqlStructuredQuery,
  startX: number,
  startY: number,
  sharedQueries?: Map<string, protos.IPerfettoSqlStructuredQuery>,
  sqlModules?: SqlModules,
): DecomposeResult {
  const acc: ResultAccumulator = {
    nodes: [],
    rootNodeIds: [],
    layouts: new Map(),
  };
  let currentY = startY;

  // Helper to add a leaf source node (no children to decompose).
  function addSourceNode(
    type: NodeType,
    state: Record<string, unknown>,
  ): string {
    const id = nextImportNodeId();
    acc.nodes.push({nodeId: id, type, state, nextNodes: []});
    acc.rootNodeIds.push(id);
    acc.layouts.set(id, {x: startX, y: currentY});
    currentY += NODE_GAP_Y;
    return id;
  }

  // Helper to merge a recursive decomposition result and advance currentY.
  function mergeSubQuery(subResult: DecomposeResult): string {
    mergeResult(acc, subResult);
    currentY = subResult.nextY;
    return subResult.finalNodeId;
  }

  // Common args for multi-source decomposition.
  const msArgs = [startX, startY, sharedQueries, sqlModules] as const;

  // 1. Process the source.
  let sourceNodeId: string;

  if (sq.table !== undefined && sq.table !== null) {
    const tableName = sq.table.tableName || undefined;
    const moduleName = sq.table.moduleName || undefined;

    // The SimpleSlices source node uses thread_or_process_slice internally.
    // Map it back to a kSimpleSlices node for a faithful round-trip.
    // The module may appear in moduleName or in referencedModules.
    const allModules = [moduleName, ...(sq.referencedModules ?? [])].filter(
      (m) => m !== undefined && m !== '',
    );
    if (
      tableName === 'thread_or_process_slice' &&
      allModules.includes('slices.with_context')
    ) {
      sourceNodeId = addSourceNode(NodeType.kSimpleSlices, {});
    } else if (
      sqlModules === undefined ||
      (tableName !== undefined && sqlModules.getTable(tableName) !== undefined)
    ) {
      sourceNodeId = addSourceNode(NodeType.kTable, {
        sqlTable: tableName,
        moduleName,
      });
    } else {
      const parts: string[] = [];
      if (moduleName !== undefined && moduleName !== '') {
        parts.push(`INCLUDE PERFETTO MODULE ${moduleName};`);
      }
      parts.push(`SELECT * FROM ${tableName ?? 'unknown_table'}`);
      sourceNodeId = addSourceNode(NodeType.kSqlSource, {
        sql: parts.join('\n'),
      });
    }
  } else if (sq.simpleSlices !== undefined && sq.simpleSlices !== null) {
    sourceNodeId = addSourceNode(NodeType.kSimpleSlices, {});

    // SimpleSlices glob fields become GLOB filters.
    const globFilters = buildSimpleSlicesFilters(sq.simpleSlices);
    if (globFilters.length > 0) {
      const filterId = nextImportNodeId();
      const filterState: Record<string, unknown> = {
        primaryInputId: sourceNodeId,
        filters: globFilters,
      };
      chainNode(
        acc,
        sourceNodeId,
        filterId,
        NodeType.kFilter,
        filterState,
        startX,
        currentY,
      );
      sourceNodeId = filterId;
      currentY += NODE_GAP_Y;
    }
  } else if (sq.sql !== undefined && sq.sql !== null) {
    sourceNodeId = addSourceNode(NodeType.kSqlSource, {sql: sq.sql.sql ?? ''});
  } else if (sq.innerQuery !== undefined && sq.innerQuery !== null) {
    sourceNodeId = mergeSubQuery(
      decomposeStructuredQuery(
        sq.innerQuery,
        startX,
        currentY,
        sharedQueries,
        sqlModules,
      ),
    );
  } else if (
    sq.experimentalTimeRange !== undefined &&
    sq.experimentalTimeRange !== null
  ) {
    sourceNodeId = addSourceNode(NodeType.kTimeRangeSource, {
      start: sq.experimentalTimeRange.ts?.toString(),
      end: undefined,
      isDynamic: false,
    });
  } else if (
    sq.intervalIntersect !== undefined &&
    sq.intervalIntersect !== null
  ) {
    const ii = sq.intervalIntersect;
    const secondaries = ii.intervalIntersect ?? [];
    sourceNodeId = mergeSubQuery(
      decomposeMultiSource(
        {
          inputs: [
            {query: ii.base, xOffset: 0},
            ...secondaries.map((q, i) => ({
              query: q,
              xOffset: (i + 1) * METRIC_CHAIN_GAP_X,
            })),
          ],
          nodeType: NodeType.kIntervalIntersect,
          buildState: (ids) => ({
            inputNodeIds: ids.filter((id): id is string => id !== undefined),
          }),
        },
        ...msArgs,
      ),
    );
  } else if (
    sq.experimentalJoin !== undefined &&
    sq.experimentalJoin !== null
  ) {
    sourceNodeId = mergeSubQuery(decomposeJoin(sq.experimentalJoin, ...msArgs));
  } else if (
    sq.experimentalUnion !== undefined &&
    sq.experimentalUnion !== null
  ) {
    const queries = sq.experimentalUnion.queries ?? [];
    sourceNodeId = mergeSubQuery(
      decomposeMultiSource(
        {
          inputs: queries.map((q, i) => ({
            query: q,
            xOffset: i * METRIC_CHAIN_GAP_X,
          })),
          nodeType: NodeType.kUnion,
          buildState: (ids) => ({
            unionNodes: ids.filter((id): id is string => id !== undefined),
            selectedColumns: [],
          }),
          layoutX:
            queries.length > 1
              ? startX + ((queries.length - 1) * METRIC_CHAIN_GAP_X) / 2
              : startX,
        },
        ...msArgs,
      ),
    );
  } else if (
    sq.experimentalAddColumns !== undefined &&
    sq.experimentalAddColumns !== null
  ) {
    sourceNodeId = mergeSubQuery(
      decomposeAddColumns(sq.experimentalAddColumns, ...msArgs),
    );
  } else if (
    sq.experimentalFilterToIntervals !== undefined &&
    sq.experimentalFilterToIntervals !== null
  ) {
    const fti = sq.experimentalFilterToIntervals;
    sourceNodeId = mergeSubQuery(
      decomposeMultiSource(
        {
          inputs: [
            {query: fti.base, xOffset: 0},
            {query: fti.intervals, xOffset: METRIC_CHAIN_GAP_X},
          ],
          nodeType: NodeType.kFilterDuring,
          buildState: ([primaryInputId, intervalsId]) => ({
            primaryInputId,
            secondaryInputNodeIds:
              intervalsId !== undefined ? [intervalsId] : [],
            clipToIntervals: fti.clipToIntervals ?? true,
          }),
          skipLayout: true,
        },
        ...msArgs,
      ),
    );
  } else if (
    sq.experimentalCreateSlices !== undefined &&
    sq.experimentalCreateSlices !== null
  ) {
    const cs = sq.experimentalCreateSlices;
    sourceNodeId = mergeSubQuery(
      decomposeMultiSource(
        {
          inputs: [
            {query: cs.startsQuery, xOffset: 0},
            {query: cs.endsQuery, xOffset: METRIC_CHAIN_GAP_X},
          ],
          nodeType: NodeType.kCreateSlices,
          buildState: ([startsNodeId, endsNodeId]) => ({
            startsNodeId,
            endsNodeId,
            startsTsColumn: cs.startsTsColumn ?? 'ts',
            endsTsColumn: cs.endsTsColumn ?? 'ts',
          }),
        },
        ...msArgs,
      ),
    );
  } else if (
    sq.experimentalCounterIntervals !== undefined &&
    sq.experimentalCounterIntervals !== null
  ) {
    const ci = sq.experimentalCounterIntervals;
    sourceNodeId = mergeSubQuery(
      decomposeMultiSource(
        {
          inputs: [{query: ci.inputQuery, xOffset: 0}],
          nodeType: NodeType.kCounterToIntervals,
          buildState: ([primaryInputId]) => ({primaryInputId}),
          layoutX: startX,
        },
        ...msArgs,
      ),
    );
  } else if (
    sq.experimentalFilterIn !== undefined &&
    sq.experimentalFilterIn !== null
  ) {
    const fi = sq.experimentalFilterIn;
    sourceNodeId = mergeSubQuery(
      decomposeMultiSource(
        {
          inputs: [
            {query: fi.base, xOffset: 0},
            {query: fi.matchValues, xOffset: METRIC_CHAIN_GAP_X},
          ],
          nodeType: NodeType.kFilterIn,
          buildState: ([primaryInputId, matchId]) => ({
            primaryInputId,
            secondaryInputNodeIds: matchId !== undefined ? [matchId] : [],
            baseColumn: fi.baseColumn ?? '',
            matchColumn: fi.matchColumn ?? '',
          }),
        },
        ...msArgs,
      ),
    );
  } else if (
    sq.innerQueryId !== undefined &&
    sq.innerQueryId !== null &&
    sq.innerQueryId !== ''
  ) {
    const sharedQuery = sharedQueries?.get(sq.innerQueryId);
    if (sharedQuery !== undefined) {
      sourceNodeId = mergeSubQuery(
        decomposeStructuredQuery(
          sharedQuery,
          startX,
          currentY,
          sharedQueries,
          sqlModules,
        ),
      );
    } else {
      sourceNodeId = addSourceNode(NodeType.kSqlSource, {
        sql: '-- Imported from pbtxt (unsupported source type)',
      });
    }
  } else {
    sourceNodeId = addSourceNode(NodeType.kSqlSource, {
      sql: '-- Imported from pbtxt (unsupported source type)',
    });
  }

  // 2. Apply operations as modification nodes.
  let lastNodeId = sourceNodeId;

  // Helper to chain a modification node onto the current chain.
  function chainModification(
    type: NodeType,
    state: Record<string, unknown>,
  ): void {
    const id = nextImportNodeId();
    state.primaryInputId = lastNodeId;
    chainNode(acc, lastNodeId, id, type, state, startX, currentY);
    lastNodeId = id;
    currentY += NODE_GAP_Y;
  }

  // Filters. Each proto filter may have multiple RHS values (IN semantics).
  // Filters with single values are grouped into one AND FilterNode.
  // Filters with multiple values each get their own OR FilterNode (chained
  // with AND between them).
  const filters = sq.filters ?? [];
  if (filters.length > 0) {
    const singleValueFilters: protos.PerfettoSqlStructuredQuery.IFilter[] = [];
    const multiValueFilters: protos.PerfettoSqlStructuredQuery.IFilter[] = [];
    for (const f of filters) {
      if (protoFilterRhsCount(f) > 1) {
        multiValueFilters.push(f);
      } else {
        singleValueFilters.push(f);
      }
    }

    if (singleValueFilters.length > 0) {
      chainModification(
        NodeType.kFilter,
        protoFiltersToFilterState(singleValueFilters),
      );
    }

    for (const f of multiValueFilters) {
      chainModification(NodeType.kFilter, protoFilterToExpandedOrState(f));
    }
  }

  // Group by / aggregation.
  if (sq.groupBy !== undefined && sq.groupBy !== null) {
    chainModification(
      NodeType.kAggregation,
      protoGroupByToAggregationState(sq.groupBy),
    );
  }

  // Select columns (modify columns).
  // Skip if the select just re-lists the group_by output (redundant).
  const selectCols = sq.selectColumns ?? [];
  if (selectCols.length > 0 && !isSelectRedundantAfterGroupBy(sq)) {
    chainModification(
      NodeType.kModifyColumns,
      protoSelectColumnsToModifyState(selectCols),
    );
  }

  // Order by (sort).
  if (sq.orderBy !== undefined && sq.orderBy !== null) {
    chainModification(NodeType.kSort, protoOrderByToSortState(sq.orderBy));
  }

  // Limit / offset. Protobufjs with --force-number sets unset numeric
  // fields to 0, so treat 0 as "unset" to avoid creating spurious nodes.
  const limitVal =
    sq.limit !== undefined && sq.limit !== null ? Number(sq.limit) : 0;
  const offsetVal =
    sq.offset !== undefined && sq.offset !== null ? Number(sq.offset) : 0;
  if (limitVal > 0 || offsetVal > 0) {
    const limitState: Record<string, unknown> = {};
    if (limitVal > 0) {
      limitState.limit = limitVal;
    }
    if (offsetVal > 0) {
      limitState.offset = offsetVal;
    }
    chainModification(NodeType.kLimitAndOffset, limitState);
  }

  // Experimental filter group.
  if (
    sq.experimentalFilterGroup !== undefined &&
    sq.experimentalFilterGroup !== null
  ) {
    const filterGroupResult = decomposeFilterGroup(
      sq.experimentalFilterGroup,
      lastNodeId,
    );
    for (const filterNode of filterGroupResult) {
      const prevNode = acc.nodes.find((n) => n.nodeId === lastNodeId);
      if (prevNode !== undefined) {
        prevNode.nextNodes.push(filterNode.nodeId);
      }
      acc.nodes.push(filterNode);
      // Don't set layout for dockable nodes — they dock to their parent.
      if (!singleNodeOperation(filterNode.type)) {
        acc.layouts.set(filterNode.nodeId, {x: startX, y: currentY});
      }
      lastNodeId = filterNode.nodeId;
      currentY += NODE_GAP_Y;
    }
  }

  return {...acc, finalNodeId: lastNodeId, nextY: currentY};
}

// ============================================================================
// Join Decomposition (kept as named function due to state complexity)
// ============================================================================

function decomposeJoin(
  join: protos.PerfettoSqlStructuredQuery.IExperimentalJoin,
  startX: number,
  startY: number,
  sharedQueries?: Map<string, protos.IPerfettoSqlStructuredQuery>,
  sqlModules?: SqlModules,
): DecomposeResult {
  let joinType = 'INNER';
  if (
    join.type === protos.PerfettoSqlStructuredQuery.ExperimentalJoin.Type.LEFT
  ) {
    joinType = 'LEFT';
  }

  let conditionType: 'equality' | 'freeform' = 'equality';
  let leftColumn = '';
  let rightColumn = '';
  let sqlExpression = '';
  if (join.equalityColumns !== undefined && join.equalityColumns !== null) {
    leftColumn = join.equalityColumns.leftColumn ?? '';
    rightColumn = join.equalityColumns.rightColumn ?? '';
  } else if (
    join.freeformCondition !== undefined &&
    join.freeformCondition !== null
  ) {
    conditionType = 'freeform';
    sqlExpression = join.freeformCondition.sqlExpression ?? '';
  }

  return decomposeMultiSource(
    {
      inputs: [
        {query: join.leftQuery, xOffset: 0},
        {query: join.rightQuery, xOffset: METRIC_CHAIN_GAP_X},
      ],
      nodeType: NodeType.kJoin,
      buildState: ([leftNodeId, rightNodeId]) => ({
        leftNodeId,
        rightNodeId,
        leftQueryAlias: 'left',
        rightQueryAlias: 'right',
        conditionType,
        joinType,
        leftColumn,
        rightColumn,
        sqlExpression,
      }),
    },
    startX,
    startY,
    sharedQueries,
    sqlModules,
  );
}

// ============================================================================
// AddColumns Decomposition (kept as named function due to state complexity)
// ============================================================================

function decomposeAddColumns(
  addCols: protos.PerfettoSqlStructuredQuery.IExperimentalAddColumns,
  startX: number,
  startY: number,
  sharedQueries?: Map<string, protos.IPerfettoSqlStructuredQuery>,
  sqlModules?: SqlModules,
): DecomposeResult {
  let leftColumn = '';
  let rightColumn = '';
  if (
    addCols.equalityColumns !== undefined &&
    addCols.equalityColumns !== null
  ) {
    leftColumn = addCols.equalityColumns.leftColumn ?? '';
    rightColumn = addCols.equalityColumns.rightColumn ?? '';
  }
  const selectedColumns = (addCols.inputColumns ?? []).map(
    (sc) => sc.columnNameOrExpression ?? sc.columnName ?? sc.alias ?? '',
  );

  return decomposeMultiSource(
    {
      inputs: [
        {query: addCols.coreQuery, xOffset: 0},
        {query: addCols.inputQuery, xOffset: METRIC_CHAIN_GAP_X},
      ],
      nodeType: NodeType.kAddColumns,
      buildState: ([primaryInputId, secondaryInputNodeId]) => ({
        primaryInputId,
        secondaryInputNodeId,
        selectedColumns,
        leftColumn,
        rightColumn,
      }),
    },
    startX,
    startY,
    sharedQueries,
    sqlModules,
  );
}

// ============================================================================
// ExperimentalFilterGroup Decomposition
// ============================================================================

/**
 * Checks whether an ExperimentalFilterGroup is "simple" — meaning it contains
 * only `filters` (no nested `groups` and no `sql_expressions`).
 */
function isSimpleFilterGroup(
  group: protos.PerfettoSqlStructuredQuery.IExperimentalFilterGroup,
): boolean {
  const groups = group.groups ?? [];
  const sqlExprs = group.sqlExpressions ?? [];
  const filters = group.filters ?? [];
  return filters.length > 0 && groups.length === 0 && sqlExprs.length === 0;
}

/**
 * Checks whether an ExperimentalFilterGroup contains only nested `groups`
 * (no direct `filters` and no `sql_expressions`).
 */
function isGroupsOnlyFilterGroup(
  group: protos.PerfettoSqlStructuredQuery.IExperimentalFilterGroup,
): boolean {
  const groups = group.groups ?? [];
  const sqlExprs = group.sqlExpressions ?? [];
  const filters = group.filters ?? [];
  return groups.length > 0 && filters.length === 0 && sqlExprs.length === 0;
}

/**
 * Converts a single proto Filter to a SQL expression string.
 */
function protoFilterToSql(
  f: protos.PerfettoSqlStructuredQuery.IFilter,
): string {
  const op = FILTER_OP_MAP[f.op ?? 0] ?? '=';
  const column = f.columnName ?? '';

  // IS NULL / IS NOT NULL don't need a RHS value.
  if (op === 'IS NULL' || op === 'IS NOT NULL') {
    return `${column} ${op}`;
  }

  // Extract value.
  const stringRhs = f.stringRhs ?? [];
  const doubleRhs = f.doubleRhs ?? [];
  const int64Rhs = f.int64Rhs ?? [];
  if (stringRhs.length > 0) {
    // Quote string values.
    return `${column} ${op} '${stringRhs[0]}'`;
  } else if (doubleRhs.length > 0) {
    return `${column} ${op} ${doubleRhs[0]}`;
  } else if (int64Rhs.length > 0) {
    return `${column} ${op} ${int64Rhs[0]}`;
  }
  return `${column} ${op} ''`;
}

/**
 * Recursively converts an ExperimentalFilterGroup into a SQL WHERE expression.
 */
function filterGroupToSql(
  group: protos.PerfettoSqlStructuredQuery.IExperimentalFilterGroup,
): string {
  const opEnum =
    protos.PerfettoSqlStructuredQuery.ExperimentalFilterGroup.Operator;
  const joiner = group.op === opEnum.OR ? ' OR ' : ' AND ';

  const parts: string[] = [];

  // Convert each filter to SQL.
  for (const f of group.filters ?? []) {
    parts.push(protoFilterToSql(f));
  }

  // Add raw SQL expressions.
  for (const expr of group.sqlExpressions ?? []) {
    parts.push(expr);
  }

  // Recurse into nested groups, wrapping each in parentheses.
  for (const subGroup of group.groups ?? []) {
    const subSql = filterGroupToSql(subGroup);
    if (subSql.length > 0) {
      parts.push(`(${subSql})`);
    }
  }

  return parts.join(joiner);
}

/**
 * Decomposes an ExperimentalFilterGroup into one or more SerializedNode[]
 * (FilterNodes) to chain onto the graph.
 *
 * Strategy:
 * 1. Simple flat AND/OR of filters only → structured FilterNode
 * 2. AND of sub-groups only (no direct filters/sql) → one FilterNode per
 *    sub-group (each sub-group that is simple becomes structured; complex
 *    ones become freeform)
 * 3. Everything else → single freeform FilterNode with the full SQL expression
 */
function decomposeFilterGroup(
  group: protos.PerfettoSqlStructuredQuery.IExperimentalFilterGroup,
  primaryInputId: string,
): SerializedNode[] {
  const opEnum =
    protos.PerfettoSqlStructuredQuery.ExperimentalFilterGroup.Operator;

  // Case 1: Simple flat group — only filters, no groups, no sql_expressions.
  if (isSimpleFilterGroup(group)) {
    const filterState = protoFiltersToFilterState(group.filters ?? []);
    filterState.primaryInputId = primaryInputId;
    if (group.op === opEnum.OR) {
      filterState.filterOperator = 'OR';
    }

    const nodeId = nextImportNodeId();
    return [
      {
        nodeId,
        type: NodeType.kFilter,
        state: filterState,
        nextNodes: [],
      },
    ];
  }

  // Case 2: AND of sub-groups only — decompose each sub-group into its own
  // FilterNode (chained sequentially, giving implicit AND).
  if (group.op === opEnum.AND && isGroupsOnlyFilterGroup(group)) {
    const result: SerializedNode[] = [];
    let currentInputId = primaryInputId;
    for (const subGroup of group.groups ?? []) {
      const subNodes = decomposeFilterGroup(subGroup, currentInputId);
      result.push(...subNodes);
      // The last node from the sub-group becomes the input for the next.
      if (subNodes.length > 0) {
        currentInputId = subNodes[subNodes.length - 1].nodeId;
      }
    }
    return result;
  }

  // Case 3: Complex / mixed — fall back to freeform SQL.
  const sql = filterGroupToSql(group);
  if (sql.length === 0) {
    return [];
  }

  const nodeId = nextImportNodeId();
  return [
    {
      nodeId,
      type: NodeType.kFilter,
      state: {
        primaryInputId,
        filterMode: 'freeform',
        sqlExpression: sql,
      },
      nextNodes: [],
    },
  ];
}

// ============================================================================
// Proto → Node State Conversion Helpers
// ============================================================================

const FILTER_OP_MAP: Record<number, string> = {
  [protos.PerfettoSqlStructuredQuery.Filter.Operator.EQUAL]: '=',
  [protos.PerfettoSqlStructuredQuery.Filter.Operator.NOT_EQUAL]: '!=',
  [protos.PerfettoSqlStructuredQuery.Filter.Operator.LESS_THAN]: '<',
  [protos.PerfettoSqlStructuredQuery.Filter.Operator.LESS_THAN_EQUAL]: '<=',
  [protos.PerfettoSqlStructuredQuery.Filter.Operator.GREATER_THAN]: '>',
  [protos.PerfettoSqlStructuredQuery.Filter.Operator.GREATER_THAN_EQUAL]: '>=',
  [protos.PerfettoSqlStructuredQuery.Filter.Operator.IS_NULL]: 'IS NULL',
  [protos.PerfettoSqlStructuredQuery.Filter.Operator.IS_NOT_NULL]:
    'IS NOT NULL',
  [protos.PerfettoSqlStructuredQuery.Filter.Operator.GLOB]: 'GLOB',
};

/** Returns the total number of RHS values across all RHS fields. */
function protoFilterRhsCount(
  f: protos.PerfettoSqlStructuredQuery.IFilter,
): number {
  return (
    (f.stringRhs ?? []).length +
    (f.doubleRhs ?? []).length +
    (f.int64Rhs ?? []).length
  );
}

/** Extracts the first RHS value as a string. */
function protoFilterFirstRhsValue(
  f: protos.PerfettoSqlStructuredQuery.IFilter,
): string {
  const stringRhs = f.stringRhs ?? [];
  const doubleRhs = f.doubleRhs ?? [];
  const int64Rhs = f.int64Rhs ?? [];
  if (stringRhs.length > 0) return stringRhs[0];
  if (doubleRhs.length > 0) return String(doubleRhs[0]);
  if (int64Rhs.length > 0) return String(int64Rhs[0]);
  return '';
}

/** Extracts ALL RHS values as strings. */
function protoFilterAllRhsValues(
  f: protos.PerfettoSqlStructuredQuery.IFilter,
): string[] {
  const values: string[] = [];
  for (const v of f.stringRhs ?? []) values.push(v);
  for (const v of f.doubleRhs ?? []) values.push(String(v));
  for (const v of f.int64Rhs ?? []) values.push(String(v));
  return values;
}

function protoFiltersToFilterState(
  filters: protos.PerfettoSqlStructuredQuery.IFilter[],
): Record<string, unknown> {
  const uiFilters = filters.map((f) => {
    const op = FILTER_OP_MAP[f.op ?? 0] ?? '=';
    const column = f.columnName ?? '';
    const value = protoFilterFirstRhsValue(f);
    return {column, op, value, enabled: true};
  });

  return {filters: uiFilters};
}

/**
 * Expands a single proto filter with multiple RHS values into an OR
 * FilterNode state. Each RHS value becomes a separate filter entry.
 */
function protoFilterToExpandedOrState(
  f: protos.PerfettoSqlStructuredQuery.IFilter,
): Record<string, unknown> {
  const op = FILTER_OP_MAP[f.op ?? 0] ?? '=';
  const column = f.columnName ?? '';
  const values = protoFilterAllRhsValues(f);

  const uiFilters = values.map((value) => ({
    column,
    op,
    value,
    enabled: true,
  }));

  return {filters: uiFilters, filterOperator: 'OR'};
}

const AGG_OP_MAP: Record<number, string> = {
  [protos.PerfettoSqlStructuredQuery.GroupBy.Aggregate.Op.COUNT]: 'COUNT',
  [protos.PerfettoSqlStructuredQuery.GroupBy.Aggregate.Op.SUM]: 'SUM',
  [protos.PerfettoSqlStructuredQuery.GroupBy.Aggregate.Op.MIN]: 'MIN',
  [protos.PerfettoSqlStructuredQuery.GroupBy.Aggregate.Op.MAX]: 'MAX',
  [protos.PerfettoSqlStructuredQuery.GroupBy.Aggregate.Op.MEAN]: 'MEAN',
  [protos.PerfettoSqlStructuredQuery.GroupBy.Aggregate.Op.MEDIAN]: 'MEDIAN',
  [protos.PerfettoSqlStructuredQuery.GroupBy.Aggregate.Op
    .DURATION_WEIGHTED_MEAN]: 'DURATION_WEIGHTED_MEAN',
  [protos.PerfettoSqlStructuredQuery.GroupBy.Aggregate.Op.COUNT_DISTINCT]:
    'COUNT_DISTINCT',
  [protos.PerfettoSqlStructuredQuery.GroupBy.Aggregate.Op.PERCENTILE]:
    'PERCENTILE',
  [protos.PerfettoSqlStructuredQuery.GroupBy.Aggregate.Op.CUSTOM]: 'CUSTOM',
};

function protoGroupByToAggregationState(
  groupBy: protos.PerfettoSqlStructuredQuery.IGroupBy,
): Record<string, unknown> {
  const groupByColumns = (groupBy.columnNames ?? []).map((name) => ({
    name,
    checked: true,
  }));

  const aggregations = (groupBy.aggregates ?? []).map((agg) => ({
    column: agg.columnName !== undefined ? {name: agg.columnName} : undefined,
    aggregationOp: AGG_OP_MAP[agg.op ?? 0] ?? 'COUNT',
    newColumnName: agg.resultColumnName || undefined,
    percentile: agg.percentile ?? undefined,
    isValid: true,
  }));

  return {groupByColumns, aggregations};
}

// Returns true when select_columns just re-lists the group_by output columns
// without aliases. In that case the ModifyColumns node would be a no-op.
function isSelectRedundantAfterGroupBy(
  sq: protos.IPerfettoSqlStructuredQuery,
): boolean {
  if (sq.groupBy === undefined || sq.groupBy === null) return false;
  const selectCols = sq.selectColumns ?? [];
  if (selectCols.length === 0) return false;

  // Collect the column names the group_by produces.
  const groupByCols = new Set(sq.groupBy.columnNames ?? []);
  for (const agg of sq.groupBy.aggregates ?? []) {
    if (agg.resultColumnName) groupByCols.add(agg.resultColumnName);
  }

  // If any select column has an alias or expression, it's not redundant.
  for (const sc of selectCols) {
    const name = sc.columnName ?? sc.columnNameOrExpression ?? '';
    if (sc.alias) return false;
    if (!groupByCols.has(name)) return false;
  }
  return selectCols.length === groupByCols.size;
}

function protoSelectColumnsToModifyState(
  selectColumns: protos.PerfettoSqlStructuredQuery.ISelectColumn[],
): Record<string, unknown> {
  const selectedColumns = selectColumns.map((sc) => ({
    name: sc.columnNameOrExpression ?? sc.columnName ?? sc.alias ?? '',
    checked: true,
    alias: sc.alias || undefined,
  }));

  return {selectedColumns};
}

function protoOrderByToSortState(
  orderBy: protos.PerfettoSqlStructuredQuery.IOrderBy,
): Record<string, unknown> {
  const sortCriteria = (orderBy.orderingSpecs ?? []).map((spec) => ({
    colName: spec.columnName ?? '',
    direction:
      spec.direction ===
      protos.PerfettoSqlStructuredQuery.OrderBy.Direction.DESC
        ? 'DESC'
        : 'ASC',
  }));

  return {sortCriteria};
}

// ============================================================================
// Metric Spec → MetricsNode State Conversion
// ============================================================================

const UNIT_ENUM_TO_STRING: Record<number, string> = {
  [protos.TraceMetricV2Spec.MetricUnit.COUNT]: 'COUNT',
  [protos.TraceMetricV2Spec.MetricUnit.TIME_NANOS]: 'TIME_NANOS',
  [protos.TraceMetricV2Spec.MetricUnit.TIME_MICROS]: 'TIME_MICROS',
  [protos.TraceMetricV2Spec.MetricUnit.TIME_MILLIS]: 'TIME_MILLIS',
  [protos.TraceMetricV2Spec.MetricUnit.TIME_SECONDS]: 'TIME_SECONDS',
  [protos.TraceMetricV2Spec.MetricUnit.BYTES]: 'BYTES',
  [protos.TraceMetricV2Spec.MetricUnit.KILOBYTES]: 'KILOBYTES',
  [protos.TraceMetricV2Spec.MetricUnit.MEGABYTES]: 'MEGABYTES',
  [protos.TraceMetricV2Spec.MetricUnit.PERCENTAGE]: 'PERCENTAGE',
  [protos.TraceMetricV2Spec.MetricUnit.BOUNDED_PERCENTAGE]:
    'BOUNDED_PERCENTAGE',
  [protos.TraceMetricV2Spec.MetricUnit.MILLI_AMPS]: 'MILLI_AMPS',
  [protos.TraceMetricV2Spec.MetricUnit.MILLI_WATTS]: 'MILLI_WATTS',
  [protos.TraceMetricV2Spec.MetricUnit.MILLI_WATT_HOURS]: 'MILLI_WATT_HOURS',
  [protos.TraceMetricV2Spec.MetricUnit.MILLI_AMP_HOURS]: 'MILLI_AMP_HOURS',
  [protos.TraceMetricV2Spec.MetricUnit.CELSIUS]: 'CELSIUS',
  [protos.TraceMetricV2Spec.MetricUnit.MILLI_VOLTS]: 'MILLI_VOLTS',
};

const POLARITY_ENUM_TO_STRING: Record<number, string> = {
  [protos.TraceMetricV2Spec.MetricPolarity.NOT_APPLICABLE]: 'NOT_APPLICABLE',
  [protos.TraceMetricV2Spec.MetricPolarity.HIGHER_IS_BETTER]:
    'HIGHER_IS_BETTER',
  [protos.TraceMetricV2Spec.MetricPolarity.LOWER_IS_BETTER]: 'LOWER_IS_BETTER',
};

function resolveUnit(spec: {
  unit?: protos.TraceMetricV2Spec.MetricUnit | null;
  customUnit?: string | null;
}): {unit: string; customUnit?: string} {
  if (
    spec.customUnit !== undefined &&
    spec.customUnit !== null &&
    spec.customUnit !== ''
  ) {
    return {unit: 'CUSTOM', customUnit: spec.customUnit};
  }
  if (spec.unit !== undefined && spec.unit !== null) {
    return {unit: UNIT_ENUM_TO_STRING[spec.unit] ?? 'COUNT'};
  }
  return {unit: 'COUNT'};
}

function resolvePolarity(
  polarity: protos.TraceMetricV2Spec.MetricPolarity | null | undefined,
): string {
  if (polarity !== undefined && polarity !== null) {
    return POLARITY_ENUM_TO_STRING[polarity] ?? 'NOT_APPLICABLE';
  }
  return 'NOT_APPLICABLE';
}

export function templateSpecToMetricsState(
  spec: protos.ITraceMetricV2TemplateSpec,
): Record<string, unknown> {
  const metricIdPrefix = spec.idPrefix ?? '';

  // Value columns from valueColumnSpecs or simple valueColumns.
  const valueColumns: Array<Record<string, unknown>> = [];
  const valueColumnSpecs = spec.valueColumnSpecs ?? [];
  const simpleValueColumns = spec.valueColumns ?? [];
  if (valueColumnSpecs.length > 0) {
    for (const vcs of valueColumnSpecs) {
      const {unit, customUnit} = resolveUnit(vcs);
      valueColumns.push({
        column: vcs.name ?? '',
        unit,
        customUnit,
        polarity: resolvePolarity(vcs.polarity),
        displayName: vcs.displayName || undefined,
        displayHelp: vcs.displayHelp || undefined,
      });
    }
  } else if (simpleValueColumns.length > 0) {
    for (const colName of simpleValueColumns) {
      valueColumns.push({
        column: colName,
        unit: 'COUNT',
        polarity: 'NOT_APPLICABLE',
      });
    }
  }

  // Dimension configs from dimensionsSpecs.
  const dimensionConfigs: Record<string, Record<string, unknown>> = {};
  const dimSpecs = spec.dimensionsSpecs ?? [];
  for (const ds of dimSpecs) {
    if (ds.name !== undefined && ds.name !== null) {
      const cfg: Record<string, unknown> = {};
      if (ds.displayName) cfg.displayName = ds.displayName;
      if (ds.displayHelp) cfg.displayHelp = ds.displayHelp;
      if (Object.keys(cfg).length > 0) {
        dimensionConfigs[ds.name] = cfg;
      }
    }
  }

  // Dimension uniqueness.
  let dimensionUniqueness = 'NOT_UNIQUE';
  if (
    spec.dimensionUniqueness ===
    protos.TraceMetricV2Spec.DimensionUniqueness.UNIQUE
  ) {
    dimensionUniqueness = 'UNIQUE';
  }

  return {
    metricIdPrefix,
    valueColumns,
    dimensionConfigs:
      Object.keys(dimensionConfigs).length > 0 ? dimensionConfigs : undefined,
    dimensionUniqueness,
  };
}

export function metricSpecToMetricsState(
  spec: protos.ITraceMetricV2Spec,
): Record<string, unknown> {
  const metricIdPrefix = spec.id ?? '';
  const valueColumns: Array<Record<string, unknown>> = [];

  if (spec.value !== undefined && spec.value !== null && spec.value !== '') {
    const {unit, customUnit} = resolveUnit(spec);
    valueColumns.push({
      column: spec.value,
      unit,
      customUnit,
      polarity: resolvePolarity(spec.polarity),
    });
  }

  const dimensionConfigs: Record<string, Record<string, unknown>> = {};
  const dimSpecs = spec.dimensionsSpecs ?? [];
  for (const ds of dimSpecs) {
    if (ds.name !== undefined && ds.name !== null) {
      const cfg: Record<string, unknown> = {};
      if (ds.displayName) cfg.displayName = ds.displayName;
      if (ds.displayHelp) cfg.displayHelp = ds.displayHelp;
      if (Object.keys(cfg).length > 0) {
        dimensionConfigs[ds.name] = cfg;
      }
    }
  }

  let dimensionUniqueness = 'NOT_UNIQUE';
  if (
    spec.dimensionUniqueness ===
    protos.TraceMetricV2Spec.DimensionUniqueness.UNIQUE
  ) {
    dimensionUniqueness = 'UNIQUE';
  }

  return {
    metricIdPrefix,
    valueColumns,
    dimensionConfigs:
      Object.keys(dimensionConfigs).length > 0 ? dimensionConfigs : undefined,
    dimensionUniqueness,
  };
}

// ============================================================================
// Pbtxt Detection & Wrapping
// ============================================================================

/**
 * Detects whether the pbtxt is a single metric_template_spec, a single
 * metric_spec, or a full TraceSummarySpec. Wraps single specs if needed.
 */
export function detectAndWrapPbtxt(text: string): string {
  const trimmed = text.trim();

  // If it already contains top-level TraceSummarySpec fields, use as-is.
  if (/^(metric_template_spec|metric_spec|query)\s*[:{]/m.test(trimmed)) {
    return trimmed;
  }

  // If it looks like a single TraceMetricV2TemplateSpec (has id_prefix).
  if (/^id_prefix\s*:/m.test(trimmed)) {
    return `metric_template_spec {\n${trimmed}\n}`;
  }

  // If it looks like a single TraceMetricV2Spec (has id: field).
  if (/^id\s*:/m.test(trimmed)) {
    return `metric_spec {\n${trimmed}\n}`;
  }

  // Otherwise, assume it's a full TraceSummarySpec.
  return trimmed;
}

/**
 * Converts SimpleSlices glob fields into GLOB filter entries so they are
 * preserved as FilterNode state. The SlicesSourceNode itself does not
 * support glob fields, so without this the filters would be silently lost.
 */
function buildSimpleSlicesFilters(
  slices: protos.PerfettoSqlStructuredQuery.ISimpleSlices,
): Array<Record<string, unknown>> {
  const GLOB_COLUMN_MAP: Array<{
    field: string | null | undefined;
    column: string;
  }> = [
    {field: slices.sliceNameGlob, column: 'name'},
    {field: slices.processNameGlob, column: 'process_name'},
    {field: slices.threadNameGlob, column: 'thread_name'},
    {field: slices.trackNameGlob, column: 'track_name'},
  ];

  return GLOB_COLUMN_MAP.filter(
    (entry) =>
      entry.field !== undefined && entry.field !== null && entry.field !== '',
  ).map((entry) => ({
    column: entry.column,
    op: 'GLOB',
    value: entry.field,
    enabled: true,
  }));
}
