// Copyright (C) 2024 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 {AggregationNode} from './query_builder/nodes/aggregation_node';
import {ModifyColumnsNode} from './query_builder/nodes/modify_columns_node';
import {ExplorePageState} from './explore_page';
import {IntervalIntersectNode} from './query_builder/nodes/interval_intersect_node';
import {SlicesSourceNode} from './query_builder/nodes/sources/slices_source';
import {SqlSourceNode} from './query_builder/nodes/sources/sql_source';
import {TableSourceNode} from './query_builder/nodes/sources/table_source';
import {serializeState, deserializeState, SerializedNode} from './json_handler';
import {Trace} from '../../public/trace';
import {
  SqlModules,
  SqlTable,
} from '../../plugins/dev.perfetto.SqlModules/sql_modules';
import {AddColumnsNode} from './query_builder/nodes/add_columns_node';
import {LimitAndOffsetNode} from './query_builder/nodes/limit_and_offset_node';
import {SortNode} from './query_builder/nodes/sort_node';
import {FilterNode} from './query_builder/nodes/filter_node';
import {JoinNode} from './query_builder/nodes/join_node';
import {UnionNode} from './query_builder/nodes/union_node';
import {PerfettoSqlType} from '../../trace_processor/perfetto_sql_type';
import {NodeType} from './query_node';
import {addConnection, removeConnection} from './query_builder/graph_utils';
import {ColumnInfo} from './query_builder/column_info';
import {FilterDuringNode} from './query_builder/nodes/filter_during_node';
import {TimeRangeSourceNode} from './query_builder/nodes/sources/timerange_source';
import {Time} from '../../base/time';
import {registerCoreNodes} from './query_builder/core_nodes';

registerCoreNodes();

