// Copyright (c) 2020 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import * as Common from '../../common/common.js';
import * as ComponentHelpers from '../../component_helpers/component_helpers.js';
import * as Host from '../../host/host.js';
import * as Platform from '../../platform/platform.js';
import * as Coordinator from '../../render_coordinator/render_coordinator.js';
import * as LitHtml from '../../third_party/lit-html/lit-html.js';
// eslint-disable-next-line rulesdir/es_modules_import
import * as UI from '../../ui/ui.js';

const {ls} = Common;

const coordinator = Coordinator.RenderCoordinator.RenderCoordinator.instance();

import {addColumnVisibilityCheckboxes, addSortableColumnItems} from './DataGridContextMenuUtils.js';
import {calculateColumnWidthPercentageFromWeighting, calculateFirstFocusableCell, Cell, CellPosition, Column, ContextMenuHeaderResetClickEvent, getRowEntryForColumnId, handleArrowKeyNavigation, renderCellValue, Row, SortDirection, SortState} from './DataGridUtils.js';

export interface DataGridContextMenusConfiguration {
  headerRow?: (menu: UI.ContextMenu.ContextMenu, columns: readonly Column[]) => void;
  bodyRow?: (menu: UI.ContextMenu.ContextMenu, columns: readonly Column[], row: Readonly<Row>) => void;
}

export interface DataGridData {
  columns: Column[];
  rows: Row[];
  activeSort: SortState|null;
  contextMenus?: DataGridContextMenusConfiguration;
}

export class ColumnHeaderClickEvent extends Event {
  data: {
    column: Column,
    columnIndex: number,
  };

  constructor(column: Column, columnIndex: number) {
    super('column-header-click');
    this.data = {
      column,
      columnIndex,
    };
  }
}

export class NewUserFilterTextEvent extends Event {
  data: {filterText: string};

  constructor(filterText: string) {
    super('new-user-filter-text', {
      composed: true,
    });

    this.data = {
      filterText,
    };
  }
}


export class BodyCellFocusedEvent extends Event {
  /**
   * Although the DataGrid cares only about the focused cell, and has no concept
   * of a focused row, many components that render a data grid want to know what
   * row is active, so on the cell focused event we also send the row that the
   * cell is part of.
   */
  data: {
    cell: Cell,
    row: Row,
  };

  constructor(cell: Cell, row: Row) {
    super('cell-focused', {
      composed: true,
    });
    this.data = {
      cell,
      row,
    };
  }
}

const KEYS_TREATED_AS_CLICKS = new Set([' ', 'Enter']);

const ROW_HEIGHT_PIXELS = 18;
const PADDING_ROWS_COUNT = 10;

export class DataGrid extends HTMLElement {
  private readonly shadow = this.attachShadow({mode: 'open'});
  private columns: readonly Column[] = [];
  private rows: readonly Row[] = [];
  private sortState: Readonly<SortState>|null = null;
  private scheduledRender = false;
  private contextMenus?: DataGridContextMenusConfiguration = undefined;
  private currentResize: {
    rightCellCol: HTMLTableColElement,
    leftCellCol: HTMLTableColElement,
    leftCellColInitialPercentageWidth: number,
    rightCellColInitialPercentageWidth: number,
    initialLeftCellWidth: number,
    initialRightCellWidth: number,
    initialMouseX: number,
    documentForCursorChange: Document,
    cursorToRestore: string,
  }|null = null;
  // Because we only render a subset of rows, we need a way to look up the
  // actual row index from the original dataset. We could use this.rows[index]
  // but that's O(n) and will slow as the dataset grows. A weakmap makes the
  // lookup constant.
  private readonly rowIndexMap = new WeakMap<Row, number>();
  private readonly resizeObserver = new ResizeObserver(() => {
    this.alignScrollHandlers();
  });

  // These have to be bound as they are put onto the global document, not onto
  // this element, so LitHtml does not bind them for us.
  private boundOnResizePointerUp = this.onResizePointerUp.bind(this);
  private boundOnResizePointerMove = this.onResizePointerMove.bind(this);
  private boundOnResizePointerDown = this.onResizePointerDown.bind(this);

