// Copyright (C) 2025 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.

// QUERY EXECUTION MODEL
// ====================
//
// The Data Explorer uses a two-phase execution model with centralized control
// in QueryExecutionService.processNode().
//
// CENTRALIZED ARCHITECTURE
// ------------------------
// All autoExecute logic is handled by QueryExecutionService.processNode():
//
// | autoExecute | manual | Behavior                                      |
// |-------------|--------|-----------------------------------------------|
// | true        | false  | Analyze + execute if query changed            |
// | true        | true   | Analyze + execute (forced)                    |
// | false       | false  | Skip everything (save engine queries)         |
// | false       | true   | Analyze + execute (user clicked "Run Query")  |
//
// PHASE 1: ANALYSIS (Validation)
// ------------------------------
// When node state changes, NodePanel calls service.processNode({ manual: false }).
// The service decides whether to analyze based on autoExecute flag.
// If analysis runs:
// 1. Sends structured queries to the engine
// 2. Engine VALIDATES the query and returns generated SQL (doesn't execute)
// 3. Returns a Query object: {sql, textproto, modules, preambles, columns}
//
// PHASE 2: EXECUTION (Materialization)
// ------------------------------------
// The service decides whether to execute based on autoExecute and manual flags.
// If execution runs:
// 1. Materializes the query into a PERFETTO table
// 2. SQL = modules + preambles + query.sql
// 3. Table name: _exp_materialized_{sanitizedNodeId}
// 4. Creates SQLDataSource pointing to the materialized table
// 5. Fetches metadata (COUNT and column info) from materialized table
// 6. SQLDataSource handles server-side pagination, filtering, sorting
// 7. Updates node.state.issues with any errors/warnings
// 8. For SqlSourceNode, updates available columns
//
// Auto-execute is set to FALSE for:
// - SqlSourceNode: User writes SQL manually, should control execution
// - IntervalIntersectNode: Multi-node operation, potentially expensive
// - UnionNode: Multi-node operation, potentially expensive
// - FilterDuringNode: Multi-node operation, potentially expensive
//
// STATE MANAGEMENT
// ---------------
// - this.query: Current validated query (from analysis phase)
//   * Single source of truth for query state
//   * Updated by both automatic analysis (NodePanel) and manual execution (Builder)
//   * Passed to NodePanel as a prop for rendering SQL/Proto tabs
// - this.response: Query results from execution
// - this.dataSource: Wrapped data source for DataGrid display
//
// QUERY STATE FLOW
// ----------------
// Automatic execution (autoExecute=true):
//   1. NodePanel.updateQuery() → processNode({ manual: false })
//   2. onAnalysisComplete → sets NodePanel.currentQuery
//   3. onAnalysisComplete → calls onQueryAnalyzed callback → sets Builder.query
//   4. Builder passes query as prop to NodePanel
//   5. NodePanel.renderContent() uses attrs.query ?? this.currentQuery
//
// Manual execution (autoExecute=false):
//   1. User clicks "Run Query" button → Builder calls processNode({ manual: true })
//   2. onAnalysisComplete → sets Builder.query
//   3. onAnalysisComplete → calls onNodeQueryAnalyzed callback → sets Builder.query
//   4. Builder passes query as prop to NodePanel
//   5. NodePanel.renderContent() uses attrs.query (this.currentQuery may be undefined)
//
// This ensures SQL/Proto tabs display correctly for both automatic and manual execution.
//
// RACE CONDITION PREVENTION
// -------------------------
// The onNodeQueryAnalyzed callback captures the selected node at creation time to prevent
// stale query leakage when rapidly switching between nodes:
//
// Without validation:
//   1. User selects Node A → async analysis starts → captures callback
//   2. User quickly switches to Node B → Node A destroyed, new callback created
//   3. Node A's analysis completes in background → old callback fires
//   4. Old callback sets this.query = queryForNodeA
//   5. Node B incorrectly displays Node A's query
//
// With validation (callbackNode === this.previousSelectedNode):
//   - Old callbacks from destroyed nodes are ignored
//   - Only queries from the currently selected node update the display

