// Copyright 2015 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/* eslint-disable @devtools/no-imperative-dom-api */

import '../../ui/legacy/legacy.js';

import * as Common from '../../core/common/common.js';
import * as i18n from '../../core/i18n/i18n.js';
import * as Platform from '../../core/platform/platform.js';
import * as Trace from '../../models/trace/trace.js';
import * as Tracing from '../../services/tracing/tracing.js';
import * as Buttons from '../../ui/components/buttons/buttons.js';
import * as DataGrid from '../../ui/legacy/components/data_grid/data_grid.js';
import * as Components from '../../ui/legacy/components/utils/utils.js';
import * as UI from '../../ui/legacy/legacy.js';
import * as ThemeSupport from '../../ui/legacy/theme_support/theme_support.js';
import * as VisualLogging from '../../ui/visual_logging/visual_logging.js';

import {ActiveFilters} from './ActiveFilters.js';
import * as Extensions from './extensions/extensions.js';
import {targetForEvent} from './TargetForEvent.js';
import {TimelineRegExp} from './TimelineFilters.js';
import {rangeForSelection, type TimelineSelection} from './TimelineSelection.js';
import timelineTreeViewStyles from './timelineTreeView.css.js';
import {TimelineUIUtils} from './TimelineUIUtils.js';

const UIStrings = {
  /**
   * @description Text for the performance of something
   */
  performance: 'Performance',
  /**
   * @description Time of a single activity, as opposed to the total time
   */
  selfTime: 'Self time',
  /**
   * @description Text for the total time of something
   */
  totalTime: 'Total time',
  /**
   * @description Text in Timeline Tree View of the Performance panel
   */
  activity: 'Activity',
  /**
   * @description Text of a DOM element in Timeline Tree View of the Performance panel
   */
  selectItemForDetails: 'Select item for details.',
  /**
   * @description Number followed by percent sign
   * @example {20} PH1
   */
  percentPlaceholder: '{PH1} %',
  /**
   * @description Text in Timeline Tree View of the Performance panel
   */
  chromeExtensionsOverhead: '[`Chrome` extensions overhead]',
  /**
   * @description Text in Timeline Tree View of the Performance panel. The text is presented
   * when developers investigate the performance of a page. 'V8 Runtime' labels the time
   * spent in (i.e. runtime) the V8 JavaScript engine.
   */
  vRuntime: '[`V8` Runtime]',
  /**
   * @description Text in Timeline Tree View of the Performance panel
   */
  unattributed: '[unattributed]',
  /**
   * @description Text that refers to one or a group of webpages
   */
  page: 'Page',
  /**
   * @description Text in Timeline Tree View of the Performance panel
   */
  noGrouping: 'No grouping',
  /**
   * @description Text in Timeline Tree View of the Performance panel
   */
  groupByActivity: 'Group by activity',
  /**
   * @description Text in Timeline Tree View of the Performance panel
   */
  groupByCategory: 'Group by category',
  /**
   * @description Text in Timeline Tree View of the Performance panel
   */
  groupByDomain: 'Group by domain',
  /**
   * @description Text in Timeline Tree View of the Performance panel
   */
  groupByFrame: 'Group by frame',
  /**
   * @description Text in Timeline Tree View of the Performance panel
   */
  groupBySubdomain: 'Group by subdomain',
  /**
   * @description Text in Timeline Tree View of the Performance panel
   */
  groupByUrl: 'Group by URL',
  /**
   * @description Text in Timeline Tree View of the Performance panel
   */
  groupByThirdParties: 'Group by Third Parties',
  /**
   * @description Aria-label for grouping combo box in Timeline Details View
   */
  groupBy: 'Group by',
  /**
   * @description Title of the sidebar pane in the Performance panel which shows the stack (call
   * stack) where the program spent the most time (out of all the call stacks) while executing.
   */
  heaviestStack: 'Heaviest stack',
  /**
   * @description Tooltip for the the Heaviest stack sidebar toggle in the Timeline Tree View of the
   * Performance panel. Command to open/show the sidebar.
   */
  showHeaviestStack: 'Show heaviest stack',
  /**
   * @description Tooltip for the the Heaviest stack sidebar toggle in the Timeline Tree View of the
   * Performance panel. Command to close/hide the sidebar.
   */
  hideHeaviestStack: 'Hide heaviest stack',
  /**
   * @description Screen reader announcement when the heaviest stack sidebar is shown in the Performance panel.
   */
  heaviestStackShown: 'Heaviest stack sidebar shown',
  /**
   * @description Screen reader announcement when the heaviest stack sidebar is hidden in the Performance panel.
   */
  heaviestStackHidden: 'Heaviest stack sidebar hidden',
  /**
   * @description Data grid name for Timeline Stack data grids
   */
  timelineStack: 'Timeline stack',
  /**
   * /*@description Text to search by matching case of the input button
   */
  matchCase: 'Match case',
  /**
   * @description Text for searching with regular expression button
   */
  useRegularExpression: 'Use regular expression',
  /**
   * @description Text for Match whole word button
   */
  matchWholeWord: 'Match whole word',
  /**
   * @description Text for bottom up tree button
   */
  bottomUp: 'Bottom-up',
  /**
   * @description Text referring to view bottom up tree
   */
  viewBottomUp: 'View Bottom-up',
  /**
   * @description Text referring to a 1st party entity
   */
  firstParty: '1st party',
  /**
   * @description Text referring to an entity that is an extension
   */
  extension: 'Extension',
} as const;
const str_ = i18n.i18n.registerUIStrings('panels/timeline/TimelineTreeView.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);

/**
 * For an overview, read: https://chromium.googlesource.com/devtools/devtools-frontend/+/refs/heads/main/front_end/panels/timeline/README.md#timeline-tree-views
 */
export class TimelineTreeView extends
    Common.ObjectWrapper.eventMixin<TimelineTreeView.EventTypes, typeof UI.Widget.VBox>(UI.Widget.VBox)
        implements UI.SearchableView.Searchable {
  /** This is sorted by ts. */
  #selectedEvents: Trace.Types.Events.Event[]|null;
  private searchResults: Trace.Extras.TraceTree.Node[];
  linkifier!: Components.Linkifier.Linkifier;
  dataGrid!: DataGrid.SortableDataGrid.SortableDataGrid<GridNode>;
  private lastHoveredProfileNode!: Trace.Extras.TraceTree.Node|null;
  private textFilterInternal!: TimelineRegExp;
  private taskFilter!: Trace.Extras.TraceFilter.ExclusiveNameFilter;
  protected startTimeInternal!: Trace.Types.Timing.Milli;
  protected endTimeInternal!: Trace.Types.Timing.Milli;
  splitWidget!: UI.SplitWidget.SplitWidget;
  detailsView!: UI.Widget.Widget;
  private searchableView!: UI.SearchableView.SearchableView;
  // TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  private currentThreadSetting?: Common.Settings.Setting<any>;
  private lastSelectedNodeInternal?: Trace.Extras.TraceTree.Node|null;
  private root?: Trace.Extras.TraceTree.Node;
  private currentResult?: number;
  textFilterUI?: UI.Toolbar.ToolbarInput;
  private caseSensitiveButton: UI.Toolbar.ToolbarToggle|undefined;
  private regexButton: UI.Toolbar.ToolbarToggle|undefined;
  private matchWholeWord: UI.Toolbar.ToolbarToggle|undefined;
  #parsedTrace: Trace.TraceModel.ParsedTrace|null = null;
  #entityMapper: Trace.EntityMapper.EntityMapper|null = null;
  #lastHighlightedEvent: HTMLElement|null = null;
  eventToTreeNode = new WeakMap<Trace.Types.Events.Event, Trace.Extras.TraceTree.Node>();
  // Compact mode is used to render the tree view in a more compact UI,
  // suitable for AI assistance widgets. It removes sidebars and toolbars.
  #compactMode = false;
  #maxLinkLength: number|undefined = undefined;
  #maxRows: number|undefined = undefined;

  /**
   * Determines if the first child in the data grid will be selected
   * by default when refreshTree() gets called.
   */
  protected autoSelectFirstChildOnRefresh = true;

  constructor(element?: HTMLElement) {
    super(element);
    this.#selectedEvents = null;
    this.element.classList.add('timeline-tree-view');
    this.registerRequiredCSS(timelineTreeViewStyles);

    this.searchResults = [];
  }

  get selectedEvents(): Trace.Types.Events.Event[] {
    return this.#selectedEvents || [];
  }

  set selectedEvents(selectedEvents: Trace.Types.Events.Event[]|null) {
    this.#selectedEvents = selectedEvents;
    this.refreshTree();
  }

  set parsedTrace(parsedTrace: Trace.TraceModel.ParsedTrace|null) {
    this.#parsedTrace = parsedTrace;
    this.refreshTree();
  }

  get parsedTrace(): Trace.TraceModel.ParsedTrace|null {
    return this.#parsedTrace;
  }

  set startTime(startTime: Trace.Types.Timing.Milli) {
    if (this.startTimeInternal === startTime) {
      return;
    }
    this.startTimeInternal = startTime;
    this.refreshTree();
  }

  get startTime(): Trace.Types.Timing.Milli {
    return this.startTimeInternal;
  }

  set endTime(endTime: Trace.Types.Timing.Milli) {
    if (this.endTimeInternal === endTime) {
      return;
    }
    this.endTimeInternal = endTime;
    this.refreshTree();
  }

  get endTime(): Trace.Types.Timing.Milli {
    return this.endTimeInternal;
  }

  get compactMode(): boolean {
    return this.#compactMode;
  }

  set compactMode(v: boolean) {
    if (this.#compactMode === v) {
      return;
    }
    this.#compactMode = v;
    if (this.dataGrid) {
      this.#applyCompactMode();
    }
  }

  get maxLinkLength(): number|undefined {
    return this.#maxLinkLength;
  }

  set maxLinkLength(maxLinkLength: number|undefined) {
    this.#maxLinkLength = maxLinkLength;
  }

  get maxRows(): number|undefined {
    return this.#maxRows;
  }

  set maxRows(maxRows: number|undefined) {
    if (this.#maxRows === maxRows) {
      return;
    }
    this.#maxRows = maxRows;
    this.refreshTree();
  }

  #applyCompactMode(): void {
    if (this.#compactMode && this.dataGrid) {
      this.splitWidget?.detach();
      this.dataGrid.asWidget().detach();
      this.dataGrid.asWidget().show(this.element);
      this.dataGrid.renderInline();
    }
  }

  #eventNameForSorting(event: Trace.Types.Events.Event): string {
    const name = TimelineUIUtils.eventTitle(event) || event.name;
    if (!this.parsedTrace) {
      return name;
    }
    return name + ':@' + Trace.Handlers.Helpers.getNonResolvedURL(event, this.parsedTrace.data);
  }

  setSearchableView(searchableView: UI.SearchableView.SearchableView): void {
    this.searchableView = searchableView;
  }

  set model(model: {
    selectedEvents: Trace.Types.Events.Event[]|null,
    parsedTrace: Trace.TraceModel.ParsedTrace|null,
    entityMapper: Trace.EntityMapper.EntityMapper|null,
  }) {
    this.#parsedTrace = model.parsedTrace;
    this.#selectedEvents = model.selectedEvents;
    this.#entityMapper = model.entityMapper;
    this.refreshTree();
  }

  entityMapper(): Trace.EntityMapper.EntityMapper|null {
    return this.#entityMapper;
  }

  isThirdPartyTreeView(): boolean {
    return false;
  }

  nodeIsFirstParty(_node: Trace.Extras.TraceTree.Node): boolean {
    return false;
  }

  nodeIsExtension(_node: Trace.Extras.TraceTree.Node): boolean {
    return false;
  }

  init(): void {
    this.linkifier = new Components.Linkifier.Linkifier();

    this.taskFilter = new Trace.Extras.TraceFilter.ExclusiveNameFilter([
      Trace.Types.Events.Name.RUN_TASK,
    ]);
    this.textFilterInternal = new TimelineRegExp();

    this.currentThreadSetting = Common.Settings.Settings.instance().createSetting('timeline-tree-current-thread', 0);
    this.currentThreadSetting.addChangeListener(() => this.refreshTree());

    const columns: DataGrid.DataGrid.ColumnDescriptor[] = [];
    this.populateColumns(columns);

    this.dataGrid = new DataGrid.SortableDataGrid.SortableDataGrid({
      displayName: i18nString(UIStrings.performance),
      columns,
    });
    this.dataGrid.addEventListener(DataGrid.DataGrid.Events.SORTING_CHANGED, this.sortingChanged, this);
    this.dataGrid.element.addEventListener('mousemove', this.onMouseMove.bind(this), true);
    this.dataGrid.element.addEventListener(
        'mouseleave', () => this.dispatchEventToListeners(TimelineTreeView.Events.TREE_ROW_HOVERED, {node: null}));
    this.dataGrid.addEventListener(DataGrid.DataGrid.Events.OPENED_NODE, this.onGridNodeOpened, this);
    this.dataGrid.setResizeMethod(DataGrid.DataGrid.ResizeMethod.LAST);
    this.dataGrid.setRowContextMenuCallback(this.onContextMenu.bind(this));
    this.dataGrid.addEventListener(DataGrid.DataGrid.Events.SELECTED_NODE, this.updateDetailsForSelection, this);

    if (this.#compactMode) {
      this.#applyCompactMode();
      return;
    }

    this.splitWidget = new UI.SplitWidget.SplitWidget(true, true, 'timeline-tree-view-details-split-widget');
    const mainView = new UI.Widget.VBox();
    const toolbar = mainView.element.createChild('devtools-toolbar');
    toolbar.setAttribute('jslog', `${VisualLogging.toolbar()}`);
    toolbar.wrappable = true;
    this.populateToolbar(toolbar);

    this.dataGrid.asWidget().show(mainView.element);

    this.detailsView = new UI.Widget.VBox();
    this.detailsView.element.classList.add('timeline-details-view', 'timeline-details-view-body');
    this.splitWidget.setMainWidget(mainView);
    this.splitWidget.setSidebarWidget(this.detailsView);
    this.splitWidget.hideSidebar();
    this.splitWidget.show(this.element);
    this.splitWidget.addEventListener(UI.SplitWidget.Events.SHOW_MODE_CHANGED, this.onShowModeChanged, this);
  }

  override wasShown(): void {
    super.wasShown();
    this.refreshTree();
    this.dataGrid.addEventListener(DataGrid.DataGrid.Events.SELECTED_NODE, this.#onDataGridSelectionChange, this);
    this.dataGrid.addEventListener(DataGrid.DataGrid.Events.DESELECTED_NODE, this.#onDataGridDeselection, this);
  }

  lastSelectedNode(): Trace.Extras.TraceTree.Node|null|undefined {
    return this.lastSelectedNodeInternal;
  }

  set activeSelection(selection: TimelineSelection) {
    const timings = rangeForSelection(selection);
    const timingMilli = Trace.Helpers.Timing.traceWindowMicroSecondsToMilliSeconds(timings);
    this.setRange(timingMilli.min, timingMilli.max);
  }

  setRange(startTime: Trace.Types.Timing.Milli, endTime: Trace.Types.Timing.Milli): void {
    this.startTime = startTime;
    this.endTime = endTime;
    this.refreshTree();
  }

  highlightEventInTree(event: Trace.Types.Events.Event|null): void {
    // Potentially clear last highlight
    const dataGridElem = event && this.dataGridElementForEvent(event);
    if (!event || (dataGridElem && dataGridElem !== this.#lastHighlightedEvent)) {
      this.#lastHighlightedEvent?.style.setProperty('background-color', '');
    }

    if (event) {
      const rowElem = dataGridElem;
      if (rowElem) {
        this.#lastHighlightedEvent = rowElem;
        this.#lastHighlightedEvent.style.backgroundColor = 'var(--sys-color-yellow-container)';
      }
    }
  }

  filters(): Trace.Extras.TraceFilter.TraceFilter[] {
    return [this.taskFilter, this.textFilterInternal, ...(ActiveFilters.instance().activeFilters())];
  }

  filtersWithoutTextFilter(): Trace.Extras.TraceFilter.TraceFilter[] {
    return [this.taskFilter, ...(ActiveFilters.instance().activeFilters())];
  }

  textFilter(): TimelineRegExp {
    return this.textFilterInternal;
  }

  exposePercentages(): boolean {
    return false;
  }

  populateToolbar(toolbar: UI.Toolbar.Toolbar): void {
    this.caseSensitiveButton =
        new UI.Toolbar.ToolbarToggle(i18nString(UIStrings.matchCase), 'match-case', undefined, 'match-case');
    this.caseSensitiveButton.addEventListener(UI.Toolbar.ToolbarButton.Events.CLICK, () => {
      this.#filterChanged();
    }, this);
    toolbar.appendToolbarItem(this.caseSensitiveButton);

    this.regexButton = new UI.Toolbar.ToolbarToggle(
        i18nString(UIStrings.useRegularExpression), 'regular-expression', undefined, 'regular-expression');
    this.regexButton.addEventListener(UI.Toolbar.ToolbarButton.Events.CLICK, () => {
      this.#filterChanged();
    }, this);
    toolbar.appendToolbarItem(this.regexButton);

    this.matchWholeWord = new UI.Toolbar.ToolbarToggle(
        i18nString(UIStrings.matchWholeWord), 'match-whole-word', undefined, 'match-whole-word');
    this.matchWholeWord.addEventListener(UI.Toolbar.ToolbarButton.Events.CLICK, () => {
      this.#filterChanged();
    }, this);
    toolbar.appendToolbarItem(this.matchWholeWord);

    const textFilterUI = new UI.Toolbar.ToolbarFilter();
    this.textFilterUI = textFilterUI;
    textFilterUI.addEventListener(UI.Toolbar.ToolbarInput.Event.TEXT_CHANGED, this.#filterChanged, this);
    toolbar.appendToolbarItem(textFilterUI);
  }

  appendContextMenuItems(_contextMenu: UI.ContextMenu.ContextMenu, _node: Trace.Extras.TraceTree.Node): void {
  }

  //  TODO(paulirish): rename profileNode to treeNode
  selectProfileNode(treeNode: Trace.Extras.TraceTree.Node, suppressSelectedEvent: boolean): void {
    const pathToRoot: Trace.Extras.TraceTree.Node[] = [];
    let node: (Trace.Extras.TraceTree.Node|null)|Trace.Extras.TraceTree.Node = treeNode;
    for (; node; node = node.parent) {
      pathToRoot.push(node);
    }
    for (let i = pathToRoot.length - 1; i > 0; --i) {
      const gridNode = this.dataGridNodeForTreeNode(pathToRoot[i]);
      if (gridNode?.dataGrid) {
        gridNode.expand();
      }
    }
    const gridNode = this.dataGridNodeForTreeNode(treeNode);
    if (gridNode?.dataGrid) {
      gridNode.reveal();
      gridNode.select(suppressSelectedEvent);
    }
  }

  /**
   * Refreshes the tree. By default, it will only do this
   * if the tree is mounted into the DOM - as in the UI we
   * have multiple trees and we only want to refresh the
   * active one. Pass `true` into this function to force a
   * refresh regardless.
   */
  refreshTree(forceRefresh = false): void {
    if (!this.element.parentElement && !forceRefresh) {
      // This function can be called in different views (Bottom-Up and
      // Call Tree) by the same single event whenever the group-by
      // dropdown changes value. Thus, we bail out whenever the view is
      // not visible, which we know if the related element is detached
      // from the document.
      return;
    }
    this.linkifier.reset();
    this.dataGrid.rootNode().removeChildren();
    if (!this.#parsedTrace) {
      this.updateDetailsForSelection();
      return;
    }
    this.root = this.buildTree();
    const children = this.root.children();
    let maxSelfTime = 0;
    let maxTotalTime = 0;
    const totalUsedTime = this.root.totalTime - this.root.selfTime;
    for (const child of children.values()) {
      maxSelfTime = Math.max(maxSelfTime, child.selfTime);
      maxTotalTime = Math.max(maxTotalTime, child.totalTime);
    }

    const gridNodes: GridNode[] = [];
    for (const child of children.values()) {
      const gridNode = new TreeGridNode(child, totalUsedTime, maxSelfTime, maxTotalTime, this);
      for (const e of child.events) {
        this.eventToTreeNode.set(e, child);
      }
      gridNodes.push(gridNode);
    }

    const columnId = this.dataGrid.sortColumnId() || 'self';
    const sortFunction = this.getSortingFunction(columnId);
    if (sortFunction) {
      gridNodes.sort((a, b) => {
        const res = sortFunction(a, b);
        return this.dataGrid.isSortOrderAscending() ? res : -res;
      });
    }

    const countToInsert = this.#maxRows !== undefined ? Math.min(this.#maxRows, gridNodes.length) : gridNodes.length;
    for (let i = 0; i < countToInsert; i++) {
      this.dataGrid.insertChild(gridNodes[i]);
    }

    this.sortingChanged();

    this.updateDetailsForSelection();
    if (this.searchableView) {
      this.searchableView.refreshSearch();
    }
    const rootNode = this.dataGrid.rootNode();
    if (this.autoSelectFirstChildOnRefresh && rootNode.children.length > 0) {
      rootNode.children[0].select(/* supressSelectedEvent */ true);
    }
  }

  buildTree(): Trace.Extras.TraceTree.Node {
    throw new Error('Not Implemented');
  }

  buildTopDownTree(doNotAggregate: boolean, eventGroupIdCallback: ((arg0: Trace.Types.Events.Event) => string)|null):
      Trace.Extras.TraceTree.Node {
    return new Trace.Extras.TraceTree.TopDownRootNode(this.selectedEvents, {
      filters: this.filters(),
      startTime: this.startTime,
      endTime: this.endTime,
      doNotAggregate,
      eventGroupIdCallback,
    });
  }

  populateColumns(columns: DataGrid.DataGrid.ColumnDescriptor[]): void {
    if (this.compactMode) {
      columns.push(
          ({id: 'self', title: i18nString(UIStrings.selfTime), width: '15%', sortable: true} as
           DataGrid.DataGrid.ColumnDescriptor));
      columns.push(
          ({id: 'total', title: i18nString(UIStrings.totalTime), width: '15%', sortable: true} as
           DataGrid.DataGrid.ColumnDescriptor));
    } else {
      columns.push(
          ({id: 'self', title: i18nString(UIStrings.selfTime), width: '120px', fixedWidth: true, sortable: true} as
           DataGrid.DataGrid.ColumnDescriptor));
      columns.push(
          ({id: 'total', title: i18nString(UIStrings.totalTime), width: '120px', fixedWidth: true, sortable: true} as
           DataGrid.DataGrid.ColumnDescriptor));
    }
    columns.push(
        ({id: 'activity', title: i18nString(UIStrings.activity), disclosure: true, sortable: true} as
         DataGrid.DataGrid.ColumnDescriptor));
  }

  sortingChanged(): void {
    const columnId = this.dataGrid.sortColumnId();
    if (!columnId) {
      return;
    }
    const sortFunction = this.getSortingFunction(columnId);
    if (sortFunction) {
      this.dataGrid.sortNodes(sortFunction, !this.dataGrid.isSortOrderAscending());
    }
  }

  // Gets the sorting function for the tree view nodes.
  getSortingFunction(columnId: string):
      ((a: DataGrid.SortableDataGrid.SortableDataGridNode<GridNode>,
        b: DataGrid.SortableDataGrid.SortableDataGridNode<GridNode>) => number)|null {
    const compareNameSortFn =
        (a: DataGrid.SortableDataGrid.SortableDataGridNode<GridNode>,
         b: DataGrid.SortableDataGrid.SortableDataGridNode<GridNode>): number => {
          const nodeA = (a as TreeGridNode);
          const nodeB = (b as TreeGridNode);
          const eventA = nodeA.profileNode.event;
          const eventB = nodeB.profileNode.event;
          if (!eventA || !eventB) {
            return 0;
          }
          const nameA = this.#eventNameForSorting(eventA);
          const nameB = this.#eventNameForSorting(eventB);
          return nameA.localeCompare(nameB);
        };

    switch (columnId) {
      case 'start-time':
        return compareStartTime;
      case 'self':
        return compareSelfTime;
      case 'total':
        return compareTotalTime;
      case 'activity':
      case 'site':
        return compareNameSortFn;
      default:
        console.assert(false, 'Unknown sort field: ' + columnId);
        return null;
    }

    function compareSelfTime(
        a: DataGrid.SortableDataGrid.SortableDataGridNode<GridNode>,
        b: DataGrid.SortableDataGrid.SortableDataGridNode<GridNode>): number {
      const nodeA = a as TreeGridNode;
      const nodeB = b as TreeGridNode;
      return nodeA.profileNode.selfTime - nodeB.profileNode.selfTime;
    }

    function compareStartTime(
        a: DataGrid.SortableDataGrid.SortableDataGridNode<GridNode>,
        b: DataGrid.SortableDataGrid.SortableDataGridNode<GridNode>): number {
      const nodeA = (a as TreeGridNode);
      const nodeB = (b as TreeGridNode);
      const eventA = nodeA.profileNode.event;
      const eventB = nodeB.profileNode.event;
      // Should not happen, but guard against the nodes not having events.
      if (!eventA || !eventB) {
        return 0;
      }
      return eventA.ts - eventB.ts;
    }

    function compareTotalTime(
        a: DataGrid.SortableDataGrid.SortableDataGridNode<GridNode>,
        b: DataGrid.SortableDataGrid.SortableDataGridNode<GridNode>): number {
      const nodeA = a as TreeGridNode;
      const nodeB = b as TreeGridNode;
      return nodeA.profileNode.totalTime - nodeB.profileNode.totalTime;
    }
  }

  #filterChanged(): void {
    const searchQuery = this.textFilterUI?.value();
    const caseSensitive = this.caseSensitiveButton?.isToggled() ?? false;
    const isRegex = this.regexButton?.isToggled() ?? false;
    const matchWholeWord = this.matchWholeWord?.isToggled() ?? false;

    this.textFilterInternal.setRegExp(
        searchQuery ? Platform.StringUtilities.createSearchRegex(searchQuery, caseSensitive, isRegex, matchWholeWord) :
                      null);
    this.refreshTree();
  }

  private onShowModeChanged(): void {
    if (this.#compactMode || !this.splitWidget) {
      return;
    }
    if (this.splitWidget.showMode() === UI.SplitWidget.ShowMode.ONLY_MAIN) {
      return;
    }
    this.lastSelectedNodeInternal = undefined;
    this.updateDetailsForSelection();
  }

  protected updateDetailsForSelection(): void {
    if (this.#compactMode || !this.splitWidget || !this.detailsView) {
      return;
    }
    const selectedNode = this.dataGrid.selectedNode ? (this.dataGrid.selectedNode as TreeGridNode).profileNode : null;
    if (selectedNode === this.lastSelectedNodeInternal) {
      return;
    }
    if (this.splitWidget.showMode() === UI.SplitWidget.ShowMode.ONLY_MAIN) {
      return;
    }
    this.detailsView.detachChildWidgets();
    this.detailsView.element.removeChildren();
    this.lastSelectedNodeInternal = selectedNode;
    if (selectedNode && this.showDetailsForNode(selectedNode)) {
      return;
    }
    const banner = this.detailsView.element.createChild('div', 'empty-state');
    UI.UIUtils.createTextChild(banner, i18nString(UIStrings.selectItemForDetails));
  }

  showDetailsForNode(_node: Trace.Extras.TraceTree.Node): boolean {
    return false;
  }

  private onMouseMove(event: Event): void {
    const gridNode =
        event.target && (event.target instanceof Node) ? (this.dataGrid.dataGridNodeFromNode((event.target))) : null;
    const profileNode = (gridNode as TreeGridNode)?.profileNode;
    if (profileNode === this.lastHoveredProfileNode) {
      return;
    }
    this.lastHoveredProfileNode = profileNode;
    this.onHover(profileNode);
  }

  onHover(node: Trace.Extras.TraceTree.Node|null): void {
    this.dispatchEventToListeners(TimelineTreeView.Events.TREE_ROW_HOVERED, {node});
  }

  onClick(node: Trace.Extras.TraceTree.Node|null): void {
    this.dispatchEventToListeners(TimelineTreeView.Events.TREE_ROW_CLICKED, {node});
  }

  override childWasDetached(_widget: UI.Widget.Widget): void {
    this.dataGrid.removeEventListener(DataGrid.DataGrid.Events.SELECTED_NODE, this.#onDataGridSelectionChange);
    this.dataGrid.removeEventListener(DataGrid.DataGrid.Events.DESELECTED_NODE, this.#onDataGridDeselection);
  }

  /**
   * This event fires when the user selects a row in the grid, either by
   * clicking or by using the arrow keys. We want to have the same effect as
   * when the user hover overs a row.
   */
  #onDataGridSelectionChange(event: Common.EventTarget.EventTargetEvent<DataGrid.DataGrid.DataGridNode<GridNode>>):
      void {
    this.onClick((event.data as GridNode).profileNode);
    this.onHover((event.data as GridNode).profileNode);
  }

  /**
   * Called when the user deselects a row.
   * This can either be because they have selected a new row
   * (you should expect a SELECTED_NODE event after this one)
   * or because they have deselected without a new selection.
   */
  #onDataGridDeselection(): void {
    this.onClick(null);
    this.onHover(null);
  }

  onGridNodeOpened(): void {
    const gridNode = this.dataGrid.selectedNode as TreeGridNode;
    // Use tree's hover method in case of unique hover experiences (like ThirdPartyTree).
    this.onHover(gridNode.profileNode);
    this.updateDetailsForSelection();
  }

  private onContextMenu(
      contextMenu: UI.ContextMenu.ContextMenu, eventGridNode: DataGrid.DataGrid.DataGridNode<GridNode>): void {
    const gridNode = (eventGridNode as GridNode);
    if (gridNode.linkElement) {
      contextMenu.appendApplicableItems(gridNode.linkElement);
    }
    const profileNode = gridNode.profileNode;
    if (profileNode) {
      this.appendContextMenuItems(contextMenu, profileNode);
    }
  }

  dataGridElementForEvent(event: Trace.Types.Events.Event|null): HTMLElement|null {
    if (!event) {
      return null;
    }
    const treeNode = this.eventToTreeNode.get(event);
    return (treeNode && this.dataGridNodeForTreeNode(treeNode)?.element()) ?? null;
  }

  dataGridNodeForTreeNode(treeNode: Trace.Extras.TraceTree.Node): GridNode|null {
    return treeNodeToGridNode.get(treeNode) || null;
  }

  // UI.SearchableView.Searchable implementation

  onSearchCanceled(): void {
    this.searchResults = [];
    this.currentResult = 0;
  }

  performSearch(searchConfig: UI.SearchableView.SearchConfig, _shouldJump: boolean, _jumpBackwards?: boolean): void {
    this.searchResults = [];
    this.currentResult = 0;
    if (!this.root) {
      return;
    }
    const searchRegex = searchConfig.toSearchRegex();
    this.searchResults = this.root.searchTree(
        event => TimelineUIUtils.testContentMatching(event, searchRegex.regex, this.#parsedTrace?.data || undefined));
    this.searchableView.updateSearchMatchesCount(this.searchResults.length);
  }

  jumpToNextSearchResult(): void {
    if (!this.searchResults.length || this.currentResult === undefined) {
      return;
    }
    this.selectProfileNode(this.searchResults[this.currentResult], false);
    this.currentResult = Platform.NumberUtilities.mod(this.currentResult + 1, this.searchResults.length);
  }

  jumpToPreviousSearchResult(): void {
    if (!this.searchResults.length || this.currentResult === undefined) {
      return;
    }
    this.selectProfileNode(this.searchResults[this.currentResult], false);
    this.currentResult = Platform.NumberUtilities.mod(this.currentResult - 1, this.searchResults.length);
  }

  supportsCaseSensitiveSearch(): boolean {
    return true;
  }

  supportsWholeWordSearch(): boolean {
    return true;
  }

  supportsRegexSearch(): boolean {
    return true;
  }
}

export namespace TimelineTreeView {
  export const enum Events {
    TREE_ROW_HOVERED = 'TreeRowHovered',
    BOTTOM_UP_BUTTON_CLICKED = 'BottomUpButtonClicked',
    TREE_ROW_CLICKED = 'TreeRowClicked',
  }

  export interface EventTypes {
    [Events.TREE_ROW_HOVERED]: {node: Trace.Extras.TraceTree.Node|null, events?: Trace.Types.Events.Event[]};
    [Events.BOTTOM_UP_BUTTON_CLICKED]: Trace.Extras.TraceTree.Node|null;
    [Events.TREE_ROW_CLICKED]: {node: Trace.Extras.TraceTree.Node|null, events?: Trace.Types.Events.Event[]};
  }
}

/**
 * GridNodes are 1:1 with `TraceTree.Node`s but represent them within the DataGrid. It handles the representation as a row.
 * `TreeGridNode` extends this to maintain relationship to the tree, and handles populate().
 *
 * `TimelineStackView` (aka heaviest stack) uses GridNode directly (as there's no hierarchy there), otherwise these TreeGridNode could probably be consolidated.
 */
export class GridNode extends DataGrid.SortableDataGrid.SortableDataGridNode<GridNode> {
  protected populated: boolean;
  profileNode: Trace.Extras.TraceTree.Node;
  protected treeView: TimelineTreeView;
  protected grandTotalTime: number;
  protected maxSelfTime: number;
  protected maxTotalTime: number;
  linkElement: Element|null;

  constructor(
      profileNode: Trace.Extras.TraceTree.Node, grandTotalTime: number, maxSelfTime: number, maxTotalTime: number,
      treeView: TimelineTreeView) {
    super(null, false);
    this.populated = false;
    this.profileNode = profileNode;
    this.treeView = treeView;
    this.grandTotalTime = grandTotalTime;
    this.maxSelfTime = maxSelfTime;
    this.maxTotalTime = maxTotalTime;
    this.linkElement = null;
  }

  override createCell(columnId: string): HTMLElement {
    if (columnId === 'activity' || columnId === 'site') {
      return this.createNameCell(columnId);
    }
    return this.createValueCell(columnId) || super.createCell(columnId);
  }

  private createNameCell(columnId: string): HTMLElement {
    const cell = this.createTD(columnId);
    const container = cell.createChild('div', 'name-container');
    const iconContainer = container.createChild('div', 'activity-icon-container');
    const icon = iconContainer.createChild('div', 'activity-icon');
    const name = container.createChild('div', 'activity-name');
    const event = this.profileNode.event;
    if (this.profileNode.isGroupNode()) {
      const treeView = (this.treeView as AggregatedTimelineTreeView);
      const info = treeView.displayInfoForGroupNode(this.profileNode);
      name.textContent = info.name;
      icon.style.backgroundColor = info.color;
      if (info.icon) {
        iconContainer.insertBefore(info.icon, icon);
      }

      // Include badges with the name, if relevant.
      if (columnId === 'site' && this.treeView.isThirdPartyTreeView()) {
        const thirdPartyTree = this.treeView;
        let badgeText = '';

        if (thirdPartyTree.nodeIsFirstParty(this.profileNode)) {
          badgeText = i18nString(UIStrings.firstParty);
        } else if (thirdPartyTree.nodeIsExtension(this.profileNode)) {
          badgeText = i18nString(UIStrings.extension);
        }

        if (badgeText) {
          const badge = container.createChild('div', 'entity-badge');
          badge.textContent = badgeText;
          UI.ARIAUtils.setLabel(badge, badgeText);
        }
      }
    } else if (event) {
      name.textContent = TimelineUIUtils.eventTitle(event);
      const parsedTrace = this.treeView.parsedTrace;
      const target = parsedTrace ? targetForEvent(parsedTrace, event) : null;
      const linkifier = this.treeView.linkifier;
      const isFreshOrEnhanced =
          Boolean(parsedTrace && Tracing.FreshRecording.Tracker.instance().recordingIsFreshOrEnhanced(parsedTrace));
      const maxLength = this.treeView.maxLinkLength;
      this.linkElement = TimelineUIUtils.linkifyTopCallFrame(event, target, linkifier, isFreshOrEnhanced, maxLength);
      if (this.linkElement) {
        container.createChild('div', 'activity-link').appendChild(this.linkElement);
      }
      UI.ARIAUtils.setLabel(icon, TimelineUIUtils.eventStyle(event).category.title);
      icon.style.backgroundColor = TimelineUIUtils.eventColor(event);
      if (Trace.Types.Extensions.isSyntheticExtensionEntry(event)) {
        icon.style.backgroundColor = Extensions.ExtensionUI.extensionEntryColor(event);
      }
    }
    return cell;
  }

  private createValueCell(columnId: string): HTMLElement|null {
    if (columnId !== 'self' && columnId !== 'total' && columnId !== 'start-time' && columnId !== 'transfer-size') {
      return null;
    }

    let showPercents = false;
    let value: number;
    let maxTime: number|undefined;
    let event: Trace.Types.Events.Event|null;
    let isSize = false;
    let showBottomUpButton = false;
    const thirdPartyView = this.treeView;
    switch (columnId) {
      case 'start-time': {
        event = this.profileNode.event;
        const parsedTrace = this.treeView.parsedTrace;
        if (!parsedTrace) {
          throw new Error('Unable to load trace data for tree view');
        }
        const timings = event && Trace.Helpers.Timing.eventTimingsMilliSeconds(event);
        const startTime = timings?.startTime ?? 0;
        value = startTime - Trace.Helpers.Timing.microToMilli(parsedTrace.data.Meta.traceBounds.min);
      } break;
      case 'self':
        value = this.profileNode.selfTime;
        maxTime = this.maxSelfTime;
        showPercents = true;
        showBottomUpButton = thirdPartyView.isThirdPartyTreeView();
        break;
      case 'total':
        value = this.profileNode.totalTime;
        maxTime = this.maxTotalTime;
        showPercents = true;
        break;
      case 'transfer-size':
        value = this.profileNode.transferSize;
        isSize = true;
        break;
      default:
        return null;
    }
    const cell = this.createTD(columnId);
    cell.className = 'numeric-column';
    let textDiv;
    if (!isSize) {
      cell.setAttribute('title', i18n.TimeUtilities.preciseMillisToString(value, 4));
      textDiv = cell.createChild('div');
      textDiv.createChild('span').textContent = i18n.TimeUtilities.preciseMillisToString(value, 1);
    } else {
      cell.setAttribute('title', i18n.ByteUtilities.formatBytesToKb(value));
      textDiv = cell.createChild('div');
      textDiv.createChild('span').textContent = i18n.ByteUtilities.formatBytesToKb(value);
    }

    if (showPercents && this.treeView.exposePercentages()) {
      textDiv.createChild('span', 'percent-column').textContent =
          i18nString(UIStrings.percentPlaceholder, {PH1: (value / this.grandTotalTime * 100).toFixed(1)});
    }
    if (maxTime) {
      textDiv.classList.add('background-bar-text');
      cell.createChild('div', 'background-bar-container').createChild('div', 'background-bar').style.width =
          (value * 100 / maxTime).toFixed(1) + '%';
    }
    // Generate button on hover for 3P self time cell.
    if (showBottomUpButton) {
      this.generateBottomUpButton(textDiv);
    }
    return cell;
  }

  // Generates bottom up tree hover button and appends it to the provided cell element.
  private generateBottomUpButton(textDiv: HTMLElement): void {
    const button = new Buttons.Button.Button();
    button.data = {
      variant: Buttons.Button.Variant.ICON,
      iconName: 'account-tree',
      size: Buttons.Button.Size.SMALL,
      toggledIconName: i18nString(UIStrings.bottomUp),
    };
    UI.ARIAUtils.setLabel(button, i18nString(UIStrings.viewBottomUp));
    button.addEventListener('click', () => this.#bottomUpButtonClicked());
    UI.Tooltip.Tooltip.install(button, i18nString(UIStrings.bottomUp));

    // Append the button to the last column
    textDiv.appendChild(button);
  }

  #bottomUpButtonClicked(): void {
    // We should also trigger an event to "unhover" the 3P tree row. Since this isn't
    // triggered when clicking the bottom up button.
    this.treeView.dispatchEventToListeners(TimelineTreeView.Events.TREE_ROW_HOVERED, {node: null});
    this.treeView.dispatchEventToListeners(TimelineTreeView.Events.BOTTOM_UP_BUTTON_CLICKED, this.profileNode);
  }
}

/**
 * `TreeGridNode` lets a `GridNode` (row) populate based on its tree children.
 */
export class TreeGridNode extends GridNode {
  constructor(
      profileNode: Trace.Extras.TraceTree.Node, grandTotalTime: number, maxSelfTime: number, maxTotalTime: number,
      treeView: TimelineTreeView) {
    super(profileNode, grandTotalTime, maxSelfTime, maxTotalTime, treeView);
    this.setHasChildren(this.profileNode.hasChildren());
    treeNodeToGridNode.set(profileNode, this);
  }

  override populate(): void {
    if (this.populated) {
      return;
    }
    this.populated = true;
    if (!this.profileNode.children) {
      return;
    }
    for (const node of this.profileNode.children().values()) {
      const gridNode = new TreeGridNode(node, this.grandTotalTime, this.maxSelfTime, this.maxTotalTime, this.treeView);
      for (const e of node.events) {
        this.treeView.eventToTreeNode.set(e, node);
      }
      this.insertChildOrdered(gridNode);
    }
  }
}

const treeNodeToGridNode = new WeakMap<Trace.Extras.TraceTree.Node, TreeGridNode>();

export class AggregatedTimelineTreeView extends TimelineTreeView {
  protected readonly groupBySetting: Common.Settings.Setting<AggregatedTimelineTreeView.GroupBy>;
  readonly stackView: TimelineStackView;

  constructor(element?: HTMLElement) {
    super(element);
    this.groupBySetting = Common.Settings.Settings.instance().createSetting(
        'timeline-tree-group-by', AggregatedTimelineTreeView.GroupBy.None);
    this.groupBySetting.addChangeListener(() => this.refreshTree());
    this.init();
    this.stackView = new TimelineStackView(this);
    this.stackView.addEventListener(TimelineStackView.Events.SELECTION_CHANGED, this.onStackViewSelectionChanged, this);
  }

  setGroupBySetting(groupBy: AggregatedTimelineTreeView.GroupBy): void {
    this.groupBySetting.set(groupBy);
  }

  override set activeSelection(selection: TimelineSelection) {
    super.activeSelection = selection;
    const rootNode = this.dataGrid.rootNode();
    if (rootNode.children.length) {
      rootNode.children[0].select(/* suppressSelectedEvent */ true);
    }
    this.updateDetailsForSelection();
  }

  private beautifyDomainName(this: AggregatedTimelineTreeView, name: string, node: Trace.Extras.TraceTree.Node):
      string {
    if (AggregatedTimelineTreeView.isExtensionInternalURL(name as Platform.DevToolsPath.UrlString)) {
      name = i18nString(UIStrings.chromeExtensionsOverhead);
    } else if (AggregatedTimelineTreeView.isV8NativeURL(name as Platform.DevToolsPath.UrlString)) {
      name = i18nString(UIStrings.vRuntime);
    } else if (name.startsWith('chrome-extension')) {
      name = this.entityMapper()?.entityForEvent(node.event)?.name || name;
    }
    return name;
  }

  displayInfoForGroupNode(node: Trace.Extras.TraceTree.Node): {
    name: string,
    color: string,
    icon?: Element,
  } {
    const categories = Trace.Styles.getCategoryStyles();
    const color = TimelineUIUtils.eventColor(node.event);
    const unattributed = i18nString(UIStrings.unattributed);

    const id = typeof node.id === 'symbol' ? undefined : node.id;

    switch (this.groupBySetting.get()) {
      case AggregatedTimelineTreeView.GroupBy.Category: {
        const idIsValid = id && Trace.Styles.stringIsEventCategory(id);
        const category = idIsValid ? categories[id] || categories['other'] : {title: unattributed, color: unattributed};

        const color = category instanceof Trace.Styles.TimelineCategory ?
            ThemeSupport.ThemeSupport.instance().getComputedValue(category.cssVariable) :
            category.color;
        return {name: category.title, color};
      }

      case AggregatedTimelineTreeView.GroupBy.Domain:
      case AggregatedTimelineTreeView.GroupBy.Subdomain:
      case AggregatedTimelineTreeView.GroupBy.ThirdParties: {
        // This `undefined` is [unattributed]
        // TODO(paulirish): Improve attribution to reduce amount of items in [unattributed].
        const domainName = id ? this.beautifyDomainName(id, node) : undefined;
        return {name: domainName || unattributed, color};
      }

      case AggregatedTimelineTreeView.GroupBy.EventName: {
        if (!node.event) {
          throw new Error('Unable to find event for group by operation');
        }
        const name = TimelineUIUtils.eventTitle(node.event);
        return {
          name,
          color,
        };
      }

      case AggregatedTimelineTreeView.GroupBy.URL:
        break;

      case AggregatedTimelineTreeView.GroupBy.Frame: {
        const frame = id ? this.parsedTrace?.data.PageFrames.frames.get(id) : undefined;
        const frameName = frame ? TimelineUIUtils.displayNameForFrame(frame) : i18nString(UIStrings.page);
        return {name: frameName, color};
      }

      default:
        console.assert(false, 'Unexpected grouping type');
    }
    return {
      name: id || unattributed,
      color,
    };
  }

  override populateToolbar(toolbar: UI.Toolbar.Toolbar): void {
    super.populateToolbar(toolbar);
    const groupBy = AggregatedTimelineTreeView.GroupBy;
    const options = [
      {label: i18nString(UIStrings.noGrouping), value: groupBy.None},
      {label: i18nString(UIStrings.groupByActivity), value: groupBy.EventName},
      {label: i18nString(UIStrings.groupByCategory), value: groupBy.Category},
      {label: i18nString(UIStrings.groupByDomain), value: groupBy.Domain},
      {label: i18nString(UIStrings.groupByFrame), value: groupBy.Frame},
      {label: i18nString(UIStrings.groupBySubdomain), value: groupBy.Subdomain},
      {label: i18nString(UIStrings.groupByUrl), value: groupBy.URL},
      {label: i18nString(UIStrings.groupByThirdParties), value: groupBy.ThirdParties},
    ];
    toolbar.appendToolbarItem(
        new UI.Toolbar.ToolbarSettingComboBox(options, this.groupBySetting, i18nString(UIStrings.groupBy)));
    if (!this.compactMode && this.splitWidget) {
      toolbar.appendSpacer();
      toolbar.appendToolbarItem(this.splitWidget.createShowHideSidebarButton(
          i18nString(UIStrings.showHeaviestStack), i18nString(UIStrings.hideHeaviestStack),
          i18nString(UIStrings.heaviestStackShown), i18nString(UIStrings.heaviestStackHidden)));
    }
  }

  private buildHeaviestStack(treeNode: Trace.Extras.TraceTree.Node): Trace.Extras.TraceTree.Node[] {
    console.assert(Boolean(treeNode.parent), 'Attempt to build stack for tree root');
    let result: Trace.Extras.TraceTree.Node[] = [];
    // Do not add root to the stack, as it's the tree itself.
    for (let node: Trace.Extras.TraceTree.Node = treeNode; node?.parent; node = node.parent) {
      result.push(node);
    }
    result = result.reverse();
    for (let node: Trace.Extras.TraceTree.Node = treeNode; node?.children()?.size;) {
      const children = Array.from(node.children().values());
      node = children.reduce((a, b) => a.totalTime > b.totalTime ? a : b);
      result.push(node);
    }
    return result;
  }

  override exposePercentages(): boolean {
    return true;
  }

  private onStackViewSelectionChanged(): void {
    const treeNode = this.stackView.selectedTreeNode();
    if (treeNode) {
      this.selectProfileNode(treeNode, true);
    }
  }

  override showDetailsForNode(node: Trace.Extras.TraceTree.Node): boolean {
    const stack = this.buildHeaviestStack(node);
    this.stackView.setStack(stack, node);
    this.stackView.show(this.detailsView.element);
    return true;
  }

  protected groupingFunction(groupBy: AggregatedTimelineTreeView.GroupBy):
      ((arg0: Trace.Types.Events.Event) => string)|null {
    const GroupBy = AggregatedTimelineTreeView.GroupBy;
    switch (groupBy) {
      case GroupBy.None:
        return null;
      case GroupBy.EventName:
        return (event: Trace.Types.Events.Event) => TimelineUIUtils.eventStyle(event).title;
      case GroupBy.Category:
        return (event: Trace.Types.Events.Event) => TimelineUIUtils.eventStyle(event).category.name;
      case GroupBy.Subdomain:
      case GroupBy.Domain:
      case GroupBy.ThirdParties:
        return this.domainByEvent.bind(this, groupBy);
      case GroupBy.URL:
        return (event: Trace.Types.Events.Event) => {
          const parsedTrace = this.parsedTrace;
          return parsedTrace ? Trace.Handlers.Helpers.getNonResolvedURL(event, parsedTrace.data) ?? '' : '';
        };
      case GroupBy.Frame:
        return (event: Trace.Types.Events.Event) => {
          const frameId = Trace.Helpers.Trace.frameIDForEvent(event);
          return frameId || this.parsedTrace?.data.Meta.mainFrameId || '';
        };
      default:
        console.assert(false, `Unexpected aggregation setting: ${groupBy}`);
        return null;
    }
  }

  // This is our groupingFunction that returns the eventId in Domain, Subdomain, and ThirdParty groupBy scenarios.
  // The eventid == the identity of a node that we expect in a bottomUp tree (either without grouping or with the groupBy grouping)
  // A "top node" (in `ungroupedTopNodes`) is aggregated by this. (But so are all the other nodes, except the `GroupNode`s)
  private domainByEvent(groupBy: AggregatedTimelineTreeView.GroupBy, event: Trace.Types.Events.Event): string {
    const parsedTrace = this.parsedTrace;
    if (!parsedTrace) {
      return '';
    }
    const url = Trace.Handlers.Helpers.getNonResolvedURL(event, parsedTrace.data);
    if (!url) {
      // We could have receiveDataEvents (that don't have a url), but that have been
      // attributed to an entity, let's check for these. This is used for ThirdParty grouping.
      const entity = this.entityMapper()?.entityForEvent(event);
      if (groupBy === AggregatedTimelineTreeView.GroupBy.ThirdParties && entity) {
        if (!entity) {
          return '';
        }
        const firstDomain = entity.domains[0];
        const parsedURL = Common.ParsedURL.ParsedURL.fromString(firstDomain);
        // chrome-extension check must come before entity.name.
        if (parsedURL?.scheme === 'chrome-extension') {
          return `${parsedURL.scheme}://${parsedURL.host}`;
        }
        return entity.name;
      }
      return '';
    }
    if (AggregatedTimelineTreeView.isExtensionInternalURL(url)) {
      return AggregatedTimelineTreeView.extensionInternalPrefix;
    }
    if (AggregatedTimelineTreeView.isV8NativeURL(url)) {
      return AggregatedTimelineTreeView.v8NativePrefix;
    }
    const parsedURL = Common.ParsedURL.ParsedURL.fromString(url);
    if (!parsedURL) {
      return '';
    }
    if (parsedURL.scheme === 'chrome-extension') {
      return parsedURL.scheme + '://' + parsedURL.host;
    }
    // This must follow after the extension checks.
    if (groupBy === AggregatedTimelineTreeView.GroupBy.ThirdParties) {
      const entity = this.entityMapper()?.entityForEvent(event);
      if (!entity) {
        return '';
      }

      return entity.name;
    }
    if (groupBy === AggregatedTimelineTreeView.GroupBy.Subdomain) {
      return parsedURL.host;
    }
    if (/^[.0-9]+$/.test(parsedURL.host)) {
      return parsedURL.host;
    }
    const domainMatch = /([^.]*\.)?[^.]*$/.exec(parsedURL.host);
    return domainMatch?.[0] || '';
  }

  private static isExtensionInternalURL(url: Platform.DevToolsPath.UrlString): boolean {
    return url.startsWith(AggregatedTimelineTreeView.extensionInternalPrefix);
  }

  private static isV8NativeURL(url: Platform.DevToolsPath.UrlString): boolean {
    return url.startsWith(AggregatedTimelineTreeView.v8NativePrefix);
  }

  private static readonly extensionInternalPrefix = 'extensions::';
  private static readonly v8NativePrefix = 'native ';

  override onHover(node: Trace.Extras.TraceTree.Node|null): void {
    if (node !== null && this.groupBySetting.get() === AggregatedTimelineTreeView.GroupBy.ThirdParties) {
      const events = this.#getThirdPartyEventsForNode(node);
      this.dispatchEventToListeners(TimelineTreeView.Events.TREE_ROW_HOVERED, {node, events});
      return;
    }
    this.dispatchEventToListeners(TimelineTreeView.Events.TREE_ROW_HOVERED, {node});
  }

  override onClick(node: Trace.Extras.TraceTree.Node|null): void {
    if (node !== null && this.groupBySetting.get() === AggregatedTimelineTreeView.GroupBy.ThirdParties) {
      const events = this.#getThirdPartyEventsForNode(node);
      this.dispatchEventToListeners(TimelineTreeView.Events.TREE_ROW_CLICKED, {node, events});
      return;
    }
    this.dispatchEventToListeners(TimelineTreeView.Events.TREE_ROW_CLICKED, {node});
  }

  #getThirdPartyEventsForNode(node: Trace.Extras.TraceTree.Node): Trace.Types.Events.Event[]|undefined {
    if (!node.event) {
      return;
    }
    const entity = this.entityMapper()?.entityForEvent(node.event);
    // Should be [unattributed]. Just use the node's events.
    if (!entity) {
      return node.events;
    }
    const events = this.entityMapper()?.eventsForEntity(entity);
    return events;
  }
}
export namespace AggregatedTimelineTreeView {
  export enum GroupBy {
    /* eslint-disable @typescript-eslint/naming-convention -- Used by web_tests. */
    None = 'None',
    EventName = 'EventName',
    Category = 'Category',
    Domain = 'Domain',
    Subdomain = 'Subdomain',
    URL = 'URL',
    Frame = 'Frame',
    ThirdParties = 'ThirdParties',
    /* eslint-enable @typescript-eslint/naming-convention */
  }
}

export class CallTreeTimelineTreeView extends AggregatedTimelineTreeView {
  constructor(element?: HTMLElement) {
    super(element);
    this.element.setAttribute('jslog', `${VisualLogging.pane('call-tree').track({resize: true})}`);
    this.dataGrid.markColumnAsSortedBy('total', DataGrid.DataGrid.Order.Descending);
  }

  override buildTree(): Trace.Extras.TraceTree.Node {
    const grouping = this.groupBySetting.get();
    return this.buildTopDownTree(false, this.groupingFunction(grouping));
  }
}

export class BottomUpTimelineTreeView extends AggregatedTimelineTreeView {
  constructor(element?: HTMLElement) {
    super(element);
    this.element.setAttribute('jslog', `${VisualLogging.pane('bottom-up').track({resize: true})}`);
    this.dataGrid.markColumnAsSortedBy('self', DataGrid.DataGrid.Order.Descending);
  }

  override buildTree(): Trace.Extras.TraceTree.Node {
    return new Trace.Extras.TraceTree.BottomUpRootNode(this.selectedEvents, {
      textFilter: this.textFilter(),
      filters: this.filtersWithoutTextFilter(),
      startTime: this.startTime,
      endTime: this.endTime,
      eventGroupIdCallback: this.groupingFunction(this.groupBySetting.get()),
      // To include instant events. When this is set to true, instant events are
      // considered (to calculate transfer size). This then includes these events in tree nodes.
      calculateTransferSize: true,
      // We should forceGroupIdCallback if filtering by 3P for correct 3P grouping.
      forceGroupIdCallback: this.groupBySetting.get() === AggregatedTimelineTreeView.GroupBy.ThirdParties,
    });
  }
}

export class TimelineStackView extends
    Common.ObjectWrapper.eventMixin<TimelineStackView.EventTypes, typeof UI.Widget.VBox>(UI.Widget.VBox) {
  private readonly treeView: TimelineTreeView;
  private readonly dataGrid: DataGrid.ViewportDataGrid.ViewportDataGrid<unknown>;

  constructor(treeView: TimelineTreeView) {
    super();
    const header = this.element.createChild('div', 'timeline-stack-view-header');
    header.textContent = i18nString(UIStrings.heaviestStack);
    this.treeView = treeView;
    const columns: DataGrid.DataGrid.ColumnDescriptor[] = [
      {id: 'total', title: i18nString(UIStrings.totalTime), fixedWidth: true, width: '110px', sortable: false},
      {id: 'activity', title: i18nString(UIStrings.activity), sortable: false},
    ];
    this.dataGrid = new DataGrid.ViewportDataGrid.ViewportDataGrid({
      displayName: i18nString(UIStrings.timelineStack),
      columns,
    });

    this.dataGrid.setResizeMethod(DataGrid.DataGrid.ResizeMethod.LAST);
    this.dataGrid.addEventListener(DataGrid.DataGrid.Events.SELECTED_NODE, this.onSelectionChanged, this);

    // Hover dim behavior within stackview sidebar
    this.dataGrid.element.addEventListener('mouseenter', this.onMouseMove.bind(this), true);
    this.dataGrid.element.addEventListener(
        'mouseleave', () => this.dispatchEventToListeners(TimelineStackView.Events.TREE_ROW_HOVERED, null));

    this.dataGrid.asWidget().show(this.element);
  }

  setStack(stack: Trace.Extras.TraceTree.Node[], selectedNode: Trace.Extras.TraceTree.Node): void {
    const rootNode = this.dataGrid.rootNode();
    rootNode.removeChildren();
    let nodeToReveal: GridNode|null = null;
    const totalTime = Math.max.apply(Math, stack.map(node => node.totalTime));
    for (const node of stack) {
      const gridNode = new GridNode(node, totalTime, totalTime, totalTime, this.treeView);
      rootNode.appendChild(gridNode);
      if (node === selectedNode) {
        nodeToReveal = gridNode;
      }
    }
    if (nodeToReveal) {
      nodeToReveal.revealAndSelect();
    }
  }

  onMouseMove(event: Event): void {
    const gridNode = event.target && (event.target instanceof Node) ?
        (this.dataGrid.dataGridNodeFromNode((event.target as Node))) :
        null;
    const profileNode = (gridNode as TreeGridNode)?.profileNode;
    this.dispatchEventToListeners(TimelineStackView.Events.TREE_ROW_HOVERED, profileNode);
  }

  selectedTreeNode(): Trace.Extras.TraceTree.Node|null {
    const selectedNode = this.dataGrid.selectedNode;
    return selectedNode && (selectedNode as GridNode).profileNode;
  }

  private onSelectionChanged(): void {
    this.dispatchEventToListeners(TimelineStackView.Events.SELECTION_CHANGED);
  }
}

export namespace TimelineStackView {
  export const enum Events {
    SELECTION_CHANGED = 'SelectionChanged',
    TREE_ROW_HOVERED = 'TreeRowHovered',
  }

  export interface EventTypes {
    [Events.TREE_ROW_HOVERED]: Trace.Extras.TraceTree.Node|null;
    [Events.SELECTION_CHANGED]: void;
  }
}