  /**
   * Following guidance from
   * https://www.w3.org/TR/wai-aria-practices/examples/grid/dataGrids.html, we
   * allow a single cell inside the table to be focusable, such that when a user
   * tabs in they select that cell. IMPORTANT: if the data-grid has sortable
   * columns, the user has to be able to navigate to the headers to toggle the
   * sort. [0,0] is considered the first cell INCLUDING the column header
   * Therefore if a user is on the first header cell, the position is considered [0, 0],
   * and if a user is on the first body cell, the position is considered [0, 1].
   *
   * We set the selectable cell to the first tbody value by default, but then on the
   * first render if any of the columns are sortable we'll set the active cell
   * to [0, 0].
   */
  private focusableCell: CellPosition = [0, 1];
  private hasRenderedAtLeastOnce = false;
  private userHasFocusInDataGrid = false;
  private userHasScrolled = false;
  private enqueuedRender = false;

  constructor() {
    super();
    this.shadow.adoptedStyleSheets = [
      ...ComponentHelpers.GetStylesheet.getStyleSheets('ui/inspectorScrollbars.css', {enableLegacyPatching: false}),
    ];
  }
  connectedCallback(): void {
    ComponentHelpers.SetCSSProperty.set(this, '--table-row-height', `${ROW_HEIGHT_PIXELS}px`);
  }

  get data(): DataGridData {
    return {
      columns: this.columns as Column[],
      rows: this.rows as Row[],
      activeSort: this.sortState,
      contextMenus: this.contextMenus,
    };
  }

  set data(data: DataGridData) {
    this.columns = data.columns;
    this.rows = data.rows;
    this.rows.forEach((row, index) => {
      this.rowIndexMap.set(row, index);
    });
    this.sortState = data.activeSort;
    this.contextMenus = data.contextMenus;

    /**
     * On first render, now we have data, we can figure out which cell is the
     * focusable cell for the table.
     *
     * If any columns are sortable, we pick [0, 0], which is the first cell of
     * the columns row. However, if any columns are hidden, we adjust
     * accordingly. e.g., if the first column is hidden, we'll set the starting
     * index as [1, 0].
     *
     * If the columns aren't sortable, we pick the first visible body row as the
     * index.
     *
     * We only do this on the first render; otherwise if we re-render and the
     * user has focused a cell, this logic will reset it.
     */
    if (!this.hasRenderedAtLeastOnce) {
      this.focusableCell = calculateFirstFocusableCell({columns: this.columns, rows: this.rows});
    }

    if (this.hasRenderedAtLeastOnce) {
      const [selectedColIndex, selectedRowIndex] = this.focusableCell;
      const columnOutOfBounds = selectedColIndex > this.columns.length;
      const rowOutOfBounds = selectedRowIndex > this.rows.length;

      /** If the row or column was removed, so the user is out of bounds, we
       * move them to the last focusable cell, which should be close to where
       * they were. */
      if (columnOutOfBounds || rowOutOfBounds) {
        this.focusableCell = [
          columnOutOfBounds ? this.columns.length : selectedColIndex,
          rowOutOfBounds ? this.rows.length : selectedRowIndex,
        ];
      }
    }

    this.render();
  }

  private scrollToBottomIfRequired(): void {
    if (this.hasRenderedAtLeastOnce === false || this.userHasFocusInDataGrid || this.userHasScrolled) {
      // On the first render we don't want to assume the user wants to scroll to the bottom.
      // And if they have focused a cell we don't want to scroll them away from it.
      // If they have scrolled the table manually we also won't scroll and disrupt their scroll position.
      return;
    }

    const focusableCell = this.getCurrentlyFocusableCell();
    if (focusableCell && focusableCell === this.shadow.activeElement) {
      // The user has a cell (and indirectly, a row) selected so we don't want
      // to mess with their scroll
      return;
    }

    coordinator.read(() => {
      const wrapper = this.shadow.querySelector('.wrapping-container');
      if (!wrapper) {
        return;
      }
      const scrollHeight = wrapper.scrollHeight;
      coordinator.scroll(() => {
        wrapper.scrollTo(0, scrollHeight);
      });
    });
  }

  private engageResizeObserver(): void {
    if (!this.hasRenderedAtLeastOnce) {
      this.resizeObserver.observe(this.shadow.host);
    }
  }

  private getCurrentlyFocusableCell(): HTMLTableCellElement|null {
    const [columnIndex, rowIndex] = this.focusableCell;
    const cell = this.shadow.querySelector<HTMLTableCellElement>(
        `[data-row-index="${rowIndex}"][data-col-index="${columnIndex}"]`);
    return cell;
  }