import m from 'mithril';
import {classNames} from '../../../base/classnames';
import {Button} from '../../../widgets/button';
import {Icons} from '../../../base/semantic_icons';
import {Icon} from '../../../widgets/icon';
import type {Trace} from '../../../public/trace';
import type {SqlModules} from '../../dev.perfetto.SqlModules/sql_modules';
import type {QueryNode, Query} from '../query_node';
import {getPrimarySelectedNode} from '../selection_utils';
import {isAQuery, queryToRun} from './query_builder_utils';
import {NodePanel} from './node_panel';
import {Graph, type GraphCallbacks} from './graph/graph';
import {ResultsPanel} from './results_panel';
import {
  DrawerPanel,
  DrawerPanelVisibility,
} from '../../../widgets/drawer_panel';
import {SQLDataSource} from '../../../components/widgets/datagrid/sql_data_source';
import {createSimpleSchema} from '../../../components/widgets/datagrid/sql_schema';
import type {QueryResponse} from '../../../components/query_table/queries';
import QueryPagePlugin from '../../dev.perfetto.QueryPage';
import {SqlSourceNode} from './nodes/sources/sql_source';
import {findErrors, findWarnings} from './query_builder_utils';
import {NodeIssues} from './node_issues';
import {ResultsPanelEmptyState, RoundActionButton} from './widgets';
import type {UIFilter} from './operations/filter';
import type {QueryExecutionService} from './query_execution_service';
import type {Column} from '../../../components/widgets/datagrid/model';
import {ResizeHandle} from '../../../widgets/resize_handle';
import {getAllDownstreamNodes, getAllNodes} from './graph_utils';
import {Popup, PopupPosition} from '../../../widgets/popup';
import type {DataSource} from '../../../components/widgets/datagrid/data_source';
import {NavigationSidePanel} from './navigation_sidepanel';

// Side panel width - must match --pf-qb-side-panel-width in builder.scss
const SIDE_PANEL_WIDTH = 60;

export interface BuilderAttrs {
  readonly trace: Trace;
  readonly sqlModules: SqlModules;
  readonly queryExecutionService: QueryExecutionService;

  // Graph data & callbacks (forwarded to Graph component).
  readonly graphCallbacks: GraphCallbacks;
  readonly rootNodes: QueryNode[];
  readonly selectedNodes: ReadonlySet<string>;
  readonly nodeLayouts: Map<string, {x: number; y: number}>;
  readonly labels: ReadonlyArray<{
    id: string;
    x: number;
    y: number;
    width: number;
    text: string;
  }>;
  readonly loadGeneration?: number;

  // Builder-specific callbacks.
  readonly isExplorerCollapsed?: boolean;
  readonly sidebarWidth?: number;
  readonly onRootNodeCreated: (node: QueryNode) => void;
  readonly onExplorerCollapsedChange?: (collapsed: boolean) => void;
  readonly onSidebarWidthChange?: (width: number) => void;
  readonly onFilterAdd: (
    node: QueryNode,
    filter: UIFilter | UIFilter[],
    filterOperator?: 'AND' | 'OR',
  ) => void;
  readonly onColumnAdd?: (node: QueryNode, column: Column) => void;

  // Starting templates (when page is empty)
  readonly onLoadEmptyTemplate?: () => void;
  readonly onLoadExampleByPath?: (jsonPath: string) => void;
  readonly onLoadDataExplorerTemplate?: () => void;
  readonly onLoadRecentGraph?: (json: string) => void;

  // Node state change callback
  readonly onNodeStateChange?: () => void;

  // Undo / Redo
  readonly onUndo?: () => void;
  readonly onRedo?: () => void;
  readonly canUndo?: boolean;
  readonly canRedo?: boolean;

  // Called when the execute function is ready (or cleared).
  // Allows parent to trigger query execution via keyboard shortcuts (Ctrl+Enter).
  // Receives undefined when no node is selected.
  readonly onExecuteReady?: (
    executeFn: (() => Promise<void>) | undefined,
  ) => void;
}