describe('JSON serialization/deserialization', () => {
  let trace: Trace;
  let sqlModules: SqlModules;

  beforeEach(() => {
    trace = {
      traceInfo: {
        traceTitle: 'test_trace',
      },
    } as Trace;

    const stringType: PerfettoSqlType = {kind: 'string'};
    const timestampType: PerfettoSqlType = {
      kind: 'timestamp',
    };

    const tables = new Map<string, SqlTable>();
    tables.set('slice', {
      name: 'slice',
      description: '',
      type: 'table',
      includeKey: '',
      importance: undefined,
      getTableColumns: () => [],
      columns: [
        {
          name: 'name',
          type: stringType,
        },
        {
          name: 'ts',
          type: timestampType,
        },
        {
          name: 'dur',
          type: timestampType,
        },
      ],
    });

    sqlModules = {
      getTable: (name: string) => tables.get(name),
    } as SqlModules;
  });

  test('serializes and deserializes a simple graph', () => {
    const sliceNode = new SlicesSourceNode({});
    const initialState: ExplorePageState = {
      rootNodes: [sliceNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(1);
    const deserializedNode = deserializedState.rootNodes[0];
    expect(deserializedNode).toBeInstanceOf(SlicesSourceNode);
  });

  test('handles multiple nodes and connections', () => {
    const tableNode = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });
    const modifyNode = new ModifyColumnsNode({
      selectedColumns: [],
    });
    addConnection(tableNode, modifyNode);

    const initialState: ExplorePageState = {
      rootNodes: [tableNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map([
        [tableNode.nodeId, {x: 10, y: 20}],
        [modifyNode.nodeId, {x: 100, y: 200}],
      ]),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(1);
    const deserializedTableNode = deserializedState.rootNodes[0];
    expect(deserializedTableNode.nextNodes.length).toBe(1);
    const deserializedModifyNode = deserializedTableNode.nextNodes[0];
    expect(
      (deserializedModifyNode as ModifyColumnsNode).primaryInput?.nodeId,
    ).toBe(deserializedTableNode.nodeId);
    // Coordinates are normalized so top-left is at (minX, minY)
    // Original: tableNode (10, 20), modifyNode (100, 200)
    // After normalization: tableNode (0, 0), modifyNode (90, 180)
    expect(
      deserializedState.nodeLayouts.get(deserializedTableNode.nodeId),
    ).toEqual({x: 0, y: 0});
  });

  test('serializes and deserializes aggregation node', () => {
    const tableNode = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    const sliceTable = sqlModules.getTable('slice')!;
    const aggregationNode = new AggregationNode({
      groupByColumns: [
        {
          name: 'name',
          type: 'STRING',
          checked: true,
          column: sliceTable.columns[0],
        },
      ],
      aggregations: [
        {
          column: {
            name: 'dur',
            type: 'TIMESTAMP_NS',
            checked: true,
            column: sliceTable.columns[2],
          },
          aggregationOp: 'SUM',
          newColumnName: 'total_dur',
        },
      ],
    });
    addConnection(tableNode, aggregationNode);

    const initialState: ExplorePageState = {
      rootNodes: [tableNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(1);
    const deserializedTableNode = deserializedState.rootNodes[0];
    expect(deserializedTableNode.nextNodes.length).toBe(1);
    const deserializedAggregationNode = deserializedTableNode
      .nextNodes[0] as AggregationNode;
    expect(deserializedAggregationNode.primaryInput?.nodeId).toBe(
      deserializedTableNode.nodeId,
    );
    expect(deserializedAggregationNode.state.groupByColumns[0].name).toBe(
      'name',
    );
    expect(
      deserializedAggregationNode.state.aggregations[0].aggregationOp,
    ).toBe('SUM');
    expect(deserializedAggregationNode.state.aggregations[0].column?.name).toBe(
      'dur',
    );
  });

  test('serializes and deserializes sql source node', () => {
    const sqlNode = new SqlSourceNode({
      sql: 'SELECT * FROM slice',
      trace,
    });
    const initialState: ExplorePageState = {
      rootNodes: [sqlNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(1);
    const deserializedNode = deserializedState.rootNodes[0] as SqlSourceNode;
    expect(deserializedNode.state.sql).toBe('SELECT * FROM slice');
  });

  test('serializes and deserializes sql source node with input connections', () => {
    const tableNode1 = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    const tableNode2 = new TableSourceNode({
      sqlTable: sqlModules.getTable('thread'),
      trace,
      sqlModules,
    });

    const sqlNode = new SqlSourceNode({
      sql: 'SELECT * FROM $input_0 JOIN $input_1',
      trace,
    });

    // Connect inputs
    tableNode1.nextNodes.push(sqlNode);
    tableNode2.nextNodes.push(sqlNode);
    sqlNode.secondaryInputs.connections.set(0, tableNode1);
    sqlNode.secondaryInputs.connections.set(1, tableNode2);

    const initialState: ExplorePageState = {
      rootNodes: [tableNode1, tableNode2],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(2);

    // Find the sql node in the deserialized graph
    const deserializedTableNode1 = deserializedState.rootNodes[0];
    const deserializedSqlNode = deserializedTableNode1
      .nextNodes[0] as SqlSourceNode;

    expect(deserializedSqlNode.state.sql).toBe(
      'SELECT * FROM $input_0 JOIN $input_1',
    );
    expect(deserializedSqlNode.secondaryInputs.connections.size).toBe(2);
    // The order of rootNodes is preserved during deserialization because
    // rootNodeIds are serialized in order and restored in the same order.
    // tableNode1 (slice) was connected to port 0, tableNode2 (thread) to port 1.
    expect(deserializedSqlNode.secondaryInputs.connections.get(0)?.nodeId).toBe(
      deserializedState.rootNodes[0].nodeId,
    );
    expect(deserializedSqlNode.secondaryInputs.connections.get(1)?.nodeId).toBe(
      deserializedState.rootNodes[1].nodeId,
    );
  });

  test('serializes and deserializes interval intersect node', () => {
    const tableNode1 = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    const tableNode2 = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    const intervalIntersectNode = new IntervalIntersectNode({
      inputNodes: [tableNode1, tableNode2],
    });
    tableNode1.nextNodes.push(intervalIntersectNode);
    tableNode2.nextNodes.push(intervalIntersectNode);

    const initialState: ExplorePageState = {
      rootNodes: [tableNode1, tableNode2],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(2);
    const deserializedTableNode1 = deserializedState.rootNodes[0];
    const deserializedTableNode2 = deserializedState.rootNodes[1];
    expect(deserializedTableNode1.nextNodes.length).toBe(1);
    const deserializedIntervalIntersectNode = deserializedTableNode1
      .nextNodes[0] as IntervalIntersectNode;
    expect(
      deserializedIntervalIntersectNode.secondaryInputs.connections,
    ).toBeDefined();
    expect(
      deserializedIntervalIntersectNode.secondaryInputs.connections.size,
    ).toBe(2);
    expect(
      deserializedIntervalIntersectNode.secondaryInputs.connections.get(0)
        ?.nodeId,
    ).toBe(deserializedTableNode1.nodeId);
    expect(
      deserializedIntervalIntersectNode.secondaryInputs.connections.get(1)
        ?.nodeId,
    ).toBe(deserializedTableNode2.nodeId);
  });

  test('serializes and deserializes interval intersect node with partition columns and filters', () => {
    const tableNode1 = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    const tableNode2 = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    const tableNode3 = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    const intervalIntersectNode = new IntervalIntersectNode({
      inputNodes: [tableNode1, tableNode2, tableNode3],
      partitionColumns: ['name'],
    });
    tableNode1.nextNodes.push(intervalIntersectNode);
    tableNode2.nextNodes.push(intervalIntersectNode);
    tableNode3.nextNodes.push(intervalIntersectNode);

    const initialState: ExplorePageState = {
      rootNodes: [tableNode1, tableNode2, tableNode3],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(3);
    const deserializedTableNode1 = deserializedState.rootNodes[0];
    const deserializedTableNode2 = deserializedState.rootNodes[1];
    const deserializedTableNode3 = deserializedState.rootNodes[2];
    expect(deserializedTableNode1.nextNodes.length).toBe(1);
    const deserializedIntervalIntersectNode = deserializedTableNode1
      .nextNodes[0] as IntervalIntersectNode;

    // Verify secondaryInputs connections
    expect(
      deserializedIntervalIntersectNode.secondaryInputs.connections,
    ).toBeDefined();
    expect(
      deserializedIntervalIntersectNode.secondaryInputs.connections.size,
    ).toBe(3);
    expect(
      deserializedIntervalIntersectNode.secondaryInputs.connections.get(0)
        ?.nodeId,
    ).toBe(deserializedTableNode1.nodeId);
    expect(
      deserializedIntervalIntersectNode.secondaryInputs.connections.get(1)
        ?.nodeId,
    ).toBe(deserializedTableNode2.nodeId);
    expect(
      deserializedIntervalIntersectNode.secondaryInputs.connections.get(2)
        ?.nodeId,
    ).toBe(deserializedTableNode3.nodeId);

    // Verify partition columns
    expect(
      deserializedIntervalIntersectNode.state.partitionColumns,
    ).toBeDefined();
    expect(
      deserializedIntervalIntersectNode.state.partitionColumns?.length,
    ).toBe(1);
    expect(deserializedIntervalIntersectNode.state.partitionColumns?.[0]).toBe(
      'name',
    );
  });

  test('serializes and deserializes sql source node with reference', () => {
    const tableNode = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    const modifyNode = new ModifyColumnsNode({
      selectedColumns: [],
    });
    addConnection(tableNode, modifyNode);

    const initialState: ExplorePageState = {
      rootNodes: [tableNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(1);
    const deserializedTableNode = deserializedState.rootNodes[0];
    expect(deserializedTableNode.nextNodes.length).toBe(1);
    const deserializedModifyNode = deserializedTableNode
      .nextNodes[0] as ModifyColumnsNode;
    expect(deserializedModifyNode.primaryInput?.nodeId).toBe(
      deserializedTableNode.nodeId,
    );
  });

  test('serializes and deserializes node with string filters', () => {
    const tableNode = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    const filterNode = new FilterNode({
      filters: [
        {
          column: 'name',
          op: '=',
          value: 'test',
        },
      ],
    });
    addConnection(tableNode, filterNode);

    const initialState: ExplorePageState = {
      rootNodes: [tableNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(1);
    const deserializedTableNode = deserializedState.rootNodes[0];
    expect(deserializedTableNode.nextNodes.length).toBe(1);
    const deserializedFilterNode = deserializedTableNode
      .nextNodes[0] as FilterNode;
    expect(deserializedFilterNode.state.filters?.length).toBe(1);
    const filter = deserializedFilterNode.state.filters?.[0];
    expect(filter?.column).toBe('name');
    expect(filter?.op).toBe('=');
    if (filter !== undefined && 'value' in filter) {
      expect(filter.value).toBe('test');
    } else {
      fail('Filter value not found');
    }
  });

  test('serializes and deserializes node with numeric filters', () => {
    const tableNode = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    const filterNode = new FilterNode({
      filters: [
        {
          column: 'dur',
          op: '>',
          value: 1000,
        },
      ],
    });
    addConnection(tableNode, filterNode);

    const initialState: ExplorePageState = {
      rootNodes: [tableNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(1);
    const deserializedTableNode = deserializedState.rootNodes[0];
    expect(deserializedTableNode.nextNodes.length).toBe(1);
    const deserializedFilterNode = deserializedTableNode
      .nextNodes[0] as FilterNode;
    expect(deserializedFilterNode.state.filters?.length).toBe(1);
    const filter = deserializedFilterNode.state.filters?.[0];
    expect(filter?.column).toBe('dur');
    expect(filter?.op).toBe('>');
    if (filter !== undefined && 'value' in filter) {
      // Numeric filters should remain as numbers after serialization/deserialization
      expect(typeof filter.value).toBe('number');
      expect(filter.value).toBe(1000);
    } else {
      fail('Filter value not found');
    }
  });

  test('serializes and deserializes node layouts', () => {
    const tableNode = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    const sliceNode = new SlicesSourceNode({});

    const initialState: ExplorePageState = {
      rootNodes: [tableNode, sliceNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map([
        [tableNode.nodeId, {x: 10, y: 20}],
        [sliceNode.nodeId, {x: 100, y: 200}],
      ]),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(2);
    const deserializedTableNode = deserializedState.rootNodes[0];
    const deserializedSliceNode = deserializedState.rootNodes[1];

    // Coordinates are normalized so top-left is at (minX, minY)
    // Original: tableNode (10, 20), sliceNode (100, 200)
    // After normalization: tableNode (0, 0), sliceNode (90, 180)
    expect(
      deserializedState.nodeLayouts.get(deserializedTableNode.nodeId),
    ).toEqual({x: 0, y: 0});
    expect(
      deserializedState.nodeLayouts.get(deserializedSliceNode.nodeId),
    ).toEqual({x: 90, y: 180});
  });

  test('serializes and deserializes node with BigInt filter', () => {
    const tableNode = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    const filterNode = new FilterNode({
      filters: [
        {
          column: 'id',
          op: '=',
          value: 12345678901234567890n,
        },
      ],
    });
    addConnection(tableNode, filterNode);

    const initialState: ExplorePageState = {
      rootNodes: [tableNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(1);
    const deserializedTableNode = deserializedState.rootNodes[0];
    expect(deserializedTableNode.nextNodes.length).toBe(1);
    const deserializedFilterNode = deserializedTableNode
      .nextNodes[0] as FilterNode;
    expect(deserializedFilterNode.state.filters?.length).toBe(1);
    const filter = deserializedFilterNode.state.filters?.[0];
    expect(filter?.column).toBe('id');
    expect(filter?.op).toBe('=');
    if (filter !== undefined && 'value' in filter) {
      // BigInt values are serialized as strings and then converted back to numbers.
      // Note: Very large numbers may lose precision due to JavaScript Number limits.
      expect(typeof filter.value).toBe('number');
      expect(filter.value).toBe(Number('12345678901234567890'));
    } else {
      fail('Filter value not found');
    }
  });

  test('serializes and deserializes an empty graph', () => {
    const initialState: ExplorePageState = {
      rootNodes: [],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(0);
    expect(deserializedState.nodeLayouts.size).toBe(0);
  });

  test('throws error on invalid json', () => {
    const invalidJson = '{"invalid": "json"}';
    expect(() => deserializeState(invalidJson, trace, sqlModules)).toThrow();
  });

  test('deserializes graph with primaryInput connections', () => {
    const tableNode = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });
    const modifyNode = new ModifyColumnsNode({
      selectedColumns: [],
    });
    addConnection(tableNode, modifyNode);

    const initialState: ExplorePageState = {
      rootNodes: [tableNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    // Test with primaryInput
    const jsonWithPrimaryInput = serializeState(initialState);
    const deserializedStateWithPrimaryInput = deserializeState(
      jsonWithPrimaryInput,
      trace,
      sqlModules,
    );
    const deserializedTableNode1 =
      deserializedStateWithPrimaryInput.rootNodes[0];
    const deserializedModifyNode1 = deserializedTableNode1
      .nextNodes[0] as ModifyColumnsNode;
    expect(deserializedModifyNode1.primaryInput?.nodeId).toBe(
      deserializedTableNode1.nodeId,
    );
  });

  test('serializes and deserializes modify columns node', () => {
    const tableNode = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    const modifyColumnsNode = new ModifyColumnsNode({
      selectedColumns: [],
    });
    addConnection(tableNode, modifyColumnsNode);

    const filterNode = new FilterNode({
      filters: [
        {
          column: 'new_col',
          op: '=',
          value: '1',
        },
      ],
    });
    addConnection(modifyColumnsNode, filterNode);

    const initialState: ExplorePageState = {
      rootNodes: [tableNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(1);
    const deserializedTableNode = deserializedState.rootNodes[0];
    expect(deserializedTableNode.nextNodes.length).toBe(1);
    const deserializedModifyNode = deserializedTableNode
      .nextNodes[0] as ModifyColumnsNode;
    expect(deserializedModifyNode.nextNodes.length).toBe(1);
    const deserializedFilterNode = deserializedModifyNode
      .nextNodes[0] as FilterNode;
    expect(deserializedFilterNode.state.filters).toBeDefined();
    expect(deserializedFilterNode.state.filters?.length).toBe(1);
    expect(deserializedFilterNode.state.filters?.[0].column).toBe('new_col');
  });

  test('serializes and deserializes add columns node', () => {
    const tableNode = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    const addColumnsNode = new AddColumnsNode({
      selectedColumns: ['name'],
    });
    // Manually connect without triggering onPrevNodesUpdated to preserve
    // the test's explicitly provided selectedColumns
    tableNode.nextNodes.push(addColumnsNode);
    addColumnsNode.primaryInput = tableNode;

    const initialState: ExplorePageState = {
      rootNodes: [tableNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(1);
    const deserializedTableNode = deserializedState.rootNodes[0];
    expect(deserializedTableNode.nextNodes.length).toBe(1);
    const deserializedNode = deserializedTableNode
      .nextNodes[0] as AddColumnsNode;
    expect(deserializedNode.state.selectedColumns).toEqual(['name']);
    // Note: sqlTable is no longer part of AddColumnsNodeState
  });

  test('serializes and deserializes add columns node with inputNodes connection', () => {
    // Create the main data flow: tableNode1 -> addColumnsNode
    const tableNode1 = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    const addColumnsNode = new AddColumnsNode({
      selectedColumns: ['name', 'ts'],
      leftColumn: 'id',
      rightColumn: 'id',
    });
    // Manually connect without triggering onPrevNodesUpdated to preserve
    // the test's explicitly provided selectedColumns
    tableNode1.nextNodes.push(addColumnsNode);
    addColumnsNode.primaryInput = tableNode1;

    // Create the side input: tableNode2 connected to inputNodes[0]
    const tableNode2 = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    // Connect tableNode2 to addColumnsNode's secondaryInputs port 0 (left-side port)
    addColumnsNode.secondaryInputs.connections.set(0, tableNode2);
    tableNode2.nextNodes.push(addColumnsNode);

    const initialState: ExplorePageState = {
      rootNodes: [tableNode1, tableNode2],
      selectedNodes: new Set(),
      nodeLayouts: new Map([
        [tableNode1.nodeId, {x: 0, y: 0}],
        [addColumnsNode.nodeId, {x: 0, y: 100}],
        [tableNode2.nodeId, {x: -200, y: 100}],
      ]),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    // Verify root nodes
    expect(deserializedState.rootNodes.length).toBe(2);
    const deserializedTableNode1 = deserializedState.rootNodes[0];
    const deserializedTableNode2 = deserializedState.rootNodes[1];

    // Verify main connection (primaryInput)
    expect(deserializedTableNode1.nextNodes.length).toBe(1);
    const deserializedAddColumnsNode = deserializedTableNode1
      .nextNodes[0] as AddColumnsNode;
    expect(deserializedAddColumnsNode.primaryInput?.nodeId).toBe(
      deserializedTableNode1.nodeId,
    );

    // Verify secondaryInputs connection (THIS IS THE BUG - this will fail before the fix)
    expect(
      deserializedAddColumnsNode.secondaryInputs.connections,
    ).toBeDefined();
    expect(
      deserializedAddColumnsNode.secondaryInputs.connections.size,
    ).toBeGreaterThan(0);
    expect(
      deserializedAddColumnsNode.secondaryInputs.connections.get(0)?.nodeId,
    ).toBe(deserializedTableNode2.nodeId);

    // Verify tableNode2 has the connection back to addColumnsNode
    expect(deserializedTableNode2.nextNodes.length).toBe(1);
    expect(deserializedTableNode2.nextNodes[0].nodeId).toBe(
      deserializedAddColumnsNode.nodeId,
    );

    // Verify node state
    expect(deserializedAddColumnsNode.state.selectedColumns).toEqual([
      'name',
      'ts',
    ]);
    expect(deserializedAddColumnsNode.state.leftColumn).toBe('id');
    expect(deserializedAddColumnsNode.state.rightColumn).toBe('id');

    // Verify layouts are preserved (after normalization)
    // Original: tableNode1 (0, 0), addColumnsNode (0, 100), tableNode2 (-200, 100)
    // After normalization (minX=-200, minY=0):
    // tableNode1 (200, 0), addColumnsNode (200, 100), tableNode2 (0, 100)
    expect(
      deserializedState.nodeLayouts.get(deserializedTableNode1.nodeId),
    ).toEqual({x: 200, y: 0});
    expect(
      deserializedState.nodeLayouts.get(deserializedAddColumnsNode.nodeId),
    ).toEqual({x: 200, y: 100});
    expect(
      deserializedState.nodeLayouts.get(deserializedTableNode2.nodeId),
    ).toEqual({x: 0, y: 100});
  });

  test('add columns node uses renamed columns from modify columns node', () => {
    // Create table source
    const tableNode1 = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    // Get all columns from the table to find one to rename
    const allColumns = tableNode1.finalCols;
    // Find a column to rename (let's use the first one)
    const columnToRename = allColumns[0];

    // Create modify columns node that renames a column using alias
    const selectedColumnsWithAlias = [
      {
        ...allColumns[0],
        alias: 'renamed_column',
        checked: true,
      },
      ...allColumns.slice(1, 3).map((col) => ({...col, checked: true})),
    ];

    const modifyColumnsNode = new ModifyColumnsNode({
      selectedColumns: selectedColumnsWithAlias,
    });
    addConnection(tableNode1, modifyColumnsNode);

    // Create another table to join with
    const tableNode2 = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    // Create add columns node that should see the renamed column 'duration_ns'
    const addColumnsNode = new AddColumnsNode({
      selectedColumns: [],
    });
    addConnection(tableNode2, addColumnsNode);

    // Connect modifyColumnsNode to addColumnsNode's secondaryInputs port 0
    addColumnsNode.secondaryInputs.connections.set(0, modifyColumnsNode);
    modifyColumnsNode.nextNodes.push(addColumnsNode);

    // Check that addColumnsNode can see the renamed column
    const rightCols = addColumnsNode.rightCols;
    const colNames = rightCols.map((c: ColumnInfo) => c.column.name);

    // Should see the renamed column
    expect(colNames).toContain('renamed_column');
    // Should NOT see the original column name
    expect(colNames).not.toContain(columnToRename.column.name);

    // Verify the type is preserved for the renamed column
    const renamedCol = rightCols.find(
      (c) => c.column.name === 'renamed_column',
    );
    expect(renamedCol).toBeDefined();
    expect(renamedCol?.type).toBe(columnToRename.type);
    expect(renamedCol?.type).not.toBe('NA'); // Type should be preserved, not 'NA'

    // Now test serialization/deserialization preserves this
    const initialState: ExplorePageState = {
      rootNodes: [tableNode1, tableNode2],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    // Find the deserialized add columns node
    const deserializedTableNode2 = deserializedState.rootNodes.find(
      (n) => n.nodeId === tableNode2.nodeId,
    );
    expect(deserializedTableNode2).toBeDefined();
    expect(deserializedTableNode2!.nextNodes.length).toBe(1);

    const deserializedAddColumnsNode = deserializedTableNode2!
      .nextNodes[0] as AddColumnsNode;

    // Verify the connection is restored
    expect(
      deserializedAddColumnsNode.secondaryInputs.connections,
    ).toBeDefined();
    expect(
      deserializedAddColumnsNode.secondaryInputs.connections.size,
    ).toBeGreaterThan(0);
    expect(
      deserializedAddColumnsNode.secondaryInputs.connections.get(0),
    ).toBeDefined();

    // Most importantly: verify that renamed columns are still accessible
    const deserializedRightCols = deserializedAddColumnsNode.rightCols;
    const deserializedColNames = deserializedRightCols.map(
      (c) => c.column.name,
    );

    expect(deserializedColNames).toContain('renamed_column');
    expect(deserializedColNames).not.toContain(columnToRename.column.name);

    // Verify the type is preserved after serialization/deserialization
    const deserializedRenamedCol = deserializedRightCols.find(
      (c) => c.column.name === 'renamed_column',
    );
    expect(deserializedRenamedCol).toBeDefined();
    expect(deserializedRenamedCol?.type).toBe(columnToRename.type);
    expect(deserializedRenamedCol?.type).not.toBe('NA'); // Type should still be preserved
  });

  test('aggregation node can group by aliased column from modify columns node', () => {
    const tableNode = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    // Create a ModifyColumnsNode that aliases a column
    const modifyNode = new ModifyColumnsNode({
      selectedColumns: tableNode.finalCols.map((col) => {
        // Alias the 'ts' column to 'timestamp_alias'
        if (col.name === 'ts') {
          return {...col, alias: 'timestamp_alias'};
        }
        return col;
      }),
    });
    // Manually connect without triggering onPrevNodesUpdated to preserve
    // the test's explicitly provided selectedColumns
    tableNode.nextNodes.push(modifyNode);
    modifyNode.primaryInput = tableNode;

    // Verify that the alias is in finalCols and the original name is not
    const tsColumn = modifyNode.finalCols.find((c) => c.name === 'ts');
    const aliasedColumn = modifyNode.finalCols.find(
      (c) => c.name === 'timestamp_alias',
    );
    expect(tsColumn).toBeUndefined(); // Original name should not be visible
    expect(aliasedColumn).toBeDefined(); // Aliased name should be visible
    expect(aliasedColumn?.column.name).toBe('timestamp_alias'); // column.name should also use alias

    // Create an AggregationNode that groups by the aliased column
    const aggregationNode = new AggregationNode({
      groupByColumns: [aliasedColumn!],
      aggregations: [],
    });
    // Manually connect without triggering onPrevNodesUpdated to preserve
    // the test's explicitly provided groupByColumns
    modifyNode.nextNodes.push(aggregationNode);
    aggregationNode.primaryInput = modifyNode;

    // Verify the aggregation node sees the aliased column
    expect(aggregationNode.state.groupByColumns.length).toBe(1);
    expect(aggregationNode.state.groupByColumns[0].name).toBe(
      'timestamp_alias',
    );
    expect(aggregationNode.state.groupByColumns[0].column.name).toBe(
      'timestamp_alias',
    );

    // Serialize and deserialize
    const initialState: ExplorePageState = {
      rootNodes: [tableNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    // Navigate to the deserialized aggregation node
    expect(deserializedState.rootNodes.length).toBe(1);
    const deserializedTableNode = deserializedState.rootNodes[0];
    expect(deserializedTableNode.nextNodes.length).toBe(1);

    const deserializedModifyNode = deserializedTableNode.nextNodes[0] as
      | ModifyColumnsNode
      | undefined;
    expect(deserializedModifyNode).toBeDefined();
    expect(deserializedModifyNode?.type).toBe(NodeType.kModifyColumns);

    // Verify the alias is still in finalCols after deserialization
    const deserializedTsColumn = deserializedModifyNode?.finalCols.find(
      (c) => c.name === 'ts',
    );
    const deserializedAliasedColumn = deserializedModifyNode?.finalCols.find(
      (c) => c.name === 'timestamp_alias',
    );
    expect(deserializedTsColumn).toBeUndefined(); // Original name should not be visible
    expect(deserializedAliasedColumn).toBeDefined(); // Aliased name should be visible
    expect(deserializedAliasedColumn?.column.name).toBe('timestamp_alias');

    // Verify the aggregation node still sees the aliased column
    expect(deserializedModifyNode?.nextNodes.length).toBe(1);
    const deserializedAggNode = deserializedModifyNode?.nextNodes[0] as
      | AggregationNode
      | undefined;
    expect(deserializedAggNode).toBeDefined();
    expect(deserializedAggNode?.type).toBe(NodeType.kAggregation);
    expect(deserializedAggNode?.state.groupByColumns.length).toBe(1);
    expect(deserializedAggNode?.state.groupByColumns[0].name).toBe(
      'timestamp_alias',
    );
    expect(deserializedAggNode?.state.groupByColumns[0].column.name).toBe(
      'timestamp_alias',
    );
  });

  test('serializes and deserializes limit and offset node', () => {
    const tableNode = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    const limitAndOffsetNode = new LimitAndOffsetNode({
      limit: 100,
      offset: 20,
    });
    addConnection(tableNode, limitAndOffsetNode);

    const initialState: ExplorePageState = {
      rootNodes: [tableNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(1);
    const deserializedTableNode = deserializedState.rootNodes[0];
    expect(deserializedTableNode.nextNodes.length).toBe(1);
    const deserializedNode = deserializedTableNode
      .nextNodes[0] as LimitAndOffsetNode;
    expect(deserializedNode.state.limit).toEqual(100);
    expect(deserializedNode.state.offset).toEqual(20);
  });

  test('serializes and deserializes sort node', () => {
    const tableNode = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    const sortNode = new SortNode({
      sortCriteria: [
        {colName: 'name', direction: 'ASC'},
        {colName: 'ts', direction: 'DESC'},
      ],
    });
    addConnection(tableNode, sortNode);

    const initialState: ExplorePageState = {
      rootNodes: [tableNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(1);
    const deserializedTableNode = deserializedState.rootNodes[0];
    expect(deserializedTableNode.nextNodes.length).toBe(1);
    const deserializedNode = deserializedTableNode.nextNodes[0] as SortNode;
    expect(deserializedNode.state.sortCriteria).toEqual([
      {colName: 'name', direction: 'ASC'},
      {colName: 'ts', direction: 'DESC'},
    ]);
  });

  test('serializes and deserializes filter node', () => {
    const tableNode = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    const filterNode = new FilterNode({
      filters: [
        {
          column: 'name',
          op: '=',
          value: 'test',
        },
        {
          column: 'dur',
          op: '>',
          value: 1000,
        },
      ],
      filterOperator: 'AND',
    });
    addConnection(tableNode, filterNode);

    const initialState: ExplorePageState = {
      rootNodes: [tableNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(1);
    const deserializedTableNode = deserializedState.rootNodes[0];
    expect(deserializedTableNode.nextNodes.length).toBe(1);
    const deserializedNode = deserializedTableNode.nextNodes[0] as FilterNode;

    // Verify filters
    expect(deserializedNode.state.filters).toBeDefined();
    expect(deserializedNode.state.filters?.length).toBe(2);
    expect(deserializedNode.state.filters?.[0].column).toBe('name');
    expect(deserializedNode.state.filters?.[0].op).toBe('=');
    if (
      deserializedNode.state.filters?.[0] &&
      'value' in deserializedNode.state.filters[0]
    ) {
      expect(deserializedNode.state.filters[0].value).toBe('test');
    }
    expect(deserializedNode.state.filters?.[1].column).toBe('dur');
    expect(deserializedNode.state.filters?.[1].op).toBe('>');
    if (
      deserializedNode.state.filters?.[1] &&
      'value' in deserializedNode.state.filters[1]
    ) {
      expect(deserializedNode.state.filters[1].value).toBe(1000);
    }

    // Verify filter operator
    expect(deserializedNode.state.filterOperator).toBe('AND');

    // Verify primaryInput connection
    expect(deserializedNode.primaryInput?.nodeId).toBe(
      deserializedTableNode.nodeId,
    );
  });

  test('serializes and deserializes filter node with null filter', () => {
    const tableNode = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    const filterNode = new FilterNode({
      filters: [
        {
          column: 'name',
          op: 'is null',
        },
      ],
    });
    addConnection(tableNode, filterNode);

    const initialState: ExplorePageState = {
      rootNodes: [tableNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(1);
    const deserializedTableNode = deserializedState.rootNodes[0];
    expect(deserializedTableNode.nextNodes.length).toBe(1);
    const deserializedNode = deserializedTableNode.nextNodes[0] as FilterNode;

    // Verify filter
    expect(deserializedNode.state.filters).toBeDefined();
    expect(deserializedNode.state.filters?.length).toBe(1);
    expect(deserializedNode.state.filters?.[0].column).toBe('name');
    expect(deserializedNode.state.filters?.[0].op).toBe('is null');
    // Null filters don't have a value property
    expect('value' in deserializedNode.state.filters![0]).toBe(false);
  });

  test('serializes and deserializes merge node with equality condition', () => {
    const tableNode1 = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    const tableNode2 = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    const joinNode = new JoinNode({
      leftNode: tableNode1,
      rightNode: tableNode2,
      leftQueryAlias: 'left',
      rightQueryAlias: 'right',
      conditionType: 'equality',
      joinType: 'INNER',
      leftColumn: 'name',
      rightColumn: 'name',
      sqlExpression: '',
      leftColumns: undefined,
      rightColumns: undefined,
    });
    tableNode1.nextNodes.push(joinNode);
    tableNode2.nextNodes.push(joinNode);

    const initialState: ExplorePageState = {
      rootNodes: [tableNode1, tableNode2],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(2);
    const deserializedTableNode1 = deserializedState.rootNodes[0];
    const deserializedTableNode2 = deserializedState.rootNodes[1];
    expect(deserializedTableNode1.nextNodes.length).toBe(1);
    const deserializedJoinNode = deserializedTableNode1
      .nextNodes[0] as JoinNode;
    expect(deserializedJoinNode.secondaryInputs.connections).toBeDefined();
    expect(deserializedJoinNode.secondaryInputs.connections.size).toBe(2);
    expect(
      deserializedJoinNode.secondaryInputs.connections.get(0)?.nodeId,
    ).toBe(deserializedTableNode1.nodeId);
    expect(
      deserializedJoinNode.secondaryInputs.connections.get(1)?.nodeId,
    ).toBe(deserializedTableNode2.nodeId);
    expect(deserializedJoinNode.state.leftQueryAlias).toBe('left');
    expect(deserializedJoinNode.state.rightQueryAlias).toBe('right');
    expect(deserializedJoinNode.state.conditionType).toBe('equality');
    expect(deserializedJoinNode.state.leftColumn).toBe('name');
    expect(deserializedJoinNode.state.rightColumn).toBe('name');
  });

  test('serializes and deserializes merge node with freeform condition', () => {
    const tableNode1 = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    const tableNode2 = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    const joinNode = new JoinNode({
      leftNode: tableNode1,
      rightNode: tableNode2,
      leftQueryAlias: 't1',
      rightQueryAlias: 't2',
      conditionType: 'freeform',
      joinType: 'INNER',
      leftColumn: '',
      rightColumn: '',
      sqlExpression: 't1.id = t2.parent_id',
      leftColumns: undefined,
      rightColumns: undefined,
    });
    tableNode1.nextNodes.push(joinNode);
    tableNode2.nextNodes.push(joinNode);

    const initialState: ExplorePageState = {
      rootNodes: [tableNode1, tableNode2],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(2);
    const deserializedTableNode1 = deserializedState.rootNodes[0];
    expect(deserializedTableNode1.nextNodes.length).toBe(1);
    const deserializedJoinNode = deserializedTableNode1
      .nextNodes[0] as JoinNode;
    expect(deserializedJoinNode.state.leftQueryAlias).toBe('t1');
    expect(deserializedJoinNode.state.rightQueryAlias).toBe('t2');
    expect(deserializedJoinNode.state.conditionType).toBe('freeform');
    expect(deserializedJoinNode.state.sqlExpression).toBe(
      't1.id = t2.parent_id',
    );
  });

  test('deserializes old JSON with NodeType.kMerge as JoinNode (backward compatibility)', () => {
    // Simulate an old saved JSON that used NodeType.kMerge
    // Since kMerge is an alias for kJoin, this should deserialize correctly
    const oldJson = `{
      "nodes": [
        {
          "nodeId": "0",
          "type": "${NodeType.kMerge}",
          "state": {
            "leftQueryAlias": "left",
            "rightQueryAlias": "right",
            "conditionType": "equality",
            "leftColumn": "id",
            "rightColumn": "id",
            "sqlExpression": "",
            "leftNodeId": "1",
            "rightNodeId": "2"
          },
          "nextNodes": []
        },
        {
          "nodeId": "1",
          "type": "${NodeType.kTable}",
          "state": {
            "tableName": "slice",
            "columnNames": ["id", "ts", "dur", "name"]
          },
          "nextNodes": ["0"]
        },
        {
          "nodeId": "2",
          "type": "${NodeType.kTable}",
          "state": {
            "tableName": "slice",
            "columnNames": ["id", "ts", "dur", "name"]
          },
          "nextNodes": ["0"]
        }
      ],
      "rootNodeIds": ["1", "2"]
    }`;

    const deserializedState = deserializeState(oldJson, trace, sqlModules);

    // Verify the graph structure
    expect(deserializedState.rootNodes.length).toBe(2);

    // Find the join node
    const table1 = deserializedState.rootNodes[0];
    const table2 = deserializedState.rootNodes[1];
    expect(table1.nextNodes.length).toBe(1);
    expect(table2.nextNodes.length).toBe(1);
    expect(table1.nextNodes[0]).toBe(table2.nextNodes[0]); // Same join node

    // Verify it's actually a JoinNode (not just any node)
    const joinNode = table1.nextNodes[0] as JoinNode;
    expect(joinNode).toBeInstanceOf(JoinNode);
    expect(joinNode.type).toBe(NodeType.kJoin); // kMerge should equal kJoin

    // Verify the state was preserved
    expect(joinNode.state.leftQueryAlias).toBe('left');
    expect(joinNode.state.rightQueryAlias).toBe('right');
    expect(joinNode.state.conditionType).toBe('equality');
    expect(joinNode.state.leftColumn).toBe('id');
    expect(joinNode.state.rightColumn).toBe('id');

    // Verify the connections are correct
    expect(joinNode.secondaryInputs.connections.size).toBe(2);
    expect(joinNode.secondaryInputs.connections.get(0)).toBe(table1);
    expect(joinNode.secondaryInputs.connections.get(1)).toBe(table2);
  });

  test('serializes and deserializes union node', () => {
    const tableNode1 = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    const tableNode2 = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    const tableNode3 = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    const sliceTable = sqlModules.getTable('slice')!;
    const unionNode = new UnionNode({
      inputNodes: [tableNode1, tableNode2, tableNode3],
      selectedColumns: [
        {
          name: 'name',
          type: 'STRING',
          checked: true,
          column: sliceTable.columns[0],
        },
        {
          name: 'ts',
          type: 'TIMESTAMP_NS',
          checked: true,
          column: sliceTable.columns[1],
        },
      ],
    });
    tableNode1.nextNodes.push(unionNode);
    tableNode2.nextNodes.push(unionNode);
    tableNode3.nextNodes.push(unionNode);

    const initialState: ExplorePageState = {
      rootNodes: [tableNode1, tableNode2, tableNode3],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(3);
    const deserializedTableNode1 = deserializedState.rootNodes[0];
    const deserializedTableNode2 = deserializedState.rootNodes[1];
    const deserializedTableNode3 = deserializedState.rootNodes[2];
    expect(deserializedTableNode1.nextNodes.length).toBe(1);
    const deserializedUnionNode = deserializedTableNode1
      .nextNodes[0] as UnionNode;
    expect(deserializedUnionNode.secondaryInputs.connections).toBeDefined();
    expect(deserializedUnionNode.secondaryInputs.connections.size).toBe(3);
    expect(
      deserializedUnionNode.secondaryInputs.connections.get(0)?.nodeId,
    ).toBe(deserializedTableNode1.nodeId);
    expect(
      deserializedUnionNode.secondaryInputs.connections.get(1)?.nodeId,
    ).toBe(deserializedTableNode2.nodeId);
    expect(
      deserializedUnionNode.secondaryInputs.connections.get(2)?.nodeId,
    ).toBe(deserializedTableNode3.nodeId);
    expect(deserializedUnionNode.state.selectedColumns.length).toBe(2);
    expect(deserializedUnionNode.state.selectedColumns[0].name).toBe('name');
    expect(deserializedUnionNode.state.selectedColumns[0].checked).toBe(true);
    expect(deserializedUnionNode.state.selectedColumns[1].name).toBe('ts');
    expect(deserializedUnionNode.state.selectedColumns[1].checked).toBe(true);
  });

  test('serializes and deserializes merge node with filters and comment', () => {
    const tableNode1 = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    const tableNode2 = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    const joinNode = new JoinNode({
      leftNode: tableNode1,
      rightNode: tableNode2,
      leftQueryAlias: 'left',
      rightQueryAlias: 'right',
      conditionType: 'equality',
      joinType: 'INNER',
      leftColumn: 'name',
      rightColumn: 'name',
      sqlExpression: '',
      leftColumns: undefined,
      rightColumns: undefined,
    });
    tableNode1.nextNodes.push(joinNode);
    tableNode2.nextNodes.push(joinNode);

    const filterNode = new FilterNode({
      filters: [
        {
          column: 'dur',
          op: '>',
          value: '1000',
        },
      ],
    });
    addConnection(joinNode, filterNode);

    const initialState: ExplorePageState = {
      rootNodes: [tableNode1, tableNode2],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(2);
    const deserializedTableNode1 = deserializedState.rootNodes[0];
    expect(deserializedTableNode1.nextNodes.length).toBe(1);
    const deserializedJoinNode = deserializedTableNode1
      .nextNodes[0] as JoinNode;
    expect(deserializedJoinNode.nextNodes.length).toBe(1);
    const deserializedFilterNode = deserializedJoinNode
      .nextNodes[0] as FilterNode;
    expect(deserializedFilterNode.state.filters).toBeDefined();
    expect(deserializedFilterNode.state.filters?.length).toBe(1);
    expect(deserializedFilterNode.state.filters?.[0].column).toBe('dur');
    expect(deserializedFilterNode.state.filters?.[0].op).toBe('>');
  });

  test('serializes and deserializes union node with filters and comment', () => {
    const tableNode1 = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    const tableNode2 = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    const sliceTable = sqlModules.getTable('slice')!;
    const unionNode = new UnionNode({
      inputNodes: [tableNode1, tableNode2],
      selectedColumns: [
        {
          name: 'name',
          type: 'STRING',
          checked: true,
          column: sliceTable.columns[0],
        },
        {
          name: 'ts',
          type: 'TIMESTAMP_NS',
          checked: false,
          column: sliceTable.columns[1],
        },
      ],
    });
    unionNode.comment = 'Union of slice sources excluding idle';
    tableNode1.nextNodes.push(unionNode);
    tableNode2.nextNodes.push(unionNode);

    const initialState: ExplorePageState = {
      rootNodes: [tableNode1, tableNode2],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(2);
    const deserializedTableNode1 = deserializedState.rootNodes[0];
    expect(deserializedTableNode1.nextNodes.length).toBe(1);
    const deserializedUnionNode = deserializedTableNode1
      .nextNodes[0] as UnionNode;
    // Verify unchecked column is preserved
    expect(deserializedUnionNode.state.selectedColumns[1].checked).toBe(false);
  });

  test('join node requires explicit column selection', () => {
    const tableNode1 = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    const tableNode2 = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    // Both tables have the same columns
    // When leftColumns/rightColumns are undefined, columns default to unchecked
    const joinNode = new JoinNode({
      leftNode: tableNode1,
      rightNode: tableNode2,
      leftQueryAlias: 'left',
      rightQueryAlias: 'right',
      conditionType: 'equality',
      joinType: 'INNER',
      leftColumn: 'name',
      rightColumn: 'name',
      sqlExpression: '',
      leftColumns: undefined,
      rightColumns: undefined,
    });
    tableNode1.nextNodes.push(joinNode);
    tableNode2.nextNodes.push(joinNode);

    // With no columns checked, finalCols should be empty
    expect(joinNode.finalCols).toEqual([]);

    // Now explicitly check some columns
    expect(joinNode.state.leftColumns).toBeDefined();
    // Find and check the 'name' column from left
    const nameCol = joinNode.state.leftColumns!.find(
      (c) => c.column.name === 'name',
    );
    expect(nameCol).toBeDefined();
    nameCol!.checked = true;

    expect(joinNode.state.rightColumns).toBeDefined();
    // Find and check 'ts' column from right (to show we can select any column)
    const tsCol = joinNode.state.rightColumns!.find(
      (c) => c.column.name === 'ts',
    );
    expect(tsCol).toBeDefined();
    tsCol!.checked = true;

    // Verify finalCols now includes only checked columns
    const finalCols = joinNode.finalCols;
    const colNames = finalCols.map((c: ColumnInfo) => c.name);

    // Should include 'name' from left and 'ts' from right
    expect(colNames).toContain('name');
    expect(colNames).toContain('ts');
    expect(colNames.length).toBe(2);

    // Verify the structured query includes select_columns
    const sq = joinNode.getStructuredQuery();
    expect(sq).toBeDefined();
    expect(sq?.selectColumns).toBeDefined();
    expect(sq?.selectColumns?.length).toBe(finalCols.length);
    expect(sq?.selectColumns?.map((c) => c.columnNameOrExpression)).toEqual(
      colNames,
    );
  });

  test('serializes and deserializes modify columns node with selected columns', () => {
    const tableNode = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    const sliceTable = sqlModules.getTable('slice')!;
    const modifyColumnsNode = new ModifyColumnsNode({
      selectedColumns: [
        {
          name: 'name',
          type: 'STRING',
          checked: true,
          column: sliceTable.columns[0],
        },
        {
          name: 'ts',
          type: 'TIMESTAMP_NS',
          checked: true,
          column: sliceTable.columns[1],
        },
      ],
    });
    // Manually connect without triggering onPrevNodesUpdated to preserve
    // the test's explicitly provided selectedColumns
    tableNode.nextNodes.push(modifyColumnsNode);
    modifyColumnsNode.primaryInput = tableNode;

    const initialState: ExplorePageState = {
      rootNodes: [tableNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(1);
    const deserializedTableNode = deserializedState.rootNodes[0];
    expect(deserializedTableNode.nextNodes.length).toBe(1);
    const deserializedNode = deserializedTableNode
      .nextNodes[0] as ModifyColumnsNode;
    expect(deserializedNode.state.selectedColumns.length).toBe(2);
    expect(deserializedNode.state.selectedColumns[0].name).toBe('name');
    expect(deserializedNode.state.selectedColumns[1].name).toBe('ts');
  });

  test('serializes modify columns node without primaryInput', () => {
    // Create a modify columns node without a primaryInput (edge case)
    const modifyColumnsNode = new ModifyColumnsNode({
      selectedColumns: [],
    });

    const initialState: ExplorePageState = {
      rootNodes: [modifyColumnsNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    // Should be able to serialize without throwing
    const json = serializeState(initialState);
    const serialized = JSON.parse(json);

    // Verify primaryInputId is undefined in serialized state
    const serializedNode = serialized.nodes.find(
      (n: SerializedNode) => n.nodeId === modifyColumnsNode.nodeId,
    );
    expect(serializedNode).toBeDefined();
    expect(serializedNode.state.primaryInputId).toBeUndefined();
  });

  test('serializes and deserializes aggregation node with multiple aggregations', () => {
    const tableNode = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    const sliceTable = sqlModules.getTable('slice')!;
    const aggregationNode = new AggregationNode({
      groupByColumns: [
        {
          name: 'name',
          type: 'STRING',
          checked: true,
          column: sliceTable.columns[0],
        },
        {
          name: 'ts',
          type: 'TIMESTAMP_NS',
          checked: true,
          column: sliceTable.columns[1],
        },
      ],
      aggregations: [
        {
          column: {
            name: 'dur',
            type: 'TIMESTAMP_NS',
            checked: true,
            column: sliceTable.columns[2],
          },
          aggregationOp: 'SUM',
          newColumnName: 'total_dur',
        },
        {
          column: {
            name: 'dur',
            type: 'TIMESTAMP_NS',
            checked: true,
            column: sliceTable.columns[2],
          },
          aggregationOp: 'AVG',
          newColumnName: 'avg_dur',
        },
        {
          column: {
            name: 'dur',
            type: 'TIMESTAMP_NS',
            checked: true,
            column: sliceTable.columns[2],
          },
          aggregationOp: 'COUNT',
          newColumnName: 'count',
        },
      ],
    });
    // Manually connect without triggering onPrevNodesUpdated to preserve
    // the test's explicitly provided groupByColumns
    tableNode.nextNodes.push(aggregationNode);
    aggregationNode.primaryInput = tableNode;

    const initialState: ExplorePageState = {
      rootNodes: [tableNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(1);
    const deserializedTableNode = deserializedState.rootNodes[0];
    expect(deserializedTableNode.nextNodes.length).toBe(1);
    const deserializedAggregationNode = deserializedTableNode
      .nextNodes[0] as AggregationNode;
    expect(deserializedAggregationNode.state.groupByColumns.length).toBe(2);
    expect(deserializedAggregationNode.state.groupByColumns[1].name).toBe('ts');
    expect(deserializedAggregationNode.state.aggregations.length).toBe(3);
    expect(
      deserializedAggregationNode.state.aggregations[0].aggregationOp,
    ).toBe('SUM');
    expect(
      deserializedAggregationNode.state.aggregations[0].newColumnName,
    ).toBe('total_dur');
    expect(
      deserializedAggregationNode.state.aggregations[1].aggregationOp,
    ).toBe('AVG');
    expect(
      deserializedAggregationNode.state.aggregations[1].newColumnName,
    ).toBe('avg_dur');
    expect(
      deserializedAggregationNode.state.aggregations[2].aggregationOp,
    ).toBe('COUNT');
  });

  test('serializes and deserializes complex multi-node chain', () => {
    const tableNode = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    const filterNode = new FilterNode({
      filters: [
        {
          column: 'name',
          op: '!=',
          value: 'idle',
        },
      ],
    });
    addConnection(tableNode, filterNode);

    const sliceTable = sqlModules.getTable('slice')!;
    const modifyNode = new ModifyColumnsNode({
      selectedColumns: [
        {
          name: 'name',
          type: 'STRING',
          checked: true,
          column: sliceTable.columns[0],
        },
      ],
    });
    addConnection(filterNode, modifyNode);

    const aggregationNode = new AggregationNode({
      groupByColumns: [
        {
          name: 'name',
          type: 'STRING',
          checked: true,
          column: sliceTable.columns[0],
        },
      ],
      aggregations: [
        {
          column: {
            name: 'dur_ms',
            type: 'TIMESTAMP_NS',
            checked: true,
            column: sliceTable.columns[2],
          },
          aggregationOp: 'SUM',
          newColumnName: 'total_dur_ms',
        },
      ],
    });
    addConnection(modifyNode, aggregationNode);

    const sortNode = new SortNode({
      sortCriteria: [{colName: 'total_dur_ms', direction: 'ASC'}],
    });
    addConnection(aggregationNode, sortNode);

    const limitNode = new LimitAndOffsetNode({
      limit: 10,
      offset: 0,
    });
    addConnection(sortNode, limitNode);

    const initialState: ExplorePageState = {
      rootNodes: [tableNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map([
        [tableNode.nodeId, {x: 0, y: 0}],
        [filterNode.nodeId, {x: 0, y: 50}],
        [modifyNode.nodeId, {x: 0, y: 100}],
        [aggregationNode.nodeId, {x: 0, y: 200}],
        [sortNode.nodeId, {x: 0, y: 300}],
        [limitNode.nodeId, {x: 0, y: 400}],
      ]),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    // Verify chain structure
    expect(deserializedState.rootNodes.length).toBe(1);
    const node1 = deserializedState.rootNodes[0] as TableSourceNode;
    expect(node1.nextNodes.length).toBe(1);

    const node2 = node1.nextNodes[0] as FilterNode;
    expect(node2.primaryInput?.nodeId).toBe(node1.nodeId);
    expect(node2.state.filters?.length).toBe(1);
    expect(node2.nextNodes.length).toBe(1);

    const node3 = node2.nextNodes[0] as ModifyColumnsNode;
    expect(node3.primaryInput?.nodeId).toBe(node2.nodeId);
    expect(node3.nextNodes.length).toBe(1);

    const node4 = node3.nextNodes[0] as AggregationNode;
    expect(node4.primaryInput?.nodeId).toBe(node3.nodeId);
    expect(node4.nextNodes.length).toBe(1);

    const node5 = node4.nextNodes[0] as SortNode;
    expect(node5.primaryInput?.nodeId).toBe(node4.nodeId);
    expect(node5.nextNodes.length).toBe(1);

    const node6 = node5.nextNodes[0] as LimitAndOffsetNode;
    expect(node6.primaryInput?.nodeId).toBe(node5.nodeId);
    expect(node6.state.limit).toBe(10);

    // Verify all layouts preserved
    expect(deserializedState.nodeLayouts.size).toBe(6);
  });

  test('serializes and deserializes branching graph', () => {
    const tableNode = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    const sliceTable = sqlModules.getTable('slice')!;
    const modifyNode1 = new ModifyColumnsNode({
      selectedColumns: [
        {
          name: 'name',
          type: 'STRING',
          checked: true,
          column: sliceTable.columns[0],
        },
      ],
    });
    addConnection(tableNode, modifyNode1);

    const modifyNode2 = new ModifyColumnsNode({
      selectedColumns: [
        {
          name: 'ts',
          type: 'TIMESTAMP_NS',
          checked: true,
          column: sliceTable.columns[1],
        },
      ],
    });
    addConnection(tableNode, modifyNode2);

    const initialState: ExplorePageState = {
      rootNodes: [tableNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(1);
    const deserializedTableNode = deserializedState.rootNodes[0];
    expect(deserializedTableNode.nextNodes.length).toBe(2);

    const branch1 = deserializedTableNode.nextNodes[0] as ModifyColumnsNode;
    const branch2 = deserializedTableNode.nextNodes[1] as ModifyColumnsNode;

    expect(branch1.primaryInput?.nodeId).toBe(deserializedTableNode.nodeId);
    expect(branch2.primaryInput?.nodeId).toBe(deserializedTableNode.nodeId);
  });

  test('deserializes graph without nodeLayouts field (auto-layout)', () => {
    // Create a real node and serialize it
    const sliceNode = new SlicesSourceNode({});
    const initialState: ExplorePageState = {
      rootNodes: [sliceNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };
    const serialized = serializeState(initialState);

    // Parse the JSON and remove the nodeLayouts field
    const parsed = JSON.parse(serialized);
    delete parsed.nodeLayouts;
    const jsonWithoutLayouts = JSON.stringify(parsed);

    const deserializedState = deserializeState(
      jsonWithoutLayouts,
      trace,
      sqlModules,
    );

    expect(deserializedState.rootNodes.length).toBe(1);
    expect(deserializedState.nodeLayouts.size).toBe(0);
    const deserializedNode = deserializedState.rootNodes[0] as SlicesSourceNode;
    expect(deserializedNode).toBeInstanceOf(SlicesSourceNode);
  });

  // ========================================
  // FilterDuringNode Serialization Tests
  // ========================================

  test('serializes and deserializes filter during node with single secondary input', () => {
    const slicesNode = new SlicesSourceNode({});
    const timeRangeNode = new TimeRangeSourceNode({trace});

    const filterDuringNode = new FilterDuringNode({
      partitionColumns: ['utid'],
      clipToIntervals: false,
    });

    // Connect slicesNode as primaryInput (from above)
    slicesNode.nextNodes.push(filterDuringNode);
    filterDuringNode.primaryInput = slicesNode;

    // Connect timeRangeNode as secondaryInput (from left)
    timeRangeNode.nextNodes.push(filterDuringNode);
    filterDuringNode.secondaryInputs.connections.set(0, timeRangeNode);

    const initialState: ExplorePageState = {
      rootNodes: [slicesNode, timeRangeNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(2);

    // Find the deserialized slices node (first root)
    const deserializedSlicesNode = deserializedState.rootNodes[0];
    expect(deserializedSlicesNode).toBeInstanceOf(SlicesSourceNode);
    expect(deserializedSlicesNode.nextNodes.length).toBe(1);

    // Find the deserialized filter during node
    const deserializedFilterDuringNode = deserializedSlicesNode
      .nextNodes[0] as FilterDuringNode;
    expect(deserializedFilterDuringNode).toBeInstanceOf(FilterDuringNode);

    // Verify primaryInput connection
    expect(deserializedFilterDuringNode.primaryInput?.nodeId).toBe(
      deserializedSlicesNode.nodeId,
    );

    // Verify secondaryInput connection
    expect(deserializedFilterDuringNode.secondaryInputs.connections.size).toBe(
      1,
    );

    // Verify state preserved
    expect(deserializedFilterDuringNode.state.partitionColumns).toEqual([
      'utid',
    ]);
    expect(deserializedFilterDuringNode.state.clipToIntervals).toBe(false);
  });

  // ========================================
  // Complex Connection Scenarios
  // ========================================

  test('serializes and deserializes complex graph with diamond pattern', () => {
    // Diamond pattern: table -> filter -> |
    //                  table -> sort   -> | -> union
    const tableNode1 = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    const tableNode2 = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    const filterNode = new FilterNode({
      filters: [{column: 'name', op: '=', value: 'foo'}],
    });
    addConnection(tableNode1, filterNode);

    const sortNode = new SortNode({
      sortCriteria: [{colName: 'ts', direction: 'ASC'}],
    });
    addConnection(tableNode2, sortNode);

    const sliceTable = sqlModules.getTable('slice')!;
    const unionNode = new UnionNode({
      inputNodes: [filterNode, sortNode],
      selectedColumns: [
        {
          name: 'name',
          type: 'STRING',
          checked: true,
          column: sliceTable.columns[0],
        },
      ],
    });
    filterNode.nextNodes.push(unionNode);
    sortNode.nextNodes.push(unionNode);

    const initialState: ExplorePageState = {
      rootNodes: [tableNode1, tableNode2],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(2);

    // Verify the diamond converges at union
    const deserializedTableNode1 = deserializedState.rootNodes[0];
    const deserializedTableNode2 = deserializedState.rootNodes[1];

    const deserializedFilterNode = deserializedTableNode1
      .nextNodes[0] as FilterNode;
    const deserializedSortNode = deserializedTableNode2
      .nextNodes[0] as SortNode;

    expect(deserializedFilterNode.nextNodes.length).toBe(1);
    expect(deserializedSortNode.nextNodes.length).toBe(1);

    // Both should converge to the same union node
    const deserializedUnionNode1 = deserializedFilterNode.nextNodes[0];
    const deserializedUnionNode2 = deserializedSortNode.nextNodes[0];
    expect(deserializedUnionNode1.nodeId).toBe(deserializedUnionNode2.nodeId);
  });

  test('serializes and deserializes deep chain with 6+ nodes', () => {
    const tableNode = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    const filterNode1 = new FilterNode({
      filters: [{column: 'name', op: '!=', value: 'idle'}],
    });
    addConnection(tableNode, filterNode1);

    const filterNode2 = new FilterNode({
      filters: [{column: 'dur', op: '>', value: 1000}],
    });
    addConnection(filterNode1, filterNode2);

    const sortNode = new SortNode({
      sortCriteria: [{colName: 'ts', direction: 'ASC'}],
    });
    addConnection(filterNode2, sortNode);

    const sliceTable = sqlModules.getTable('slice')!;
    const aggregationNode = new AggregationNode({
      groupByColumns: [
        {
          name: 'name',
          type: 'STRING',
          checked: true,
          column: sliceTable.columns[0],
        },
      ],
      aggregations: [
        {
          column: {
            name: 'dur',
            type: 'TIMESTAMP_NS',
            checked: true,
            column: sliceTable.columns[2],
          },
          aggregationOp: 'SUM',
          newColumnName: 'total_dur',
        },
      ],
    });
    addConnection(sortNode, aggregationNode);

    const limitNode = new LimitAndOffsetNode({limit: 100, offset: 0});
    addConnection(aggregationNode, limitNode);

    const initialState: ExplorePageState = {
      rootNodes: [tableNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(1);

    // Walk the chain and verify all connections
    let currentNode = deserializedState.rootNodes[0];
    expect(currentNode).toBeInstanceOf(TableSourceNode);

    currentNode = currentNode.nextNodes[0];
    expect(currentNode).toBeInstanceOf(FilterNode);
    expect((currentNode as FilterNode).primaryInput).toBeDefined();

    currentNode = currentNode.nextNodes[0];
    expect(currentNode).toBeInstanceOf(FilterNode);
    expect((currentNode as FilterNode).primaryInput).toBeDefined();

    currentNode = currentNode.nextNodes[0];
    expect(currentNode).toBeInstanceOf(SortNode);
    expect((currentNode as SortNode).primaryInput).toBeDefined();

    currentNode = currentNode.nextNodes[0];
    expect(currentNode).toBeInstanceOf(AggregationNode);
    expect((currentNode as AggregationNode).primaryInput).toBeDefined();

    currentNode = currentNode.nextNodes[0];
    expect(currentNode).toBeInstanceOf(LimitAndOffsetNode);
    expect((currentNode as LimitAndOffsetNode).primaryInput).toBeDefined();
    expect(currentNode.nextNodes.length).toBe(0); // End of chain
  });

  test('serializes and deserializes graph with interval intersect and union combined', () => {
    const tableNode1 = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    const tableNode2 = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    const tableNode3 = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    // Create interval intersect with first two tables
    const intervalIntersectNode = new IntervalIntersectNode({
      inputNodes: [tableNode1, tableNode2],
    });
    tableNode1.nextNodes.push(intervalIntersectNode);
    tableNode2.nextNodes.push(intervalIntersectNode);

    // Create union with interval intersect result and third table
    const sliceTable = sqlModules.getTable('slice')!;
    const unionNode = new UnionNode({
      inputNodes: [intervalIntersectNode, tableNode3],
      selectedColumns: [
        {
          name: 'name',
          type: 'STRING',
          checked: true,
          column: sliceTable.columns[0],
        },
      ],
    });
    intervalIntersectNode.nextNodes.push(unionNode);
    tableNode3.nextNodes.push(unionNode);

    const initialState: ExplorePageState = {
      rootNodes: [tableNode1, tableNode2, tableNode3],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(3);

    // Find interval intersect node
    const deserializedTableNode1 = deserializedState.rootNodes[0];
    const deserializedIntervalIntersectNode = deserializedTableNode1
      .nextNodes[0] as IntervalIntersectNode;
    expect(deserializedIntervalIntersectNode).toBeInstanceOf(
      IntervalIntersectNode,
    );

    // Verify interval intersect has 2 secondary inputs
    expect(
      deserializedIntervalIntersectNode.secondaryInputs.connections.size,
    ).toBe(2);

    // Find union node
    expect(deserializedIntervalIntersectNode.nextNodes.length).toBe(1);
    const deserializedUnionNode = deserializedIntervalIntersectNode
      .nextNodes[0] as UnionNode;
    expect(deserializedUnionNode).toBeInstanceOf(UnionNode);

    // Union should have 2 inputs: interval intersect and tableNode3
    expect(deserializedUnionNode.secondaryInputs.connections.size).toBe(2);
  });

  // ========================================
  // Connection Removal Tests
  // ========================================

  test('handles removing connection from middle of chain', () => {
    const tableNode = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    const filterNode = new FilterNode({
      filters: [{column: 'name', op: '=', value: 'foo'}],
    });
    addConnection(tableNode, filterNode);

    const sortNode = new SortNode({
      sortCriteria: [{colName: 'ts', direction: 'ASC'}],
    });
    addConnection(filterNode, sortNode);

    const limitNode = new LimitAndOffsetNode({limit: 10, offset: 0});
    addConnection(sortNode, limitNode);

    // Remove the filter node from the chain
    removeConnection(tableNode, filterNode);
    removeConnection(filterNode, sortNode);

    // Reconnect directly
    addConnection(tableNode, sortNode);

    const initialState: ExplorePageState = {
      rootNodes: [tableNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    // Table -> Sort -> Limit (filter should be orphaned)
    const deserializedTableNode = deserializedState.rootNodes[0];
    expect(deserializedTableNode.nextNodes.length).toBe(1);

    const deserializedSortNode = deserializedTableNode.nextNodes[0] as SortNode;
    expect(deserializedSortNode).toBeInstanceOf(SortNode);
    expect(deserializedSortNode.primaryInput?.nodeId).toBe(
      deserializedTableNode.nodeId,
    );

    expect(deserializedSortNode.nextNodes.length).toBe(1);
    const deserializedLimitNode = deserializedSortNode
      .nextNodes[0] as LimitAndOffsetNode;
    expect(deserializedLimitNode).toBeInstanceOf(LimitAndOffsetNode);
  });

  test('handles removing one input from multi-input node', () => {
    const tableNode1 = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    const tableNode2 = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    const tableNode3 = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    const sliceTable = sqlModules.getTable('slice')!;
    const unionNode = new UnionNode({
      inputNodes: [tableNode1, tableNode2, tableNode3],
      selectedColumns: [
        {
          name: 'name',
          type: 'STRING',
          checked: true,
          column: sliceTable.columns[0],
        },
      ],
    });
    tableNode1.nextNodes.push(unionNode);
    tableNode2.nextNodes.push(unionNode);
    tableNode3.nextNodes.push(unionNode);

    // Remove the second table from union
    removeConnection(tableNode2, unionNode);

    const initialState: ExplorePageState = {
      rootNodes: [tableNode1, tableNode2, tableNode3],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    // Find union node
    const deserializedTableNode1 = deserializedState.rootNodes[0];
    const deserializedUnionNode = deserializedTableNode1
      .nextNodes[0] as UnionNode;

    // Union should only have 2 inputs now
    expect(deserializedUnionNode.secondaryInputs.connections.size).toBe(2);

    // tableNode2 should have no nextNodes
    const deserializedTableNode2 = deserializedState.rootNodes[1];
    expect(deserializedTableNode2.nextNodes.length).toBe(0);
  });

  test('handles removing all inputs from multi-input node', () => {
    const tableNode1 = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    const tableNode2 = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    const sliceTable = sqlModules.getTable('slice')!;
    const unionNode = new UnionNode({
      inputNodes: [tableNode1, tableNode2],
      selectedColumns: [
        {
          name: 'name',
          type: 'STRING',
          checked: true,
          column: sliceTable.columns[0],
        },
      ],
    });
    tableNode1.nextNodes.push(unionNode);
    tableNode2.nextNodes.push(unionNode);

    // Remove all inputs
    removeConnection(tableNode1, unionNode);
    removeConnection(tableNode2, unionNode);

    const initialState: ExplorePageState = {
      rootNodes: [tableNode1, tableNode2, unionNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    // Union should be a root node now with no inputs
    expect(deserializedState.rootNodes.length).toBe(3);

    // Find the union node (should be the third root)
    const deserializedUnionNode = deserializedState.rootNodes.find(
      (n) => n.type === NodeType.kUnion,
    ) as UnionNode;
    expect(deserializedUnionNode).toBeDefined();
    expect(deserializedUnionNode.secondaryInputs.connections.size).toBe(0);
  });

  test('handles graph with multiple disconnected subgraphs', () => {
    // First subgraph: table1 -> filter
    const tableNode1 = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });
    const filterNode = new FilterNode({
      filters: [{column: 'name', op: '=', value: 'foo'}],
    });
    addConnection(tableNode1, filterNode);

    // Second subgraph: table2 -> sort -> limit
    const tableNode2 = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });
    const sortNode = new SortNode({
      sortCriteria: [{colName: 'ts', direction: 'ASC'}],
    });
    addConnection(tableNode2, sortNode);
    const limitNode = new LimitAndOffsetNode({limit: 100, offset: 0});
    addConnection(sortNode, limitNode);

    // Third subgraph: standalone slices source
    const slicesNode = new SlicesSourceNode({});

    const initialState: ExplorePageState = {
      rootNodes: [tableNode1, tableNode2, slicesNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(3);

    // Verify first subgraph
    const deserializedTableNode1 = deserializedState.rootNodes[0];
    expect(deserializedTableNode1.nextNodes.length).toBe(1);
    expect(deserializedTableNode1.nextNodes[0]).toBeInstanceOf(FilterNode);

    // Verify second subgraph
    const deserializedTableNode2 = deserializedState.rootNodes[1];
    expect(deserializedTableNode2.nextNodes.length).toBe(1);
    const deserializedSortNode = deserializedTableNode2.nextNodes[0];
    expect(deserializedSortNode).toBeInstanceOf(SortNode);
    expect(deserializedSortNode.nextNodes.length).toBe(1);
    expect(deserializedSortNode.nextNodes[0]).toBeInstanceOf(
      LimitAndOffsetNode,
    );

    // Verify third subgraph
    const deserializedSlicesNode = deserializedState.rootNodes[2];
    expect(deserializedSlicesNode).toBeInstanceOf(SlicesSourceNode);
    expect(deserializedSlicesNode.nextNodes.length).toBe(0);
  });

  test('serializes and deserializes time range source node', () => {
    const timeRangeNode = new TimeRangeSourceNode({
      trace,
      start: Time.fromRaw(1000n),
      end: Time.fromRaw(2000n),
    });

    const initialState: ExplorePageState = {
      rootNodes: [timeRangeNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(1);
    const deserializedNode = deserializedState
      .rootNodes[0] as TimeRangeSourceNode;
    expect(deserializedNode).toBeInstanceOf(TimeRangeSourceNode);
    expect(deserializedNode.state.start).toEqual(Time.fromRaw(1000n));
    expect(deserializedNode.state.end).toEqual(Time.fromRaw(2000n));
  });

  test('serializes and deserializes labels', () => {
    const sliceNode = new SlicesSourceNode({});
    const labels = [
      {
        id: 'label-1',
        x: 100,
        y: 200,
        width: 300,
        text: 'First label',
      },
      {
        id: 'label-2',
        x: 400,
        y: 500,
        width: 250,
        text: 'Second label',
      },
    ];

    const initialState: ExplorePageState = {
      rootNodes: [sliceNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels,
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    // Labels are normalized so top-left is at (minX, minY)
    // Original: label-1 (100, 200), label-2 (400, 500)
    // After normalization: label-1 (0, 0), label-2 (300, 300)
    expect(deserializedState.labels).toEqual([
      {
        id: 'label-1',
        x: 0,
        y: 0,
        width: 300,
        text: 'First label',
      },
      {
        id: 'label-2',
        x: 300,
        y: 300,
        width: 250,
        text: 'Second label',
      },
    ]);
  });

  test('handles state without labels', () => {
    const sliceNode = new SlicesSourceNode({});
    const initialState: ExplorePageState = {
      rootNodes: [sliceNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.labels).toEqual([]);
  });

  test('handles empty labels array', () => {
    const sliceNode = new SlicesSourceNode({});
    const initialState: ExplorePageState = {
      rootNodes: [sliceNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.labels).toEqual([]);
  });

  test('labels survive JSON round-trip with special characters', () => {
    const sliceNode = new SlicesSourceNode({});
    const labels = [
      {
        id: 'label-1',
        x: 0,
        y: 0,
        width: 200,
        text: 'Label with "quotes" and \n newlines \t tabs',
      },
      {
        id: 'label-2',
        x: 0,
        y: 0,
        width: 200,
        text: 'Unicode: 你好 🎉',
      },
    ];

    const initialState: ExplorePageState = {
      rootNodes: [sliceNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels,
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.labels).toEqual(labels);
  });

  test('normalizes coordinates so top-left corner starts at minimum coordinates', () => {
    const tableNode = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });
    const sliceNode = new SlicesSourceNode({});

    const initialState: ExplorePageState = {
      rootNodes: [tableNode, sliceNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map([
        [tableNode.nodeId, {x: 500, y: 300}],
        [sliceNode.nodeId, {x: 800, y: 600}],
      ]),
      labels: [
        {
          id: 'label-1',
          x: 400,
          y: 250,
          width: 100,
          text: 'Test label',
        },
      ],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    // After normalization, the minimum coordinates (400, 250) become (0, 0)
    // All other coordinates are shifted accordingly
    const tableNodeId = deserializedState.rootNodes[0].nodeId;
    const sliceNodeId = deserializedState.rootNodes[1].nodeId;

    expect(deserializedState.nodeLayouts.get(tableNodeId)).toEqual({
      x: 100,
      y: 50,
    }); // (500-400, 300-250)
    expect(deserializedState.nodeLayouts.get(sliceNodeId)).toEqual({
      x: 400,
      y: 350,
    }); // (800-400, 600-250)
    expect(deserializedState.labels[0]).toEqual({
      id: 'label-1',
      x: 0,
      y: 0,
      width: 100,
      text: 'Test label',
    }); // (400-400, 250-250)
  });

  test('does not modify coordinates when already normalized at (0, 0)', () => {
    const tableNode = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });

    const initialState: ExplorePageState = {
      rootNodes: [tableNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map([[tableNode.nodeId, {x: 0, y: 0}]]),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    const tableNodeId = deserializedState.rootNodes[0].nodeId;
    expect(deserializedState.nodeLayouts.get(tableNodeId)).toEqual({
      x: 0,
      y: 0,
    });
  });

  test('serializeState normalizes coordinates in the exported JSON', () => {
    const tableNode = new TableSourceNode({
      sqlTable: sqlModules.getTable('slice'),
      trace,
      sqlModules,
    });
    const sliceNode = new SlicesSourceNode({});

    // State with non-zero minimum coordinates
    const initialState: ExplorePageState = {
      rootNodes: [tableNode, sliceNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map([
        [tableNode.nodeId, {x: 500, y: 300}],
        [sliceNode.nodeId, {x: 800, y: 600}],
      ]),
      labels: [{id: 'label-1', x: 400, y: 250, width: 100, text: 'Test label'}],
    };

    const json = serializeState(initialState);
    const parsed = JSON.parse(json);

    // The exported JSON should have normalized coordinates
    // Minimum is (400, 250) from the label, so all coordinates shift by that
    expect(parsed.nodeLayouts[tableNode.nodeId]).toEqual({
      x: 100,
      y: 50,
    }); // (500-400, 300-250)
    expect(parsed.nodeLayouts[sliceNode.nodeId]).toEqual({
      x: 400,
      y: 350,
    }); // (800-400, 600-250)
    expect(parsed.labels[0]).toEqual({
      id: 'label-1',
      x: 0,
      y: 0,
      width: 100,
      text: 'Test label',
    }); // (400-400, 250-250)
  });
});
