// 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 m from 'mithril';
import type SqlModulesPlugin from '../dev.perfetto.SqlModules';

import {Builder} from './query_builder/builder';
import type {QueryNode} from './query_node';
import {ensureAllNodeActions} from './node_actions';
import type {Trace} from '../../public/trace';
import {getOrCreate} from '../../base/utils';
import {shortUuid} from '../../base/uuid';
import {MenuItem} from '../../widgets/menu';
import {Tabs, type TabsTab} from '../../widgets/tabs';
import {Button, ButtonBar} from '../../widgets/button';
import {Icons} from '../../base/semantic_icons';
import {showModal} from '../../widgets/modal';
import {serializeState, deserializeState} from './json_handler';

import {
  confirmAndFinalizeCurrentGraph,
  exportGraph,
  exportTab,
  importGraph,
  importTab,
  loadGraphFromJson,
  loadGraphFromPath,
  createDataExplorerGraph,
  type GraphIODeps,
} from './graph_io';
import {registerCoreNodes} from './query_builder/core_nodes';
import {nodeRegistry} from './query_builder/node_registry';
import {QueryExecutionService} from './query_builder/query_execution_service';
import {CleanupManager} from './query_builder/cleanup_manager';
import {HistoryManager} from './history_manager';
import {getPrimarySelectedNode} from './selection_utils';
import {
  getAllNodes,
  createGroupFromSelection,
  applyGroupRewiring,
  ungroupNode,
} from './query_builder/graph_utils';
import {GroupNode} from './query_builder/nodes/group_node';
import {
  cleanupExistingNodes,
  addOperationNode,
  addSourceNode,
  addAndConnectTable,
  insertNodeAtPort,
  clearAllNodes,
  duplicateNode,
  deleteNode,
  deleteSelectedNodes,
  removeNodeConnection,
} from './node_crud_operations';
import {Dashboard} from './dashboard/dashboard';
import {isDashboardNode} from './query_builder/nodes/dashboard_node';
import {
  dashboardRegistry,
  type DashboardBrushFilter,
  type DashboardItem,
} from './dashboard/dashboard_registry';
import type {NodeCrudDeps} from './node_crud_operations';
import {addFilter, addColumnFromJoinid} from './datagrid_node_creation';
import {showHelp} from './help_modal';

import {copySelectedNodes, pasteClipboardNodes} from './clipboard_operations';
import type {ClipboardResult} from './clipboard_operations';
import type {SqlModules} from '../dev.perfetto.SqlModules/sql_modules';

registerCoreNodes();

/** State for a single dashboard within a graph tab. */
export interface DashboardTabState {
  readonly id: string;
  items: DashboardItem[];
  brushFilters: Map<string, DashboardBrushFilter[]>;
}

export interface DataExplorerTab {
  readonly id: string;
  title: string;
  state: DataExplorerState;
  // Each graph tab has one or more dashboards.
  dashboards: DashboardTabState[];
  // Which sub-tab is active: 'graph' (default) or a dashboard ID.
  activeSubTab?: string;
}

export interface DataExplorerState {
  rootNodes: QueryNode[];
  selectedNodes: ReadonlySet<string>; // Set of selected node IDs for multi-selection
  nodeLayouts: Map<string, {x: number; y: number}>;
  labels: Array<{
    id: string;
    x: number;
    y: number;
    width: number;
    text: string;
  }>;
  isExplorerCollapsed?: boolean;
  sidebarWidth?: number;
  loadGeneration?: number; // Incremented each time content is loaded
}

type StateUpdateFn = (
  update:
    | DataExplorerState
    | ((current: DataExplorerState) => DataExplorerState),
) => void;