enum SelectedView {
  kInfo = 0,
  kModify = 1,
  kResult = 2,
}

export class Builder implements m.ClassComponent<BuilderAttrs> {
  private trace: Trace;
  private queryExecutionService: QueryExecutionService;
  private query?: Query | Error;
  private isQueryRunning: boolean = false;
  private isAnalyzing: boolean = false;
  private previousSelectedNode?: QueryNode;
  // Stores selected node for keyboard shortcuts. This duplicates attrs.selectedNode
  // because we need access outside of view() for executeSelectedNode() public method.
  // Updated in view() to stay synchronized with attrs.
  private selectedNode?: QueryNode;
  // Stores all nodes for use in executeSelectedNode() which needs to pass
  // allNodes to prevent auto-drop of disconnected graphs.
  private rootNodes: QueryNode[] = [];
  private response?: QueryResponse;
  private dataSource?: DataSource;
  private drawerVisibility = DrawerPanelVisibility.COLLAPSED;
  private selectedView: SelectedView = SelectedView.kInfo;
  private readonly MIN_SIDEBAR_WIDTH = 250;
  private readonly MAX_SIDEBAR_WIDTH = 800;
  private readonly DEFAULT_SIDEBAR_WIDTH = 500;
  private hasEverSelectedNode = false;
  private onNodeQueryAnalyzed?: (query: Query | Error | undefined) => void;

  constructor({attrs}: m.Vnode<BuilderAttrs>) {
    this.trace = attrs.trace;
    // Use the shared QueryExecutionService from parent
    this.queryExecutionService = attrs.queryExecutionService;
  }

  private handleSidebarResize(attrs: BuilderAttrs, deltaPx: number) {
    const currentWidth = attrs.sidebarWidth ?? this.DEFAULT_SIDEBAR_WIDTH;
    // Subtract delta because the handle is on the left edge of the sidebar
    // Dragging left (negative delta) = narrower sidebar (positive change)
    // Dragging right (positive delta) = wider sidebar (negative change)
    const newWidth = Math.max(
      this.MIN_SIDEBAR_WIDTH,
      Math.min(this.MAX_SIDEBAR_WIDTH, currentWidth - deltaPx),
    );
    attrs.onSidebarWidthChange?.(newWidth);
  }