  private focusCell([newColumnIndex, newRowIndex]: CellPosition): void {
    this.userHasFocusInDataGrid = true;

    const [currentColumnIndex, currentRowIndex] = this.focusableCell;
    const newCellIsCurrentlyFocusedCell = (currentColumnIndex === newColumnIndex && currentRowIndex === newRowIndex);

    if (!newCellIsCurrentlyFocusedCell) {
      this.focusableCell = [newColumnIndex, newRowIndex];
      this.render();
    }

    const cellElement = this.getCurrentlyFocusableCell();
    if (!cellElement) {
      // Return in case the cell is out of bounds and we do nothing
      return;
    }
    /* The cell may already be focused if the user clicked into it, but we also
     * add arrow key support, so in the case where we're programatically moving the
     * focus, ensure we actually focus the cell.
     */
    coordinator.write(() => {
      cellElement.focus();
    });
  }

  private onTableKeyDown(event: KeyboardEvent): void {
    const key = event.key;

    if (KEYS_TREATED_AS_CLICKS.has(key)) {
      const focusedCell = this.getCurrentlyFocusableCell();
      const [focusedColumnIndex, focusedRowIndex] = this.focusableCell;
      const activeColumn = this.columns[focusedColumnIndex];
      if (focusedCell && focusedRowIndex === 0 && activeColumn && activeColumn.sortable) {
        this.onColumnHeaderClick(activeColumn, focusedColumnIndex);
      }
    }

    if (!Platform.KeyboardUtilities.keyIsArrowKey(key)) {
      return;
    }

    const nextFocusedCell = handleArrowKeyNavigation({
      key: key,
      currentFocusedCell: this.focusableCell,
      columns: this.columns,
      rows: this.rows,
    });
    event.preventDefault();
    this.focusCell(nextFocusedCell);
  }

  private onColumnHeaderClick(col: Column, index: number): void {
    this.dispatchEvent(new ColumnHeaderClickEvent(col, index));
  }

  /**
   * Applies the aria-sort label to a column's th.
   * Guidance on values of attribute taken from
   * https://www.w3.org/TR/wai-aria-practices/examples/grid/dataGrids.html.
   */
  private ariaSortForHeader(col: Column): string|undefined {
    if (col.sortable && (!this.sortState || this.sortState.columnId !== col.id)) {
      // Column is sortable but is not currently sorted
      return 'none';
    }

    if (this.sortState && this.sortState.columnId === col.id) {
      return this.sortState.direction === SortDirection.ASC ? 'ascending' : 'descending';
    }

    // Column is not sortable, so don't apply any label
    return undefined;
  }

  private renderEmptyFillerRow(): LitHtml.TemplateResult {
    const emptyCells = this.columns.map((col, colIndex) => {
      if (!col.visible) {
        return LitHtml.nothing;
      }
      const emptyCellClasses = LitHtml.Directives.classMap({
        firstVisibleColumn: colIndex === 0,
      });
      return LitHtml.html`<td tabindex="-1" class=${emptyCellClasses} data-filler-row-column-index=${colIndex}></td>`;
    });
    return LitHtml.html`<tr tabindex="-1" class="filler-row padding-row">${emptyCells}</tr>`;
  }

  private cleanUpAfterResizeColumnComplete(): void {
    if (!this.currentResize) {
      return;
    }
    this.currentResize.documentForCursorChange.body.style.cursor = this.currentResize.cursorToRestore;
    this.currentResize = null;
    // Realign the scroll handlers now the table columns have been resized.
    this.alignScrollHandlers();
  }