interface DataExplorerAttrs {
  readonly trace: Trace;
  readonly sqlModulesPlugin: SqlModulesPlugin;
  // Active tab's state (convenience reference)
  readonly state: DataExplorerState;
  // State updater for the active tab (used by keyboard handlers, etc.)
  readonly onStateUpdate: StateUpdateFn;
  // Factory that returns a per-tab state update function.
  // Used in renderTabContent to create tab-scoped updaters that remain
  // correct even if the active tab changes while async work is in flight.
  readonly makeOnStateUpdate: (tabId: string) => StateUpdateFn;
  // Multi-tab props
  readonly tabs: DataExplorerTab[];
  readonly activeTabId: string;
  readonly onTabAdd: () => void;
  readonly onTabClose: (tabId: string) => void;
  readonly onTabChange: (tabId: string) => void;
  readonly onTabRename: (tabId: string, newName: string) => void;
  readonly onTabReorder: (
    draggedId: string,
    beforeId: string | undefined,
  ) => void;
  // Creates a new tab with the given title and state, inserted after the
  // tab identified by afterTabId. Optionally accepts pre-built dashboards;
  // when omitted a single empty dashboard is created.
  readonly onTabAddWithState: (
    title: string,
    state: DataExplorerState,
    afterTabId: string,
    dashboards?: DashboardTabState[],
  ) => void;
  // Notify the plugin that dashboard-local state changed (items/filters)
  // so it can trigger debounced saves.
  readonly onDashboardStateChange: () => void;
}

// Per-tab service instances that live for the lifetime of the tab.
interface TabServices {
  queryExecutionService: QueryExecutionService;
  cleanupManager: CleanupManager;
  historyManager?: HistoryManager;
  initializedNodes: Set<string>;
  executeFn?: () => Promise<void>;
}

export class DataExplorer implements m.ClassComponent<DataExplorerAttrs> {
  // Shared clipboard across all tabs (not persisted).
  private clipboard?: ClipboardResult;

  // Per-tab services, keyed by tab ID.
  private tabServices = new Map<string, TabServices>();

  private getOrCreateServices(
    tabId: string,
    engine: Trace['engine'],
  ): TabServices {
    return getOrCreate(this.tabServices, tabId, () => {
      const qes = new QueryExecutionService(engine);
      return {
        queryExecutionService: qes,
        cleanupManager: new CleanupManager(qes),
        initializedNodes: new Set<string>(),
      };
    });
  }

  private getActiveServices(activeTabId: string): TabServices | undefined {
    return this.tabServices.get(activeTabId);
  }

  private selectNode(attrs: DataExplorerAttrs, node: QueryNode) {
    attrs.onStateUpdate((currentState) => ({
      ...currentState,
      selectedNodes: new Set([node.nodeId]),
    }));
  }

  private addNodeToSelection(attrs: DataExplorerAttrs, node: QueryNode) {
    attrs.onStateUpdate((currentState) => {
      const newSelectedNodes = new Set(currentState.selectedNodes);
      newSelectedNodes.add(node.nodeId);
      return {
        ...currentState,
        selectedNodes: newSelectedNodes,
      };
    });
  }

  private removeNodeFromSelection(attrs: DataExplorerAttrs, nodeId: string) {
    attrs.onStateUpdate((currentState) => {
      const newSelectedNodes = new Set(currentState.selectedNodes);
      newSelectedNodes.delete(nodeId);
      return {
        ...currentState,
        selectedNodes: newSelectedNodes,
      };
    });
  }

  private deselectNode(attrs: DataExplorerAttrs) {
    attrs.onStateUpdate((currentState) => ({
      ...currentState,
      selectedNodes: new Set(),
    }));
  }

  private handleCopy(attrs: DataExplorerAttrs): void {
    const result = copySelectedNodes(attrs.state);
    if (result !== undefined) {
      this.clipboard = result;
    }
  }

  private handlePaste(attrs: DataExplorerAttrs): void {
    if (this.clipboard === undefined) return;
    const clipboard = this.clipboard;
    attrs.onStateUpdate((currentState) => {
      const result = pasteClipboardNodes(currentState, clipboard);
      if (result === undefined) return currentState;
      return {...currentState, ...result};
    });
  }

  private handleKeyDown(
    event: KeyboardEvent,
    attrs: DataExplorerAttrs,
    nodeCrudDeps: NodeCrudDeps,
    graphIODeps: GraphIODeps,
  ) {
    const {state} = attrs;

    // Do not interfere with text inputs
    if (
      event.target instanceof HTMLInputElement ||
      event.target instanceof HTMLTextAreaElement
    ) {
      return;
    }

    // Handle "?" to show help modal
    if (event.key === '?') {
      showHelp();
      event.preventDefault();
      return;
    }

    const activeServices = this.getActiveServices(attrs.activeTabId);

    // Handle Ctrl+Enter to execute selected node
    if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
      const selectedNode = getPrimarySelectedNode(
        state.selectedNodes,
        state.rootNodes,
      );
      if (
        selectedNode !== undefined &&
        activeServices?.executeFn !== undefined
      ) {
        void activeServices.executeFn();
        event.preventDefault();
      }
      return;
    }