  view({attrs}: m.CVnode<BuilderAttrs>) {
    const {trace, rootNodes} = attrs;
    const selectedNode = getPrimarySelectedNode(attrs.selectedNodes, rootNodes);

    // Store selectedNode and rootNodes for keyboard shortcuts (executeSelectedNode)
    this.selectedNode = selectedNode;
    this.rootNodes = rootNodes;

    // Notify parent when execute function changes (when selectedNode changes)
    if (selectedNode !== this.previousSelectedNode) {
      if (selectedNode !== undefined) {
        // Provide execute function bound to the current instance
        attrs.onExecuteReady?.(() => this.executeSelectedNode());
      } else {
        // Clear execute function when no node is selected
        attrs.onExecuteReady?.(undefined);
      }
    }

    if (
      selectedNode !== undefined &&
      selectedNode !== this.previousSelectedNode
    ) {
      this.resetQueryState();
      this.isQueryRunning = false;
      this.isAnalyzing = false;

      // Show drawer the first time any node is selected
      if (!this.hasEverSelectedNode) {
        this.drawerVisibility = DrawerPanelVisibility.VISIBLE;
        this.hasEverSelectedNode = true;
      }

      const hasModifyPanel = selectedNode.nodeSpecificModify() != null;
      // If current view is Info, switch to Modify (if available) when selecting a new node
      if (this.selectedView === SelectedView.kInfo && hasModifyPanel) {
        this.selectedView = SelectedView.kModify;
      }
      // If current view is Modify but modify panel is not available, switch to Info
      if (this.selectedView === SelectedView.kModify && !hasModifyPanel) {
        this.selectedView = SelectedView.kInfo;
      }
    }

    const isExplorerCollapsed = attrs.isExplorerCollapsed ?? false;
    const sidebarWidth = attrs.sidebarWidth ?? this.DEFAULT_SIDEBAR_WIDTH;

    // When transitioning to unselected state with collapsed explorer, reappear at minimum size
    if (
      selectedNode === undefined &&
      this.previousSelectedNode !== undefined &&
      isExplorerCollapsed
    ) {
      attrs.onExplorerCollapsedChange?.(false);
      attrs.onSidebarWidthChange?.(this.MIN_SIDEBAR_WIDTH);
    }

    this.previousSelectedNode = selectedNode;

    const layoutClasses =
      classNames(
        'pf-query-builder-layout',
        isExplorerCollapsed && 'explorer-collapsed',
      ) || '';

    // Create the onQueryAnalyzed callback and save it so manual execution can also use it
    // Capture the current selected node to prevent race conditions when switching nodes rapidly
    const callbackNode = selectedNode;
    this.onNodeQueryAnalyzed = (query: Query | Error | undefined) => {
      // Only update query if we're still on the same node
      // This prevents stale queries from Node A being applied when we've switched to Node B
      if (callbackNode === this.previousSelectedNode) {
        this.query = query;
      }
    };

    const hasMultipleNodesSelected = attrs.selectedNodes.size > 1;

    const explorer = hasMultipleNodesSelected
      ? m(
          '.pf-unselected-explorer',
          m(ResultsPanelEmptyState, {
            icon: 'info',
            title: `${attrs.selectedNodes.size} nodes selected`,
          }),
        )
      : selectedNode
        ? m(NodePanel, {
            // The key to force mithril to re-create the component when the
            // selected node changes, preventing state from leaking between
            // different nodes.
            key: selectedNode.nodeId,
            trace,
            node: selectedNode,
            queryExecutionService: this.queryExecutionService,
            allNodes: getAllNodes(rootNodes),
            resolveNode: (nodeId: string) =>
              this.resolveNode(nodeId, rootNodes),
            query: this.query, // Pass the query state from Builder (single source of truth)
            onQueryAnalyzed: this.onNodeQueryAnalyzed,
            onAnalysisStateChange: (isAnalyzing: boolean) => {
              this.isAnalyzing = isAnalyzing;
              if (isAnalyzing) {
                // Clear dataSource at the START of analysis to prevent stale
                // queries with old columns while the table is being re-materialized.
                this.dataSource = undefined;
              }
            },
            onExecutionStart: () => {
              this.isQueryRunning = true;
            },
            onExecutionSuccess: (result) => {
              this.handleExecutionSuccess(selectedNode, result);
            },
            onExecutionError: (error) => {
              this.handleQueryError(selectedNode, error);
              this.isQueryRunning = false;
              m.redraw();
            },
            onchange: () => {
              // When a node's state changes, notify all downstream nodes
              // to update their columns and UI. This ensures that when e.g.
              // a column is renamed in ModifyColumnsNode, the AggregationNode
              // sees the new column name.
              const downstreamNodes = getAllDownstreamNodes(selectedNode);
              for (const node of downstreamNodes) {
                // Skip the node itself (it's included in downstream nodes)
                if (node.nodeId === selectedNode.nodeId) continue;
                node.onPrevNodesUpdated?.();
              }
              attrs.onNodeStateChange?.();
            },
            onUngroupNode: attrs.graphCallbacks.onUngroupNode,
            isCollapsed: isExplorerCollapsed,
            selectedView: this.selectedView,
            onViewChange: (view: number) => {
              this.selectedView = view;
            },
          })
        : m(
            '.pf-unselected-explorer',
            m(NavigationSidePanel, {
              selectedNode,
              onAddSourceNode: attrs.graphCallbacks.onAddSourceNode,
              onLoadExampleByPath: attrs.onLoadExampleByPath,
              onLoadDataExplorerTemplate: attrs.onLoadDataExplorerTemplate,
              onLoadEmptyTemplate: attrs.onLoadEmptyTemplate,
              onLoadRecentGraph: attrs.onLoadRecentGraph,
            }),
          );

    return m(DrawerPanel, {
      className: layoutClasses,
      visibility: this.drawerVisibility,
      onVisibilityChange: (v) => {
        this.drawerVisibility = v;
      },
      startingHeight: 300,
      drawerContent: hasMultipleNodesSelected
        ? m(ResultsPanelEmptyState, {
            icon: 'info',
            title: `${attrs.selectedNodes.size} nodes selected`,
          })
        : selectedNode?.customResultsPanel?.() ??
          (selectedNode
            ? m(ResultsPanel, {
                trace: this.trace,
                query: this.query,
                node: selectedNode,
                response: this.response,
                dataSource: this.dataSource,
                sqlModules: attrs.sqlModules,
                queryExecutionService: this.queryExecutionService,
                isQueryRunning: this.isQueryRunning,
                isAnalyzing: this.isAnalyzing,
                isStale: this.queryExecutionService.isNodeStale(
                  selectedNode.nodeId,
                ),
                onchange: () => {
                  attrs.onNodeStateChange?.();
                },
                onFilterAdd: (filter, filterOperator) => {
                  attrs.onFilterAdd(selectedNode, filter, filterOperator);
                },
                onColumnAdd: attrs.onColumnAdd
                  ? (column) => attrs.onColumnAdd?.(selectedNode, column)
                  : undefined,
                isFullScreen:
                  this.drawerVisibility === DrawerPanelVisibility.FULLSCREEN,
                onFullScreenToggle: () => {
                  if (
                    this.drawerVisibility === DrawerPanelVisibility.FULLSCREEN
                  ) {
                    this.drawerVisibility = DrawerPanelVisibility.VISIBLE;
                  } else {
                    this.drawerVisibility = DrawerPanelVisibility.FULLSCREEN;
                  }
                },
                onExecute: async () => {
                  await this.executeSelectedNode();
                },
                onExportToTimeline: async () => {
                  await this.exportToTimeline(selectedNode);
                },
              })
            : m(ResultsPanelEmptyState, {
                icon: 'info',
                title: 'Select a node to see the data',
              })),
      mainContent: [
        m(
          '.pf-qb-node-graph',
          m(Graph, {
            ...attrs.graphCallbacks,
            rootNodes,
            selectedNodes: attrs.selectedNodes,
            nodeLayouts: attrs.nodeLayouts,
            labels: attrs.labels,
            loadGeneration: attrs.loadGeneration,
          }),
          selectedNode &&
            m(
              '.pf-qb-floating-controls',
              !selectedNode.validate() &&
                m(
                  Popup,
                  {
                    trigger: m(
                      '.pf-qb-floating-warning',
                      m(Icon, {
                        icon: Icons.Warning,
                        filled: true,
                        className: 'pf-qb-warning-icon',
                        title: 'Click to see error details',
                      }),
                    ),
                    position: PopupPosition.BottomEnd,
                    showArrow: true,
                  },
                  m(
                    '.pf-error-details',
                    selectedNode.context.issues?.getTitle() ??
                      'No error details',
                  ),
                ),
            ),
          m(
            '.pf-qb-floating-controls-bottom',
            attrs.onUndo &&
              RoundActionButton({
                icon: Icons.Undo,
                title: 'Undo (Ctrl+Z)',
                onclick: attrs.onUndo,
                disabled: !attrs.canUndo,
              }),
            attrs.onRedo &&
              RoundActionButton({
                icon: Icons.Redo,
                title: 'Redo (Ctrl+Shift+Z)',
                onclick: attrs.onRedo,
                disabled: !attrs.canRedo,
              }),
          ),
        ),
        m(ResizeHandle, {
          direction: 'horizontal',
          onResize: (deltaPx) => this.handleSidebarResize(attrs, deltaPx),
        }),
        m(
          '.pf-qb-explorer',
          {
            style: {
              width: isExplorerCollapsed
                ? '0'
                : `${sidebarWidth + (selectedNode ? 0 : SIDE_PANEL_WIDTH)}px`,
            },
          },
          explorer,
        ),
        selectedNode &&
          m(
            '.pf-qb-side-panel',
            m(Button, {
              icon: Icons.Info,
              title: 'Info',
              className:
                this.selectedView === SelectedView.kInfo && !isExplorerCollapsed
                  ? 'pf-active'
                  : '',
              onclick: () => {
                if (
                  this.selectedView === SelectedView.kInfo &&
                  !isExplorerCollapsed
                ) {
                  attrs.onExplorerCollapsedChange?.(true);
                } else {
                  this.selectedView = SelectedView.kInfo;
                  attrs.onExplorerCollapsedChange?.(false);
                }
              },
            }),
            selectedNode.nodeSpecificModify() != null &&
              m(Button, {
                icon: Icons.Edit,
                title: 'Edit',
                className:
                  this.selectedView === SelectedView.kModify &&
                  !isExplorerCollapsed
                    ? 'pf-active'
                    : '',
                onclick: () => {
                  if (
                    this.selectedView === SelectedView.kModify &&
                    !isExplorerCollapsed
                  ) {
                    attrs.onExplorerCollapsedChange?.(true);
                  } else {
                    this.selectedView = SelectedView.kModify;
                    attrs.onExplorerCollapsedChange?.(false);
                  }
                },
              }),
            m(Button, {
              icon: 'code',
              title: 'Result',
              className:
                this.selectedView === SelectedView.kResult &&
                !isExplorerCollapsed
                  ? 'pf-active'
                  : '',
              onclick: () => {
                if (
                  this.selectedView === SelectedView.kResult &&
                  !isExplorerCollapsed
                ) {
                  attrs.onExplorerCollapsedChange?.(true);
                } else {
                  this.selectedView = SelectedView.kResult;
                  attrs.onExplorerCollapsedChange?.(false);
                }
              },
            }),
          ),
      ],
    });
  }