  private onResizePointerDown(event: PointerEvent): void {
    if (event.buttons !== 1 || (Host.Platform.isMac() && event.ctrlKey)) {
      // Ensure we only react to a left click drag mouse down event.
      // On Mac we ignore Ctrl-click which can be used to bring up context menus, etc.
      return;
    }
    event.preventDefault();
    const resizerElement = event.target as HTMLElement;
    if (!resizerElement) {
      return;
    }
    const leftColumnIndex = resizerElement.dataset.columnIndex;
    if (!leftColumnIndex) {
      return;
    }
    const leftColumnIndexAsNumber = globalThis.parseInt(leftColumnIndex, 10);
    /* To find the cell to the right we can't just go +1 as it might be hidden,
     * so find the next index that is visible.
     */
    const rightColumnIndexAsNumber = this.columns.findIndex((column, index) => {
      return index > leftColumnIndexAsNumber && column.visible === true;
    });

    const leftCell = this.shadow.querySelector(`td[data-filler-row-column-index="${leftColumnIndexAsNumber}"]`);
    const rightCell = this.shadow.querySelector(`td[data-filler-row-column-index="${rightColumnIndexAsNumber}"]`);
    if (!leftCell || !rightCell) {
      return;
    }
    // We query for the <col> elements as they are the elements that we put the actual width on.
    const leftCellCol =
        this.shadow.querySelector<HTMLTableColElement>(`col[data-col-column-index="${leftColumnIndexAsNumber}"]`);
    const rightCellCol =
        this.shadow.querySelector<HTMLTableColElement>(`col[data-col-column-index="${rightColumnIndexAsNumber}"]`);
    if (!leftCellCol || !rightCellCol) {
      return;
    }

    const targetDocumentForCursorChange = (event.target as Node).ownerDocument;
    if (!targetDocumentForCursorChange) {
      return;
    }
    // We now store values that we'll make use of in the mousemouse event to calculate how much to resize the table by.
    this.currentResize = {
      leftCellCol,
      rightCellCol,
      leftCellColInitialPercentageWidth: globalThis.parseInt(leftCellCol.style.width, 10),
      rightCellColInitialPercentageWidth: globalThis.parseInt(rightCellCol.style.width, 10),
      initialLeftCellWidth: leftCell.clientWidth,
      initialRightCellWidth: rightCell.clientWidth,
      initialMouseX: event.x,
      documentForCursorChange: targetDocumentForCursorChange,
      cursorToRestore: resizerElement.style.cursor,
    };

    targetDocumentForCursorChange.body.style.cursor = 'col-resize';
    resizerElement.setPointerCapture(event.pointerId);
    resizerElement.addEventListener('pointermove', this.boundOnResizePointerMove);
  }

  private onResizePointerMove(event: PointerEvent): void {
    event.preventDefault();
    if (!this.currentResize) {
      return;
    }

    const MIN_CELL_WIDTH_PERCENTAGE = 10;
    const MAX_CELL_WIDTH_PERCENTAGE =
        (this.currentResize.leftCellColInitialPercentageWidth + this.currentResize.rightCellColInitialPercentageWidth) -
        MIN_CELL_WIDTH_PERCENTAGE;
    const deltaOfMouseMove = event.x - this.currentResize.initialMouseX;
    const absoluteDelta = Math.abs(deltaOfMouseMove);
    const percentageDelta =
        (absoluteDelta / (this.currentResize.initialLeftCellWidth + this.currentResize.initialRightCellWidth)) * 100;

    let newLeftColumnPercentage;
    let newRightColumnPercentage;
    if (deltaOfMouseMove > 0) {
      /**
       * A positive delta means the user moved their mouse to the right, so we
       * want to make the right column smaller, and the left column larger.
       */
      newLeftColumnPercentage = Platform.NumberUtilities.clamp(
          this.currentResize.leftCellColInitialPercentageWidth + percentageDelta, MIN_CELL_WIDTH_PERCENTAGE,
          MAX_CELL_WIDTH_PERCENTAGE);
      newRightColumnPercentage = Platform.NumberUtilities.clamp(
          this.currentResize.rightCellColInitialPercentageWidth - percentageDelta, MIN_CELL_WIDTH_PERCENTAGE,
          MAX_CELL_WIDTH_PERCENTAGE);
    } else if (deltaOfMouseMove < 0) {
      /**
       * Negative delta means the user moved their mouse to the left, which
       * means we want to make the right column larger, and the left column
       * smaller.
       */
      newLeftColumnPercentage = Platform.NumberUtilities.clamp(
          this.currentResize.leftCellColInitialPercentageWidth - percentageDelta, MIN_CELL_WIDTH_PERCENTAGE,
          MAX_CELL_WIDTH_PERCENTAGE);
      newRightColumnPercentage = Platform.NumberUtilities.clamp(
          this.currentResize.rightCellColInitialPercentageWidth + percentageDelta, MIN_CELL_WIDTH_PERCENTAGE,
          MAX_CELL_WIDTH_PERCENTAGE);
    }

    if (!newLeftColumnPercentage || !newRightColumnPercentage) {
      // The delta was 0, so nothing to do.
      return;
    }

    // We limit the values to two decimal places to not work with huge decimals.
    // It also prevents stuttering if the user barely moves the mouse, as the
    // browser won't try to move the column by 0.0000001% or similar.
    this.currentResize.leftCellCol.style.width = newLeftColumnPercentage.toFixed(2) + '%';
    this.currentResize.rightCellCol.style.width = newRightColumnPercentage.toFixed(2) + '%';
  }