    // Handle copy shortcut - text selection takes priority over node copy,
    // so users can copy proto/SQL from the sidebar, error messages, etc.
    if ((event.ctrlKey || event.metaKey) && event.key === 'c') {
      const textSelection = window.getSelection();
      const hasTextSelected =
        textSelection !== null && textSelection.toString().length > 0;
      if (!hasTextSelected && state.selectedNodes.size > 0) {
        this.handleCopy(attrs);
        event.preventDefault();
      }
      return;
    }

    if ((event.ctrlKey || event.metaKey) && event.key === 'v') {
      this.handlePaste(attrs);
      event.preventDefault();
      return;
    }

    // Handle delete key - delete all selected nodes
    if (event.key === 'Delete' || event.key === 'Backspace') {
      if (state.selectedNodes.size > 0) {
        deleteSelectedNodes(nodeCrudDeps, attrs.state);
        event.preventDefault();
      }
      return;
    }

    // For other shortcuts, skip if a node is selected to avoid interfering
    // with node-specific interactions
    if (state.selectedNodes.size > 0) {
      return;
    }

    // Handle undo/redo shortcuts
    if ((event.ctrlKey || event.metaKey) && event.key === 'z') {
      if (event.shiftKey) {
        // Ctrl+Shift+Z or Cmd+Shift+Z for Redo
        this.handleRedo(attrs);
        event.preventDefault();
        return;
      } else {
        // Ctrl+Z or Cmd+Z for Undo
        this.handleUndo(attrs);
        event.preventDefault();
        return;
      }
    }

    // Also support Ctrl+Y for Redo on Windows/Linux
    if ((event.ctrlKey || event.metaKey) && event.key === 'y') {
      this.handleRedo(attrs);
      event.preventDefault();
      return;
    }

    // Handle source node creation shortcuts
    for (const [id, descriptor] of nodeRegistry.list()) {
      if (
        descriptor.type === 'source' &&
        descriptor.hotkey &&
        event.key.toLowerCase() === descriptor.hotkey.toLowerCase()
      ) {
        addSourceNode(nodeCrudDeps, attrs.state, id);
        event.preventDefault(); // Prevent default browser actions for this key
        return;
      }
    }