  private resolveNode(
    nodeId: string,
    rootNodes: QueryNode[],
  ): QueryNode | undefined {
    const queue: QueryNode[] = [...rootNodes];
    const visited = new Set<string>();

    while (queue.length > 0) {
      const current = queue.shift()!;
      if (visited.has(current.nodeId)) {
        continue;
      }
      visited.add(current.nodeId);

      if (current.nodeId === nodeId) {
        return current;
      }

      queue.push(...current.nextNodes);
    }
    return undefined;
  }

  /**
   * Handles successful query execution by updating UI state.
   * Called from both automatic execution (via NodePanel) and manual execution (via onExecute).
   */
  private handleExecutionSuccess(
    node: QueryNode,
    result: {
      tableName: string;
      rowCount: number;
      columns: string[];
      durationMs: number;
    },
  ) {
    const engine = this.queryExecutionService.getEngine();
    const query = this.query;

    this.response = {
      query: isAQuery(query) ? queryToRun(query) : '',
      totalRowCount: result.rowCount,
      durationMs: result.durationMs,
      columns: result.columns,
      rows: [],
      statementCount: 1,
      statementWithOutputCount: 1,
      lastStatementSql: isAQuery(query) ? query.sql : '',
    };

    this.dataSource = new SQLDataSource({
      engine,
      sqlSchema: createSimpleSchema(result.tableName),
      rootSchemaName: 'query',
    });
    this.isQueryRunning = false;

    if (isAQuery(query)) {
      this.setNodeIssuesFromResponse(node, query, this.response);
    }

    if (node instanceof SqlSourceNode && this.response !== undefined) {
      node.onQueryExecuted(this.response.columns);
    }

    m.redraw();
  }