  private onResizePointerUp(event: PointerEvent): void {
    event.preventDefault();
    const resizer = event.target as HTMLElement;
    if (!resizer) {
      return;
    }
    resizer.releasePointerCapture(event.pointerId);
    resizer.removeEventListener('pointermove', this.boundOnResizePointerMove);
    this.cleanUpAfterResizeColumnComplete();
  }

  private renderResizeForCell(column: Column, position: CellPosition): LitHtml.TemplateResult {
    /**
     * A resizer for a column is placed at the far right of the _previous column
     * cell_. So when we get called with [1, 0] that means this dragger is
     * resizing column 1, but the dragger itself is located within column 0. We
     * need the column to the left because when you resize a column you're not
     * only resizing it but also the column to its left.
     */
    const [columnIndex] = position;
    const lastVisibleColumnIndex = this.getIndexOfLastVisibleColumn();
    // If we are in the very last column, there is no column to the right to resize, so don't render a resizer.
    if (columnIndex === lastVisibleColumnIndex || !column.visible) {
      return LitHtml.nothing as LitHtml.TemplateResult;
    }

    return LitHtml.html`<span class="cell-resize-handle"
     @pointerdown=${this.boundOnResizePointerDown}
     @pointerup=${this.boundOnResizePointerUp}
     data-column-index=${columnIndex}
    ></span>`;
  }

  private getIndexOfLastVisibleColumn(): number {
    let index = this.columns.length - 1;
    for (; index > -1; index--) {
      const col = this.columns[index];
      if (col.visible) {
        break;
      }
    }
    return index;
  }

  /**
   * This function is called when the user right clicks on the header row of the
   * data grid.
   */
  private onHeaderContextMenu(event: MouseEvent): void {
    if (event.button !== 2) {
      // 2 = secondary button = right click. We only show context menus if the
      // user has right clicked.
      return;
    }

    const menu = new UI.ContextMenu.ContextMenu(event);
    addColumnVisibilityCheckboxes(this, menu);
    const sortMenu = menu.defaultSection().appendSubMenuItem(ls`Sort By`);
    addSortableColumnItems(this, sortMenu);

    menu.defaultSection().appendItem(ls`Reset Columns`, () => {
      this.dispatchEvent(new ContextMenuHeaderResetClickEvent());
    });

    if (this.contextMenus && this.contextMenus.headerRow) {
      // Let the user append things to the menu
      this.contextMenus.headerRow(menu, this.columns);
    }
    menu.show();
  }

  private onBodyRowContextMenu(event: MouseEvent): void {
    if (event.button !== 2) {
      // 2 = secondary button = right click. We only show context menus if the
      // user has right clicked.
      return;
    }
    /**
     * We now make sure that the event came from an HTML element with a
     * data-row-index attribute, else we bail.
     */
    if (!event.target || !(event.target instanceof HTMLElement)) {
      return;
    }
    const rowIndexAttribute = event.target.dataset.rowIndex;
    if (!rowIndexAttribute) {
      return;
    }

    const rowIndex = parseInt(rowIndexAttribute, 10);
    // rowIndex - 1 here because in the UI the 0th row is the column headers.
    const rowThatWasClicked = this.rows[rowIndex - 1];

    const menu = new UI.ContextMenu.ContextMenu(event);
    const sortMenu = menu.defaultSection().appendSubMenuItem(ls`Sort By`);
    addSortableColumnItems(this, sortMenu);

    const headerOptionsMenu = menu.defaultSection().appendSubMenuItem(ls`Header Options`);
    addColumnVisibilityCheckboxes(this, headerOptionsMenu);
    headerOptionsMenu.defaultSection().appendItem(ls`Reset Columns`, () => {
      this.dispatchEvent(new ContextMenuHeaderResetClickEvent());
    });

    if (this.contextMenus && this.contextMenus.bodyRow) {
      this.contextMenus.bodyRow(menu, this.columns, rowThatWasClicked);
    }
    menu.show();
  }