    // Handle other shortcuts
    switch (event.key) {
      case 'i':
        importTab(graphIODeps, (title, newState, dashboards) =>
          attrs.onTabAddWithState(
            title,
            newState,
            attrs.activeTabId,
            dashboards,
          ),
        );
        break;
      case 'e': {
        const activeTab = attrs.tabs.find((t) => t.id === attrs.activeTabId);
        if (activeTab !== undefined) {
          void exportTab(activeTab, attrs.trace);
        }
        break;
      }
    }
  }

  private handleUndo(attrs: DataExplorerAttrs) {
    const historyManager = this.getActiveServices(
      attrs.activeTabId,
    )?.historyManager;
    if (!historyManager) {
      console.warn('Cannot undo: history manager not initialized');
      return;
    }

    const previousState = historyManager.undo();
    if (previousState) {
      attrs.onStateUpdate(previousState);
    }
  }

  private handleRedo(attrs: DataExplorerAttrs) {
    const historyManager = this.getActiveServices(
      attrs.activeTabId,
    )?.historyManager;
    if (!historyManager) {
      console.warn('Cannot redo: history manager not initialized');
      return;
    }

    const nextState = historyManager.redo();
    if (nextState) {
      attrs.onStateUpdate(nextState);
    }
  }

  // Render the content for a single tab. This includes the Builder and all
  // per-tab service setup (history, query execution, cleanup, etc.).
  private renderTabContent(
    attrs: DataExplorerAttrs,
    tab: DataExplorerTab,
    sqlModules: SqlModules,
  ): m.Children {
    const {trace} = attrs;
    const {state} = tab;
    const isActive = tab.id === attrs.activeTabId;

    const services = this.getOrCreateServices(tab.id, trace.engine);

    // Initialize history manager for this tab if not already done
    if (services.historyManager === undefined) {
      services.historyManager = new HistoryManager(trace, sqlModules);
      services.historyManager.pushState(state);
    }

    // Create a per-tab state updater that wraps history tracking.
    // Uses makeOnStateUpdate(tab.id) so the updater is always bound to THIS
    // tab's state, even if the active tab changes while async work is in flight.
    const tabOnStateUpdate = attrs.makeOnStateUpdate(tab.id);
    const wrappedOnStateUpdate: StateUpdateFn = (update) => {
      tabOnStateUpdate((currentState) => {
        const newState =
          typeof update === 'function' ? update(currentState) : update;
        services.historyManager?.pushState(newState);
        return newState;
      });
    };

    const wrappedAttrs = {
      ...attrs,
      state,
      onStateUpdate: wrappedOnStateUpdate,
    };

    // Construct deps objects once per render cycle.
    const nodeCrudDeps: NodeCrudDeps = {
      trace,
      sqlModules,
      onStateUpdate: wrappedOnStateUpdate,
      cleanupManager: services.cleanupManager,
      initializedNodes: services.initializedNodes,
      nodeActionHandlers: {
        onAddAndConnectTable: (
          tableName: string,
          node: QueryNode,
          portIndex: number,
        ) => {
          addAndConnectTable(
            nodeCrudDeps,
            wrappedAttrs.state,
            tableName,
            node,
            portIndex,
          );
        },
        onInsertNodeAtPort: (
          node: QueryNode,
          portIndex: number,
          descriptorKey: string,
        ) => {
          insertNodeAtPort(
            nodeCrudDeps,
            wrappedAttrs.state,
            node,
            portIndex,
            descriptorKey,
          );
        },
      },
    };

    const graphIODeps: GraphIODeps = {
      trace,
      sqlModules,
      onStateUpdate: wrappedOnStateUpdate,
      cleanupExistingNodes: (rootNodes) =>
        cleanupExistingNodes(
          services.cleanupManager,
          services.initializedNodes,
          rootNodes,
        ),
    };

    const allNodes = getAllNodes(state.rootNodes);

    // Set callbacks on dashboard nodes so they can resolve table names and
    // trigger execution even when the graph tab is not active.
    for (const node of allNodes) {
      if (!isDashboardNode(node)) continue;
      // Propagate the stable tab ID so sources are namespaced by graph.
      // Re-publish if the graphId changed so the registry has the right key.
      const prevGraphId = node.graphId;
      node.graphId = tab.id;
      if (prevGraphId !== tab.id) {
        node.onPrevNodesUpdated?.();
      }
      if (node.context.getTableNameForNode === undefined) {
        node.context.getTableNameForNode = (nodeId: string) =>
          services.queryExecutionService.getTableName(nodeId);
      }
      if (node.context.requestNodeExecution === undefined) {
        node.context.requestNodeExecution = (nodeId: string) => {
          const targetNode = allNodes.find((n) => n.nodeId === nodeId);
          if (targetNode === undefined) return Promise.resolve();
          return services.queryExecutionService
            .processNode(targetNode, trace.engine, allNodes, {manual: true})
            .then(() => {});
        };
      }
    }

    // Only do full node processing for the active tab to avoid unnecessary work
    if (isActive) {
      // Provide getTableNameForNode callback for all nodes (used by
      // add_columns_node and others).
      for (const node of allNodes) {
        if (node.context.getTableNameForNode === undefined) {
          node.context.getTableNameForNode = (nodeId: string) =>
            services.queryExecutionService.getTableName(nodeId);
        }
      }

      // Ensure all nodes have actions initialized
      ensureAllNodeActions(
        allNodes,
        services.initializedNodes,
        nodeCrudDeps.nodeActionHandlers,
      );

      // Store deps for keyboard handler access
      this.activeNodeCrudDeps = nodeCrudDeps;
      this.activeGraphIODeps = graphIODeps;
    }

    const doUngroup = (groupNode: GroupNode) => {
      wrappedOnStateUpdate((currentState) => {
        // ungroupNode performs in-place graph mutations (rewiring
        // nextNodes/primaryInput) matching the applyGroupRewiring pattern.
        ungroupNode(groupNode);
        const newRootNodes = currentState.rootNodes
          .filter((n) => n.nodeId !== groupNode.nodeId)
          .concat(groupNode.innerNodes);
        const newNodeLayouts = new Map(currentState.nodeLayouts);
        newNodeLayouts.delete(groupNode.nodeId);
        const innerIds = new Set(groupNode.innerNodes.map((n) => n.nodeId));
        return {
          ...currentState,
          rootNodes: newRootNodes,
          nodeLayouts: newNodeLayouts,
          selectedNodes: innerIds,
        };
      });
    };

    // Sized wrapper so DrawerPanel can read a non-zero clientHeight;
    // Gate (display:contents) elements have clientHeight === 0.
    return m(
      '.pf-data-explorer__tab-content',
      m(Builder, {
        trace,
        sqlModules,
        queryExecutionService: services.queryExecutionService,
        rootNodes: state.rootNodes,
        selectedNodes: state.selectedNodes,
        nodeLayouts: state.nodeLayouts,
        labels: state.labels,
        loadGeneration: state.loadGeneration,
        isExplorerCollapsed: state.isExplorerCollapsed,
        sidebarWidth: state.sidebarWidth,
        onExecuteReady: (executeFn) => {
          services.executeFn = executeFn;
        },
        onRootNodeCreated: (node) => {
          wrappedOnStateUpdate((currentState) => ({
            ...currentState,
            rootNodes: [...currentState.rootNodes, node],
            selectedNodes: new Set([node.nodeId]),
          }));
        },
        onExplorerCollapsedChange: (collapsed) => {
          wrappedOnStateUpdate((currentState) => ({
            ...currentState,
            isExplorerCollapsed: collapsed,
          }));
        },
        onSidebarWidthChange: (width) => {
          wrappedOnStateUpdate((currentState) => ({
            ...currentState,
            sidebarWidth: width,
          }));
        },
        graphCallbacks: {
          onNodeSelected: (node) => {
            this.selectNode(wrappedAttrs, node);
          },
          onNodeAddToSelection: (node) => {
            this.addNodeToSelection(wrappedAttrs, node);
          },
          onNodeRemoveFromSelection: (nodeId) => {
            this.removeNodeFromSelection(wrappedAttrs, nodeId);
          },
          onDeselect: () => this.deselectNode(wrappedAttrs),
          onNodeLayoutChange: (nodeId, layout) => {
            wrappedOnStateUpdate((currentState) => {
              const newNodeLayouts = new Map(currentState.nodeLayouts);
              newNodeLayouts.set(nodeId, layout);
              return {
                ...currentState,
                nodeLayouts: newNodeLayouts,
              };
            });
          },
          onLabelsChange: (labels) => {
            wrappedOnStateUpdate((currentState) => ({
              ...currentState,
              labels,
            }));
          },
          onAddSourceNode: (id) => {
            addSourceNode(nodeCrudDeps, wrappedAttrs.state, id);
          },
          onAddOperationNode: (id, node) => {
            addOperationNode(nodeCrudDeps, wrappedAttrs.state, node, id);
          },
          onClearAllNodes: () =>
            clearAllNodes(nodeCrudDeps, wrappedAttrs.state),
          onDuplicateNode: (node) => {
            duplicateNode(wrappedOnStateUpdate, node);
          },
          onDeleteNode: (node) => {
            deleteNode(nodeCrudDeps, wrappedAttrs.state, node);
          },
          onConnectionRemove: (fromNode, toNode, isSecondaryInput) => {
            removeNodeConnection(
              wrappedAttrs.state,
              wrappedOnStateUpdate,
              fromNode,
              toNode,
              isSecondaryInput,
            );
          },
          onImport: () =>
            importGraph(graphIODeps, (title, newState) =>
              attrs.onTabAddWithState(title, newState, tab.id),
            ),
          onExport: () => exportGraph(state, trace),
          onCreateGroup: (selectedNodeIds) => {
            wrappedOnStateUpdate((currentState) => {
              // Validate against currentState so we never act on a stale
              // snapshot.
              const allNodes = getAllNodes(currentState.rootNodes);
              const result = createGroupFromSelection(
                selectedNodeIds,
                allNodes,
              );

              if (!result.ok) {
                showModal({
                  title: 'Cannot create group',
                  content: () => m('p', result.error),
                  buttons: [{text: 'OK', action: () => {}}],
                });
                return currentState;
              }

              const groupNode = result.value;
              applyGroupRewiring(groupNode);

              // Remove inner nodes from rootNodes and add the group instead.
              // Inner nodes are now reachable via GroupNode.innerNodes
              // (getAllNodes traverses into groups automatically).
              const newRootNodes = currentState.rootNodes
                .filter((n) => !selectedNodeIds.has(n.nodeId))
                .concat(groupNode);

              // Place the group at the centroid of the inner nodes' layouts.
              const newNodeLayouts = new Map(currentState.nodeLayouts);
              let sumX = 0;
              let sumY = 0;
              let count = 0;
              for (const id of selectedNodeIds) {
                const layout = newNodeLayouts.get(id);
                if (layout !== undefined) {
                  sumX += layout.x;
                  sumY += layout.y;
                  count++;
                }
                newNodeLayouts.delete(id);
              }
              if (count > 0) {
                newNodeLayouts.set(groupNode.nodeId, {
                  x: sumX / count,
                  y: sumY / count,
                });
              }

              return {
                ...currentState,
                rootNodes: newRootNodes,
                nodeLayouts: newNodeLayouts,
                selectedNodes: new Set([groupNode.nodeId]),
              };
            });
          },
          onUngroupNode: (node) => {
            if (node instanceof GroupNode) doUngroup(node);
          },
        },
        onLoadEmptyTemplate: async () => {
          if (!(await confirmAndFinalizeCurrentGraph(state))) return;

          wrappedOnStateUpdate((currentState) => {
            return {
              ...currentState,
              rootNodes: [],
              selectedNodes: new Set(),
              nodeLayouts: new Map(),
              labels: [],
              loadGeneration: (currentState.loadGeneration ?? 0) + 1,
            };
          });
        },
        onLoadExampleByPath: (jsonPath: string) =>
          loadGraphFromPath(graphIODeps, state, jsonPath, 'Failed to Load'),
        onLoadDataExplorerTemplate: async () => {
          if (!(await confirmAndFinalizeCurrentGraph(state))) return;
          await createDataExplorerGraph(graphIODeps);
        },
        onLoadRecentGraph: async (json: string) => {
          if (!(await confirmAndFinalizeCurrentGraph(state))) return;
          await loadGraphFromJson(graphIODeps, state.rootNodes, json);
        },
        onFilterAdd: (node, filter, filterOperator) => {
          addFilter(nodeCrudDeps, node, filter, filterOperator);
        },
        onColumnAdd: (node, column) => {
          addColumnFromJoinid(nodeCrudDeps, wrappedAttrs.state, node, column);
        },
        onNodeStateChange: () => {
          wrappedOnStateUpdate((currentState) => {
            return {...currentState};
          });
        },
        onUndo: () => this.handleUndo(attrs),
        onRedo: () => this.handleRedo(attrs),
        canUndo: services.historyManager?.canUndo() ?? false,
        canRedo: services.historyManager?.canRedo() ?? false,
      }),
    );
  }

  // Stored references for the active tab's deps, used by keyboard handler.
  private activeNodeCrudDeps?: NodeCrudDeps;
  private activeGraphIODeps?: GraphIODeps;

  /** Get exported sources for a specific graph tab. */
  private getDashboardSources(graphTabId: string) {
    return dashboardRegistry.getExportedSourcesForGraph(graphTabId);
  }

  /** Trigger debounced saves after dashboard state changes. */
  private triggerSave(attrs: DataExplorerAttrs) {
    attrs.onDashboardStateChange();
    m.redraw();
  }

  private addDashboard(tab: DataExplorerTab, attrs: DataExplorerAttrs): void {
    const db: DashboardTabState = {
      id: shortUuid(),
      items: [],
      brushFilters: new Map(),
    };
    tab.dashboards.push(db);
    tab.activeSubTab = db.id;
    this.triggerSave(attrs);
  }

  private removeDashboard(
    tab: DataExplorerTab,
    attrs: DataExplorerAttrs,
    dbId: string,
  ): void {
    if (tab.dashboards.length <= 1) return;
    const idx = tab.dashboards.findIndex((d) => d.id === dbId);
    if (idx === -1) return;
    const itemCount = tab.dashboards[idx].items.length;

    const doRemove = () => {
      const currentIdx = tab.dashboards.findIndex((d) => d.id === dbId);
      if (currentIdx === -1) return;
      tab.dashboards.splice(currentIdx, 1);
      // If the removed dashboard was active, switch to the previous one or graph.
      if (tab.activeSubTab === dbId) {
        if (tab.dashboards.length > 0) {
          const newIdx = Math.min(currentIdx, tab.dashboards.length - 1);
          tab.activeSubTab = tab.dashboards[newIdx].id;
        } else {
          tab.activeSubTab = 'graph';
        }
      }
      this.triggerSave(attrs);
    };

    if (itemCount === 0) {
      doRemove();
      return;
    }

    showModal({
      title: 'Remove dashboard?',
      content: m(
        'div',
        `This dashboard contains ${itemCount} item(s). ` +
          'Removing it will discard all its contents.',
      ),
      buttons: [
        {text: 'Remove', primary: true, action: doRemove},
        {text: 'Cancel'},
      ],
    });
  }

  /**
   * Render a graph tab with nested sub-tabs (Graph | Dashboard 1 | ... | +).
   * The graph builder is always initialized (for dashboard node execution),
   * but we conditionally show either the builder or the dashboard view.
   */
  private renderDashboardButton(
    tab: DataExplorerTab,
    attrs: DataExplorerAttrs,
    db: DashboardTabState,
    index: number,
    activeSubTab: string,
  ): m.Children {
    const label =
      tab.dashboards.length === 1 ? 'Dashboard' : `Dashboard ${index + 1}`;
    const showClose = tab.dashboards.length > 1;
    return [
      m(Button, {
        label,
        icon: 'dashboard',
        active: activeSubTab === db.id,
        rightIcon: showClose ? Icons.Close : undefined,
        onclick: (e: MouseEvent) => {
          // If the click landed on the close (right) icon, remove instead.
          const target = e.target as HTMLElement;
          if (showClose && target.closest('.pf-right-icon') !== null) {
            this.removeDashboard(tab, attrs, db.id);
            return;
          }
          tab.activeSubTab = db.id;
          m.redraw();
        },
      }),
    ];
  }

  private renderTabWithSubTabs(
    attrs: DataExplorerAttrs,
    tab: DataExplorerTab,
    sqlModules: SqlModules,
  ): m.Children {
    const activeSubTab = tab.activeSubTab ?? 'graph';
    const graphContent = this.renderTabContent(attrs, tab, sqlModules);

    const subTabBar = m(
      '.pf-data-explorer__sub-tab-bar',
      m(
        ButtonBar,
        m(Button, {
          label: 'Graph',
          icon: 'account_tree',
          active: activeSubTab === 'graph',
          onclick: () => {
            tab.activeSubTab = 'graph';
            m.redraw();
          },
        }),
        ...tab.dashboards.map((db, i) =>
          this.renderDashboardButton(tab, attrs, db, i, activeSubTab),
        ),
        m(Button, {
          icon: 'add',
          title: 'Add dashboard',
          onclick: () => this.addDashboard(tab, attrs),
        }),
        m(Button, {
          icon: 'download',
          title: 'Export current tab',
          onclick: () => {
            void exportTab(tab, attrs.trace);
          },
        }),
      ),
    );

    // Find active dashboard if any.
    const activeDashboard = tab.dashboards.find((db) => db.id === activeSubTab);

    if (activeDashboard !== undefined) {
      return m('.pf-data-explorer__tab-with-subtabs', [
        subTabBar,
        m(
          '.pf-data-explorer__tab-content',
          m(Dashboard, {
            dashboardId: activeDashboard.id,
            trace: attrs.trace,
            items: activeDashboard.items,
            sources: this.getDashboardSources(tab.id),
            brushFilters: activeDashboard.brushFilters,
            onItemsChange: (items) => {
              activeDashboard.items = items;
              this.triggerSave(attrs);
            },
            onBrushFiltersChange: (filters) => {
              activeDashboard.brushFilters = filters;
              this.triggerSave(attrs);
            },
          }),
        ),
      ]);
    }

    return m('.pf-data-explorer__tab-with-subtabs', [subTabBar, graphContent]);
  }

  view({attrs}: m.CVnode<DataExplorerAttrs>) {
    const {tabs, activeTabId} = attrs;

    const sqlModules = attrs.sqlModulesPlugin.getSqlModules();

    if (!sqlModules) {
      return m(
        '.pf-data-explorer',
        m(
          '.pf-data-explorer__header',
          m('h1', 'Loading SQL Modules, please wait...'),
        ),
      );
    }

    // Build tab entries for the Tabs widget
    const tabEntries: TabsTab[] = tabs.map((tab) => ({
      key: tab.id,
      title: tab.title,
      leftIcon: 'account_tree',
      closeButton: tabs.length > 1,
      content: this.renderTabWithSubTabs(attrs, tab, sqlModules),
      menuItems: m(MenuItem, {
        label: 'Duplicate tab',
        icon: 'content_copy',
        onclick: () => {
          const sqlMods = attrs.sqlModulesPlugin.getSqlModules();
          if (sqlMods === undefined) return;
          const json = serializeState(tab.state);
          const clonedState = deserializeState(json, attrs.trace, sqlMods);
          attrs.onTabAddWithState(`${tab.title} (copy)`, clonedState, tab.id);
        },
      }),
    }));

    return m(
      '.pf-data-explorer',
      {
        onkeydown: (e: KeyboardEvent) => {
          if (this.activeNodeCrudDeps && this.activeGraphIODeps) {
            this.handleKeyDown(
              e,
              attrs,
              this.activeNodeCrudDeps,
              this.activeGraphIODeps,
            );
          }
        },
        oncreate: (vnode) => {
          (vnode.dom as HTMLElement).focus();
        },
        onremove: () => {
          // Clean up all materialized tables for all tabs in parallel
          void Promise.all(
            [...this.tabServices].map(([tabId, services]) => {
              const tab = tabs.find((t) => t.id === tabId);
              const rootNodes = tab ? getAllNodes(tab.state.rootNodes) : [];
              return services.cleanupManager
                .cleanupAll(rootNodes)
                .catch((e) => console.warn(`Tab ${tabId} cleanup failed:`, e));
            }),
          ).finally(() => this.tabServices.clear());
        },
        tabindex: 0,
      },
      m(Tabs, {
        className: 'pf-data-explorer__tabs',
        tabs: tabEntries,
        activeTabKey: activeTabId,
        reorderable: true,
        newTabContent: m('.pf-data-explorer__tab-actions', [
          m(Button, {
            icon: 'add',
            className: 'pf-tabs__new-tab-btn',
            onclick: () => attrs.onTabAdd(),
          }),
          m(Button, {
            icon: 'upload',
            className: 'pf-tabs__new-tab-btn',
            title: 'Import tab',
            onclick: () => {
              if (this.activeGraphIODeps) {
                importTab(
                  this.activeGraphIODeps,
                  (title, newState, dashboards) =>
                    attrs.onTabAddWithState(
                      title,
                      newState,
                      attrs.activeTabId,
                      dashboards,
                    ),
                );
              }
            },
          }),
        ]),
        onTabChange: (key) => attrs.onTabChange(key),
        onTabRename: (key, newTitle) => {
          attrs.onTabRename(key, newTitle);
        },
        onTabClose: (key) => {
          // Clean up services for the closed tab eagerly
          const services = this.tabServices.get(key);
          if (services) {
            const tab = tabs.find((t) => t.id === key);
            const rootNodes = tab ? getAllNodes(tab.state.rootNodes) : [];
            void services.cleanupManager
              .cleanupAll(rootNodes)
              .catch((e) => console.warn(`Tab ${key} cleanup failed:`, e));
            this.tabServices.delete(key);
          }
          attrs.onTabClose(key);
        },
        onTabReorder: (draggedKey, beforeKey) =>
          attrs.onTabReorder(draggedKey, beforeKey),
      }),
    );
  }
}