  /**
   * Creates callbacks for processNode() when manually executing a query.
   * Used by onExecute to avoid duplicating callback logic.
   */
  private createManualExecutionCallbacks(node: QueryNode) {
    return {
      onAnalysisStart: () => {
        this.isAnalyzing = true;
        // Clear dataSource at the START of analysis to prevent stale
        // queries with old columns while the table is being re-materialized.
        this.dataSource = undefined;
        m.redraw();
      },
      onAnalysisComplete: (query: Query | Error | undefined) => {
        // Update query state via callback (with validation to prevent stale query leakage)
        this.onNodeQueryAnalyzed?.(query);
        this.isAnalyzing = false;
        m.redraw();
      },
      onExecutionStart: () => {
        this.isQueryRunning = true;
        m.redraw();
      },
      onExecutionSuccess: (result: {
        tableName: string;
        rowCount: number;
        columns: string[];
        durationMs: number;
      }) => {
        this.handleExecutionSuccess(node, result);
      },
      onExecutionError: (error: unknown) => {
        this.handleQueryError(node, error);
        this.isQueryRunning = false;
        m.redraw();
      },
    };
  }

  /**
   * Executes the selected node's query manually.
   * Public method called when user clicks "Run Query" button or presses Ctrl+Enter
   * keyboard shortcut (invoked via parent component's keyboard handler).
   */
  async executeSelectedNode(): Promise<void> {
    const selectedNode = this.selectedNode;
    if (selectedNode === undefined) {
      return;
    }

    if (!selectedNode.validate()) {
      console.warn(
        `Cannot execute query: node ${selectedNode.nodeId} failed validation`,
      );
      return;
    }

    // Use the centralized service with manual=true.
    // The service handles both analysis and execution.
    await this.queryExecutionService.processNode(
      selectedNode,
      this.trace.engine,
      getAllNodes(this.rootNodes),
      {
        manual: true, // User explicitly requested execution
        ...this.createManualExecutionCallbacks(selectedNode),
      },
    );
  }