  private onScroll(): void {
    this.render();
  }

  private onWheel(): void {
    this.userHasScrolled = true;
  }

  private alignScrollHandlers(): Promise<void> {
    return coordinator.read(() => {
      const columnHeaders = this.shadow.querySelectorAll('th:not(.hidden)');
      const handlers = this.shadow.querySelectorAll<HTMLElement>('.cell-resize-handle');
      const table = this.shadow.querySelector<HTMLTableElement>('table');
      if (!table) {
        return;
      }

      columnHeaders.forEach(async (header, index) => {
        const {right} = header.getBoundingClientRect();
        if (handlers[index]) {
          /**
           * 40px here because the handler is 20px wide, and we use the right
           * boundary of the cell to position it. So we move it back 20px
           * because it's 20px wide, but then need to pull it back another 20px
           * so it sits over The very right hand edge of the column.
           */
          coordinator.write(() => {
            handlers[index].style.left = `${right - 40}px`;
          });
        }
      });
    });
  }

  /**
   * Calculates the index of the first row we want to render, and the last row we want to render.
   * Pads in each direction by PADDING_ROWS_COUNT so we render some rows that are off scren.
   */
  private calculateTopAndBottomRowIndexes(): Promise<{topVisibleRow: number, bottomVisibleRow: number}> {
    return coordinator.read(() => {
      const wrapper = this.shadow.querySelector('.wrapping-container');

      // On first render we don't have a wrapper, so we can't get at its
      // scroll/height values. So we default to the inner height of the window as
      // the limit for rendering. This means we may over-render by a few rows, but
      // better that than either render everything, or rendering too few rows.
      let scrollTop = 0;
      let clientHeight = window.innerHeight;
      if (wrapper) {
        scrollTop = wrapper.scrollTop;
        clientHeight = wrapper.clientHeight;
      }
      const padding = ROW_HEIGHT_PIXELS * PADDING_ROWS_COUNT;
      let topVisibleRow = Math.floor((scrollTop - padding) / ROW_HEIGHT_PIXELS);
      let bottomVisibleRow = Math.ceil((scrollTop + clientHeight + padding) / ROW_HEIGHT_PIXELS);

      topVisibleRow = Math.max(0, topVisibleRow);
      bottomVisibleRow = Math.min(this.rows.filter(r => !r.hidden).length, bottomVisibleRow);

      return {
        topVisibleRow,
        bottomVisibleRow,
      };
    });
  }

  private onFocusOut(): void {
    /**
     * When any element in the data-grid loses focus, we set this to false. If
     * the user then focuses another cell, that code will set the focus to true.
     * We need to know if the user is focused because if they are and they've
     * scrolled their focused cell out of rendering view and back in, we want to
     * refocus it. But if they aren't focused and that happens, we don't, else
     * we can steal focus away from the user if they are typing into an input
     * box to filter the data-grid, for example.
     */
    this.userHasFocusInDataGrid = false;
  }