  private async exportToTimeline(node: QueryNode) {
    // Fetch table name from TP
    const tableName = await this.queryExecutionService.getTableName(
      node.nodeId,
    );
    if (!tableName) {
      console.warn('Cannot export to timeline: no materialized table');
      return;
    }

    // Use the materialized table instead of re-running the original query
    this.trace.plugins.getPlugin(QueryPagePlugin).addQueryResultsTab(
      {
        query: `SELECT * FROM ${tableName}`,
        title: 'Data Explorer Query',
      },
      'data_explorer',
    );

    // Navigate to the timeline page
    this.trace.navigate('#!/viewer');
  }

  private setNodeIssuesFromResponse(
    node: QueryNode,
    query: Query,
    response: QueryResponse,
  ) {
    const error = findErrors(query, response);
    const warning = findWarnings(response, node);
    const noDataWarning =
      response.totalRowCount === 0
        ? new Error('Query returned no rows')
        : undefined;

    if (error || warning || noDataWarning) {
      if (!node.context.issues) {
        node.context.issues = new NodeIssues();
      }
      node.context.issues.queryError = error;
      node.context.issues.responseError = warning;
      node.context.issues.dataError = noDataWarning;
      // Clear any previous execution error since we got a successful response
      node.context.issues.clearExecutionError();
    } else {
      node.context.issues = undefined;
    }
  }

  /**
   * Resets the query execution state.
   * Used when switching nodes or after query execution errors.
   */
  private resetQueryState() {
    this.dataSource = undefined;
    this.response = undefined;
    this.query = undefined;
    // Clear any pending execution in the service
    this.queryExecutionService.clearPendingExecution();
  }

  private handleQueryError(node: QueryNode, e: unknown) {
    console.error('Failed to run query:', e);
    // Clear response and data source but keep query so Retry can re-execute
    this.dataSource = undefined;
    this.response = undefined;
    if (!node.context.issues) {
      node.context.issues = new NodeIssues();
    }
    // Use executionError (not queryError) so error persists across re-renders
    // that trigger validate() - queryError gets cleared during validation
    node.context.issues.executionError =
      e instanceof Error ? e : new Error(String(e));
  }
}