  /**
   * Renders the data-grid table. Note that we do not render all rows; the
   * performance cost are too high once you have a large enough table. Instead
   * we calculate the size of the container we are rendering into, and then
   * render only the rows required to fill that table (plus a bit extra for
   * padding).
   */
  private render(): void {
    if (this.scheduledRender) {
      // If we receive a request to render during a previous render call, we block
      // the newly requested render (since we could receive a lot of them in quick
      // succession), but we do ensure that at the end of the current render we
      // go again with the latest data.
      this.enqueuedRender = true;
      return;
    }
    this.scheduledRender = true;

    coordinator.read(async () => {
      const {topVisibleRow, bottomVisibleRow} = await this.calculateTopAndBottomRowIndexes();
      const renderableRows =
          this.rows.filter(row => !row.hidden).filter((_, idx) => idx >= topVisibleRow && idx <= bottomVisibleRow);
      const indexOfFirstVisibleColumn = this.columns.findIndex(col => col.visible);
      const anyColumnsSortable = this.columns.some(col => col.sortable === true);

      await coordinator.write(() => {
        // Disabled until https://crbug.com/1079231 is fixed.
        // clang-format off
        LitHtml.render(LitHtml.html`
        <style>
          :host {
            height: 100%;
            display: block;
            position: relative;
          }
          /* Ensure that vertically we don't overflow */
          .wrapping-container {
            overflow-y: scroll;
            /* Use max-height instead of height to ensure that the
              table does not use more space than necessary. */
            height: 100%;
          }

          table {
            border-spacing: 0;
            width: 100%;
            height: 100%;
            /* To make sure that we properly hide overflowing text
              when horizontal space is too narrow. */
            table-layout: fixed;
          }

          tr {
            outline: none;
          }

          tbody tr {
            background-color: var(--color-background);
          }

          tbody tr.selected {
            background-color: var(--color-background-elevation-1);
          }

          td,
          th {
            padding: 1px 4px;
            /* Divider between each cell, except the first one (see below) */
            border-left: 1px solid var(--color-details-hairline);
            color: var(--color-text-primary);
            line-height: var(--table-row-height);
            height: var(--table-row-height);
            user-select: text;
            /* Ensure that text properly cuts off if horizontal space is too narrow */
            white-space: nowrap;
            text-overflow: ellipsis;
            overflow: hidden;
          }

          th {
            font-weight: normal;
            text-align: left;
            border-bottom: 1px solid var(--color-details-hairline);
            position: sticky;
            top: 0;
            z-index: 2;
            background-color: var(--color-background-elevation-1);
          }

          td:focus,
          th:focus {
            outline: var(--color-primary) auto 1px;
          }

          .cell-resize-handle {
            top: 0;
            height: 100%;
            z-index: 3;
            width: 20px;
            cursor: col-resize;
            position: absolute;
          }
          /* There is no divider before the first cell */
          td.firstVisibleColumn,
          th.firstVisibleColumn {
            border-left: none;
          }

          .hidden {
            display: none;
          }

          .filler-row td {
            /* By making the filler row cells 100% they take up any extra height,
            * leaving the cells with content to be the regular height, and the
            * final filler row to be as high as it needs to be to fill the empty
            * space.
            */
            height: 100%;
            pointer-events: none;
          }

          [aria-sort]:hover {
            cursor: pointer;
          }

          [aria-sort="descending"]::after {
            content: " ";
            border-left: 0.3em solid transparent;
            border-right: 0.3em solid transparent;
            border-top: 0.3em solid var(--color-text-primary);
            position: absolute;
            right: 0.5em;
            top: 0.6em;
          }

          [aria-sort="ascending"]::after {
            content: " ";
            border-bottom: 0.3em solid var(--color-text-primary);
            border-left: 0.3em solid transparent;
            border-right: 0.3em solid transparent;
            position: absolute;
            right: 0.5em;
            top: 0.6em;
          }
        </style>
        ${this.columns.map((col, columnIndex) => {
          /**
          * We render the resizers outside of the table. One is rendered for each
          * column, and they are positioned absolutely at the right position. They
          * have 100% height so they sit over the entire table and can be grabbed
          * by the user.
          */
          return this.renderResizeForCell(col, [columnIndex, 0]);
        })}
        <div class="wrapping-container" @scroll=${this.onScroll} @wheel=${this.onWheel} @focusout=${this.onFocusOut}>
          <table
            aria-rowcount=${this.rows.length}
            aria-colcount=${this.columns.length}
            @keydown=${this.onTableKeyDown}
          >
            <colgroup>
              ${this.columns.map((col, colIndex) => {
                const width = calculateColumnWidthPercentageFromWeighting(this.columns, col.id);
                const style = `width: ${width}%`;
                if (!col.visible) {
                  return LitHtml.nothing;
                }

                return LitHtml.html`<col style=${style} data-col-column-index=${colIndex}>`;
              })}
            </colgroup>
            <thead>
              <tr @contextmenu=${this.onHeaderContextMenu}>
                ${this.columns.map((col, columnIndex) => {
                  const thClasses = LitHtml.Directives.classMap({
                    hidden: !col.visible,
                    firstVisibleColumn: columnIndex === indexOfFirstVisibleColumn,
                  });
                  const cellIsFocusableCell = anyColumnsSortable && columnIndex === this.focusableCell[0] && this.focusableCell[1] === 0;

                  return LitHtml.html`<th class=${thClasses}
                    data-grid-header-cell=${col.id}
                    @click=${(): void => {
                      this.focusCell([columnIndex, 0]);
                      this.onColumnHeaderClick(col, columnIndex);
                    }}
                    title=${col.title}
                    aria-sort=${LitHtml.Directives.ifDefined(this.ariaSortForHeader(col))}
                    aria-colindex=${columnIndex + 1}
                    data-row-index='0'
                    data-col-index=${columnIndex}
                    tabindex=${LitHtml.Directives.ifDefined(anyColumnsSortable ? (cellIsFocusableCell ? '0' : '-1') : undefined)}
                  >${col.title}</th>`;
                })}
              </tr>
            </thead>
            <tbody>
              <tr class="filler-row-top padding-row" style=${LitHtml.Directives.styleMap({
                height: `${topVisibleRow * ROW_HEIGHT_PIXELS}px`,
              })}></tr>
              ${LitHtml.Directives.repeat(renderableRows, row => this.rowIndexMap.get(row), (row): LitHtml.TemplateResult => {
                const rowIndex = this.rowIndexMap.get(row);
                if (rowIndex === undefined) {
                  throw new Error('Trying to render a row that has no index in the rowIndexMap');
                }
                const focusableCell = this.getCurrentlyFocusableCell();
                const [,focusableCellRowIndex] = this.focusableCell;
                // Remember that row 0 is considered the header row, so the first tbody row is row 1.
                const tableRowIndex = rowIndex + 1;

                // Have to check for focusableCell existing as this runs on the
                // first render before it's ever been created.
                const rowIsSelected = focusableCell ? focusableCell === this.shadow.activeElement && tableRowIndex === focusableCellRowIndex : false;

                const rowClasses = LitHtml.Directives.classMap({
                  selected: rowIsSelected,
                  hidden: row.hidden === true,
                });
                return LitHtml.html`
                  <tr
                    aria-rowindex=${rowIndex + 1}
                    class=${rowClasses}
                    @contextmenu=${this.onBodyRowContextMenu}
                  >${this.columns.map((col, columnIndex) => {
                    const cell = getRowEntryForColumnId(row, col.id);
                    const cellClasses = LitHtml.Directives.classMap({
                      hidden: !col.visible,
                      firstVisibleColumn: columnIndex === indexOfFirstVisibleColumn,
                    });
                    const cellIsFocusableCell = columnIndex === this.focusableCell[0] && tableRowIndex === this.focusableCell[1];
                    const cellOutput = col.visible ? renderCellValue(cell) : null;
                    return LitHtml.html`<td
                      class=${cellClasses}
                      tabindex=${cellIsFocusableCell ? '0' : '-1'}
                      aria-colindex=${columnIndex + 1}
                      title=${cell.title || String(cell.value).substr(0, 20)}
                      data-row-index=${tableRowIndex}
                      data-col-index=${columnIndex}
                      data-grid-value-cell-for-column=${col.id}
                      @focus=${(): void => {
                        this.dispatchEvent(new BodyCellFocusedEvent(cell, row));
                      }}
                      @click=${(): void => {
                        this.focusCell([columnIndex, tableRowIndex]);
                      }}
                    >${cellOutput}</td>`;
                  })}
                `;
              })}
              ${this.renderEmptyFillerRow()}
              <tr class="filler-row-bottom padding-row" style=${LitHtml.Directives.styleMap({
                height: `${Math.max(0, renderableRows.length - bottomVisibleRow) * ROW_HEIGHT_PIXELS}px`,
              })}></tr>
            </tbody>
          </table>
        </div>
        `, this.shadow, {
          eventContext: this,
        });
      });
      // clang-format on


      // This ensures if the user has a cell focused, but then scrolls so that
      // the focused cell is now not rendered, that when it then gets scrolled
      // back in, that it becomes rendered.
      // However, if the cell is a column header, we don't do this, as that
      // can never be not-rendered.
      const currentlyFocusedRowIndex = this.focusableCell[1];
      if (this.userHasFocusInDataGrid && currentlyFocusedRowIndex > 0) {
        this.focusCell(this.focusableCell);
      }
      this.scrollToBottomIfRequired();
      this.engageResizeObserver();
      this.scheduledRender = false;
      this.hasRenderedAtLeastOnce = true;

      // If we've received more data mid-render we will do one extra render at
      // the end with the most recent data.
      if (this.enqueuedRender) {
        this.enqueuedRender = false;
        this.render();
      }
    });
  }
}

customElements.define('devtools-data-grid', DataGrid);

declare global {
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  interface HTMLElementTagNameMap {
    'devtools-data-grid': DataGrid;
  }
}
