/* eslint-disable max-len */
import { html, LitElement, nothing, PropertyValues, TemplateResult } from 'lit'
import { property, state } from 'lit/decorators.js'
import { classMap } from 'lit/directives/class-map.js'
import { ref, createRef } from 'lit/directives/ref.js'
import { ifDefined } from 'lit/directives/if-defined.js'
import { dataAttr } from '../../directives/data-attr.js'
import { LiveData } from '../../reactive/LiveData.js'

import '../../md/focus-ring/ui-focus-ring.js'
import '../../md/icons/ui-icon.js'

/**
 * Options for rendering a table cell.
 */
export interface CellRenderOptions {
  /**
   * CSS classes to apply to the cell.
   */
  classes?: string[]
  /**
   * Data attributes to add to the cell (e.g., `data-id="my-id"`).
   */
  data?: Record<string, string | number | boolean>
  /**
   * The value of the aria-label to render on the cell.
   */
  ariaLabel?: string
  /**
   * Used with interactive elements within the cell.
   * The content of the cell should have exactly one element
   * with the `tabindex=-1`. The table will automatically
   * focus on that element when activated.
   */
  delegateFocus?: boolean
  /**
   * Used for accessibility. Should be set on the cell that represents
   * the name for the row, like, a person name or a file name.
   */
  isPrimary?: boolean
  /**
   * The part name to use for the cell.
   */
  part?: string
}

/**
 * Options for rendering a table header cell. Extends CellRenderOptions.
 */
export interface HeaderCellRenderOptions extends CellRenderOptions {
  /**
   * The name of the sort property.
   */
  sort?: string
}

export interface CellPosition {
  /**
   * The index of the selected row
   */
  row: number
  /**
   * The index of the selected cell
   */
  col: number

  /**
   * Whether the logic should ignore setting up focus.
   * This is used when tracking cell selection when using a pointer device.
   */
  ignoreFocus?: boolean
}

export interface EventCellInfo {
  /**
   * The cell element.
   */
  cell: HTMLElement
  /**
   * The column index
   */
  col: number
  /**
   * The **table** row index (not items)
   */
  row: number
}

/**
 * Details for the `sort` event dispatched by the DataTable component.
 */
export interface DataTableSortEventDetail {
  /**
   * The name of the column being sorted.
   */
  column: string
  /**
   * The sort direction, either "asc" (ascending) or "desc" (descending).
   */
  direction: 'asc' | 'desc'
}

/**
 * A versatile and accessible data table web component.
 *
 * The `DataTable` component provides a highly customizable and accessible way to display tabular data.
 * It offers features such as:
 *
 * - **Accessibility:** Built with ARIA roles and attributes for screen reader compatibility and keyboard navigation.
 * - **Customizable Cells:** Allows complete control over the content and rendering of each cell in the table.
 * - **Header Customization:** Provides flexibility in defining and rendering table headers, including sorting.
 * - **Selection:** Supports single, multiple, or no selection of rows.
 * - **Activation:** Enables the activation of rows (e.g., via double-click or Enter/Space key).
 * - **Infinite Scrolling:** Supports "load more" functionality (infinite scroll) using a sentinel element.
 * - **Sorting:** Allows sorting of data by clicking on sortable header cells.
 * - **Keyboard Navigation:** Full keyboard support for navigating through the table.
 * - **Empty State:** Customizable content to display when the table has no data.
 * - **Focus Management:** Properly manages focus for both keyboard and pointer interactions.
 *
 * @slot empty - Optional. Content to display when the table is empty (i.e., `items` is an empty array).
 *                Defaults to a message saying "No data to display."
 *
 * @fires select - Dispatched when the selection changes.
 *                  - `detail`: An object with a `selectedItems` property, containing an array of the currently selected items.
 *                  - Example: `{ selectedItems: [item1, item2] }`
 *
 * @fires activate - Dispatched when a row is activated (e.g., double-clicked or Enter/Space pressed).
 *                  - `detail`: The data item that was activated.
 *                  - Example: `item1`
 *
 * @fires scrollend - Dispatched when the user scrolls to the bottom of the table (when the `overflow` property is true).
 *                  - No event detail.
 *
 * @fires sort - Dispatched when the user clicks on a sortable header to change the sorting.
 *                  - `detail`: An object with `column` and `direction` properties.
 *                  - `column`: The name of the column being sorted.
 *                  - `direction`: The sort direction, either "asc" (ascending) or "desc" (descending).
 *                  - Example: `{ column: "name", direction: "asc" }`
 *
 * @csspart table - The underlying `<table>` element.
 * @csspart cell - Individual `<td>` and `<th>` elements.
 * @csspart header-cell - Individual `<th>` elements.
 *
 * @cssprop --md-focus-ring-shape-start-start - The start-start corner shape for the focus ring.
 * @cssprop --md-focus-ring-shape-start-end - The start-end corner shape for the focus ring.
 * @cssprop --md-focus-ring-shape-end-end - The end-end corner shape for the focus ring.
 * @cssprop --md-focus-ring-shape-end-start - The end-start corner shape for the focus ring.
 * @cssprop --md-sys-typescale-label-large-font - The font family for label large.
 * @cssprop --md-sys-typescale-label-large-size - The font size for label large.
 * @cssprop --md-sys-typescale-label-large-tracking - The letter spacing for label large.
 * @cssprop --md-sys-typescale-label-large-height - The line height for label large.
 * @cssprop --md-sys-typescale-label-large-weight - The font weight for label large.
 * @cssprop --md-sys-color-outline-variant - The outline variant color.
 * @cssprop --md-sys-color-secondary-container - The secondary container color.
 * @cssprop --md-icon-size - The size of the icons.
 * @cssprop --md-sys-typescale-title-small-font - The font family for title small.
 * @cssprop --md-sys-typescale-title-small-weight - The font weight for title small.
 * @cssprop --md-sys-typescale-title-small-size - The font size for title small.
 * @cssprop --md-sys-typescale-title-small-tracking - The letter spacing for title small.
 * @cssprop --md-sys-typescale-title-small-height - The line height for title small.
 * @cssprop --md-sys-typescale-body-medium-font - The font family for body medium.
 * @cssprop --md-sys-typescale-body-medium-weight - The font weight for body medium.
 * @cssprop --md-sys-typescale-body-medium-size - The font size for body medium.
 * @cssprop --md-sys-typescale-body-medium-tracking - The letter spacing for body medium.
 * @cssprop --md-sys-typescale-body-medium-height - The line height for body medium.
 *
 * @typeParam T The type of data items in the table.
 *
 * @example
 * ```html
 * <data-table
 *   .items="${myItems}"
 *   selection="multi"
 *   @select="${handleSelect}"
 *   @activate="${handleActivate}"
 *   @scrollend="${handleScrollEnd}"
 *   @sort="${handleSort}"
 *   overflow
 * >
 *   <p slot="empty">No items to display at this time.</p>
 * </data-table>
 * ```
 *
 * @example
 * ```typescript
 * // Inside a subclass of DataTable.
 * // Rendering a name cell.
 * #nameCell(item: MyItem, row: number, col: number): TemplateResult {
 *   return this.renderCell(item.name, row, col, { isPrimary: true });
 * }
 *
 * // Rendering a header cell.
 * override renderHeader(): TemplateResult[] {
 *   return [
 *     this.renderHeaderCell('Name', 0, { sort: 'name' }),
 *     this.renderHeaderCell('Value', 1, { sort: 'value' }),
 *   ];
 * }
 * ```
 */
export abstract class DataTable<T extends object> extends LitElement {
  /**
   * The data items to display in the table.
   */
  @property({ type: Object }) accessor items: LiveData<T[]> | undefined

  /**
   * The selection mode. Can be "none", "single", or "multi".
   * - `none`: No selection is allowed.
   * - `single`: Only one item can be selected at a time.
   * - `multi`: Multiple items can be selected.
   * @attribute
   * @default "none"
   */
  @property({ type: String, reflect: true }) accessor selection: 'none' | 'single' | 'multi' = 'none'

  /**
   * The currently selected items.
   * @default []
   */
  @property({ type: Array }) accessor selectedItems: T[] = []

  /**
   * The ARIA label for the table. Used for accessibility.
   * @attribute
   * @default "Data table"
   */
  @property({ type: String, reflect: true }) override accessor ariaLabel = 'Data table'

  /**
   * Enables double-click and Enter/Space key activation of table items.
   * When `true`, items can be activated by:
   * - Double-clicking the item.
   * - Focusing on the item and pressing Enter or Space.
   * The `activate` event is dispatched when an item is activated.
   * @attribute
   * @default false
   */
  @property({ type: Boolean }) accessor active = false

  /**
   * Enables the "load more" functionality when the user scrolls to the bottom of the table.
   * When `true`, a sentinel element is added to the bottom of the table. When the sentinel
   * becomes visible, the `scrollend` event is dispatched.
   * This is commonly used for infinite scrolling or lazy loading of data.
   * @attribute
   * @default false
   */
  @property({ type: Boolean, reflect: true }) accessor overflow = false

  /**
   * The name of the property to sort the table by. If set, the table will
   * automatically indicate the sortable header.
   * @attribute
   * @default undefined
   */
  @property({ type: String }) accessor sort: string | undefined

  /**
   * The direction of the sort. Can be "asc" (ascending) or "desc" (descending).
   * If `sort` is set, this property will be used to determine the sort direction.
   * @attribute
   * @default undefined
   */
  @property({ type: String }) accessor sortDirection: 'asc' | 'desc' | undefined

  protected lastSelectedIndex = -1

  protected observer?: IntersectionObserver

  protected sentinel = createRef<HTMLElement>()

  /**
   * The current focus position.
   * It is used to support keyboard navigation.
   *
   * Initially we always allow to focus on the first cell, which
   * should be the first header cell. The keyboard navigation will
   * take over.
   */
  @state() accessor position: CellPosition = { row: 0, col: 0 }

  constructor() {
    super()
    this.handleLiveData = this.handleLiveData.bind(this)
  }

  override connectedCallback(): void {
    super.connectedCallback()
    if (this.overflow) {
      // Only create the observer if overflow is enabled
      this.createObserver()
    }
  }

  override disconnectedCallback(): void {
    super.disconnectedCallback()
    this.observer?.disconnect()
    this.observer = undefined
  }

  override firstUpdated(): void {
    if (this.overflow) {
      this.createObserver()
    }
  }

  protected override willUpdate(cp: PropertyValues<this>): void {
    if (cp.has('items')) {
      this.lastSelectedIndex = -1
      this.revalidateSelection()
    }
  }

  protected override updated(cp: PropertyValues<this>): void {
    if (cp.has('position')) {
      const { row, col, ignoreFocus = false } = this.position
      if (!ignoreFocus) {
        const cell = this.shadowRoot?.querySelector(`[data-row="${row}"][data-col="${col}"]`) as HTMLElement | null
        if (cell) {
          cell.focus()
        }
      }
    }
    if (cp.has('items')) {
      const old = cp.get('items')
      if (old) {
        old.removeEventListener('update', this.handleLiveData)
      }
      if (this.items) {
        this.items.addEventListener('update', this.handleLiveData)
      }
    }
  }

  handleLiveData(): void {
    this.requestUpdate()
  }

  /**
   * @internal
   * @description Creates and configures an `IntersectionObserver` to detect when the sentinel element is visible,
   * indicating the user has scrolled to the bottom of the table.
   *
   * This method sets up the observer to watch the sentinel element. When the sentinel intersects with the component's viewport,
   * it triggers the `scrollend` event.
   *
   * This function is used when `overflow` is set to true.
   *
   * @returns void
   */
  protected createObserver(): void {
    const { sentinel } = this
    if (!sentinel.value) {
      return
    }

    const options: IntersectionObserverInit = {
      root: this, // Observe within the component's viewport
      rootMargin: '0px', // No margin
      threshold: 0.0, // Trigger when the sentinel is 0% visible (at the very bottom)
    }

    this.observer = new IntersectionObserver(this.handleIntersection, options)
    this.observer.observe(sentinel.value) // Observe the sentinel.
  }

  protected revalidateSelection(): void {
    const { items, selectedItems = [] } = this
    if (!items || selectedItems.length === 0) {
      return
    }
    const filteredSelectedItems = selectedItems.filter((selectedItem) =>
      items.value.some((item) => item === selectedItem)
    )
    if (filteredSelectedItems.length !== selectedItems.length) {
      this.selectedItems = filteredSelectedItems
      this.notifySelect(filteredSelectedItems)
    }
  }

  /**
   * Notifies about the selection change.
   *
   * This method dispatches a `select` event with the currently selected items.
   *
   * @param selectedItems - The array of currently selected items.
   */
  protected notifySelect(selectedItems: T[]): void {
    this.dispatchEvent(new CustomEvent('select', { detail: { selectedItems } }))
  }

  /**
   * Notifies about the scroll end event.
   *
   * This method dispatches a `scrollend` event when the user scrolls to the bottom of the table.
   * It is typically used in conjunction with the `overflow` property to implement infinite scrolling.
   */
  protected notifyScrollEnd(): void {
    this.dispatchEvent(new CustomEvent('scrollend'))
  }

  /**
   * Notifies about the activation of a row.
   *
   * This method dispatches an `activate` event with the activated item.
   * It is typically used when a row is double-clicked or when the Enter/Space key is pressed while focused on a row.
   *
   * @param item - The data item that was activated.
   */
  protected notifyActivate(item: T): void {
    this.dispatchEvent(new CustomEvent('activate', { detail: item }))
  }

  /**
   * Notifies about the sorting action.
   *
   * This method dispatches a `sort` event with the column name and sort direction.
   * It is typically used when a sortable header cell is clicked.
   *
   * @param column - The name of the column being sorted.
   * @param direction - The sort direction, either "asc" (ascending) or "desc" (descending).
   */
  protected notifySort(column: string, direction: 'asc' | 'desc'): void {
    this.dispatchEvent(
      new CustomEvent<DataTableSortEventDetail>('sort', {
        detail: {
          column,
          direction,
        },
      })
    )
  }

  /**
   * @internal
   * @description Handles the intersection of the sentinel element with the viewport.
   *
   * This method is the callback function for the `IntersectionObserver`. When the sentinel becomes visible,
   * it dispatches the `scrollend` event to signal that the user has reached the bottom of the table.
   *
   * @param entries - An array of IntersectionObserverEntry objects.
   * @returns void
   */
  handleIntersection: IntersectionObserverCallback = (entries): void => {
    if (entries.some((entry) => entry.isIntersecting)) {
      this.notifyScrollEnd()
    }
  }

  /**
   * @internal
   * @description Extracts information about a cell from an event.
   *
   * This method analyzes the event's composed path to identify the clicked or focused cell (either `<td>` or `<th>`).
   * It returns the cell element, its column index, and its row index.
   * It is also capable of detecting if the user focused on an interactive element within a cell.
   *
   * @param e - The event object (typically a MouseEvent or KeyboardEvent).
   * @returns An object containing the cell element, column index, and row index, or `undefined` if the target is not a cell.
   */
  protected infoFromEvent(e: Event): EventCellInfo | undefined {
    const path = e.composedPath()
    let cell: HTMLElement | undefined
    for (const current of path.values()) {
      const element = current as Element
      if (element.nodeType !== Node.ELEMENT_NODE) {
        continue
      }
      const isTarget = ['TD', 'TH'].includes(element.nodeName)
      // @TODO: This creates a focus trap and should be investigated.
      if (!isTarget && element.hasAttribute('tabindex')) {
        // It means that the user focusing on an interactive element
        // within a cell and we should abandon whatever we do right now.
        return
      }
      if (isTarget) {
        cell = element as HTMLElement
        break
      }
    }
    if (!cell) {
      return
    }
    const col = Number(cell.dataset.col)
    const row = Number(cell.dataset.row)
    if (isNaN(col) || isNaN(row)) {
      return
    }
    return { cell, col, row }
  }

  /**
   * @internal
   * @description Gets the number of columns and rows of the table.
   *
   * @returns An object with `cols` and `rows` properties indicating the number of columns and rows.
   */
  protected getSize(): { cols: number; rows: number } {
    const result = { cols: 0, rows: 0 }
    const table = this.shadowRoot?.querySelector('table')
    if (!table) {
      return result
    }
    const row = table.querySelector('tr')
    if (!row) {
      return result
    }
    const columns = row.querySelectorAll('th, td')
    result.cols = columns.length ? columns.length - 1 : 0
    const rows = table.rows
    result.rows = rows.length ? rows.length - 1 : 0
    return result
  }

  /**
   * @internal
   * @description Get the element containing the cell data.
   *
   * @param col - The column index of the cell.
   * @param row - The row index of the cell.
   * @returns An object with the cell element and `delegateFocus` flag, or `undefined` if the cell does not exist.
   */
  protected getCellInfo(col: number, row: number): { cell: HTMLElement; delegateFocus: boolean } | undefined {
    const cell = this.shadowRoot?.querySelector(`[data-col="${col}"][data-row="${row}"]`) as HTMLElement | null
    if (!cell) {
      return
    }
    const delegateFocus = cell.dataset.delegate === 'true'
    return { cell, delegateFocus }
  }

  /**
   * @internal
   * @description Delegates the focus to a specific interactive element within a cell.
   *
   * This method is used when the cell is configured to handle its own focus management (i.e., `delegateFocus` is `true`).
   *
   * @param parent - The parent element (the cell) containing the interactive element.
   * @returns void
   */
  protected delegateFocus(parent: HTMLElement): void {
    const delegate = parent.querySelector('[tabindex]') as HTMLElement | null
    if (delegate) {
      delegate.focus()
    }
  }

  /**
   * @internal
   * @description Moves the focus to the specified cell.
   *
   * This method updates the component's internal `position` state and then focuses on the corresponding cell.
   * If the cell has the `delegateFocus` property, it delegates the focus to the first interactive element inside of the cell.
   *
   * @param col - The column index of the cell to focus on.
   * @param row - The row index of the cell to focus on.
   * @returns void
   */
  protected moveFocusTo(col: number, row: number): void {
    const cell = this.getCellInfo(col, row)
    if (!cell) {
      return
    }
    this.position = { row, col }
    if (cell.delegateFocus) {
      this.position.ignoreFocus = true
      this.delegateFocus(cell.cell)
    }
  }

  /**
   * @internal
   * @description Handles keyboard navigation within the table.
   *
   * This method processes keyboard events to move the focus between cells. It supports arrow keys, Page Up/Down, Home/End, Enter and Space.
   * - ArrowRight: Moves focus to the next cell to the right.
   * - ArrowLeft: Moves focus to the next cell to the left.
   * - ArrowDown: Moves focus to the cell below.
   * - ArrowUp: Moves focus to the cell above.
   * - PageUp: Moves focus to the first row.
   * - PageDown: Moves focus to the last row.
   * - Home: Moves focus to the first cell of the current row (Ctrl+Home moves to the first cell of the first row).
   * - End: Moves focus to the last cell of the current row (Ctrl+End moves to the last cell of the last row).
   * - Enter: Activates the current row.
   * - Space: Manages selection.
   *
   * @param e - The KeyboardEvent object.
   * @returns void
   */
  protected handleKeyDown(e: KeyboardEvent) {
    if (e.defaultPrevented) {
      return
    }
    const info = this.infoFromEvent(e)
    if (!info) {
      // This also makes sure that when we are focusing on an element
      // within a `delegateFocus` cell, we won't interfere with its events.
      return
    }
    const { code } = e
    const { position } = this
    const size = this.getSize()
    if (code === 'ArrowRight') {
      e.preventDefault()
      if (position.col < size.cols) {
        this.moveFocusTo(position.col + 1, position.row)
      }
    } else if (code === 'ArrowLeft') {
      e.preventDefault()
      if (position.col > 0) {
        this.moveFocusTo(position.col - 1, position.row)
      }
    } else if (code === 'ArrowDown') {
      e.preventDefault()
      if (position.row < size.rows) {
        this.moveFocusTo(position.col, position.row + 1)
      }
    } else if (code === 'ArrowUp') {
      e.preventDefault()
      if (position.row > 0) {
        this.moveFocusTo(position.col, position.row - 1)
      }
    } else if (code === 'PageUp') {
      e.preventDefault()
      if (position.row > 1) {
        this.moveFocusTo(position.col, 1)
      }
    } else if (code === 'PageDown') {
      e.preventDefault()
      if (position.row < size.rows) {
        this.moveFocusTo(position.col, size.rows)
      }
    } else if (code === 'Home') {
      e.preventDefault()
      if (!e.ctrlKey && position.col > 0) {
        this.moveFocusTo(0, position.row)
      } else if (e.ctrlKey && (position.row > 1 || position.col > 0)) {
        this.moveFocusTo(0, 1)
      }
    } else if (code === 'End') {
      e.preventDefault()
      if (!e.ctrlKey && position.col < size.cols) {
        this.moveFocusTo(size.cols, position.row)
      } else if (e.ctrlKey && position.row < size.rows) {
        this.moveFocusTo(size.cols, size.rows)
      }
    } else if (code === 'Enter') {
      e.preventDefault()
      this.activateFromCellInfo(info)
    } else if (code === 'Space') {
      e.preventDefault()
      this.manageSelectionFromEvent(info, e)
    }
  }

  /**
   * @internal
   * @description Handles mouse click events on the table.
   *
   * @param e - The MouseEvent object.
   * @returns void
   */
  protected handleClick(e: MouseEvent): void {
    if (e.detail === 3) {
      return
    }
    const info = this.infoFromEvent(e)
    if (!info) {
      return
    }
    if (e.detail === 2) {
      this.activateFromCellInfo(info)
    } else {
      this.manageSelectionFromEvent(info, e)
    }
  }

  /**
   * @internal
   * @description Manages the selection state based on a mouse or keyboard event.
   *
   * This method determines the row and column from the event target and updates the `selectedItems` array accordingly.
   * It supports single, multiple and no selection based on the `selection` property. It also supports shift-click.
   *
   * @param info - The cell info.
   * @param e - The MouseEvent or KeyboardEvent object.
   * @returns void
   */
  protected manageSelectionFromEvent(info: EventCellInfo, e: MouseEvent | KeyboardEvent): void {
    if (!this.items) {
      return
    }
    const { row, col } = info
    const index = row - 1
    if (info.row === 0) {
      // Handle sort.
      this.sortFrom(info.cell)
      this.position = { row, col }
      return
    }
    const item = this.items.value[index]
    if (!item) {
      return
    }

    const isMultiSelect = this.selection === 'multi'
    if (isMultiSelect && (e.metaKey || e.ctrlKey)) {
      this.toggleSelection(item, 'add')
    } else if (isMultiSelect && e.shiftKey) {
      this.handleShiftClick(index, item)
    } else {
      this.toggleSelection(item, 'replace')
      this.lastSelectedIndex = index
    }
    // This won't cause the focus ring to be rendered because the cell
    // won't have the `tabindex=0`, which is OK with pointer device navigation,
    // but it will keep track of the last cell in case the keyboard navigation starts.
    this.position = { row, col }
  }

  /**
   * @internal
   * @description Handles shift-click for range selection.
   *
   * When shift-clicking a row, it will select all the items between the last selected item and the clicked item.
   *
   * @param clickedIndex - The index of the clicked item.
   * @param item - The clicked item.
   * @returns void
   */
  protected handleShiftClick(clickedIndex: number, item: T) {
    if (!this.items) {
      return
    }
    if (this.lastSelectedIndex === -1) {
      // If no previous selection, just select the clicked item.
      this.toggleSelection(item)
      this.lastSelectedIndex = clickedIndex
      return
    }
    const startIndex = Math.min(this.lastSelectedIndex, clickedIndex)
    const endIndex = Math.max(this.lastSelectedIndex, clickedIndex)
    const newSelectedItems = this.items.value.slice(startIndex, endIndex + 1)
    this.selectedItems = newSelectedItems as T[]
    this.notifySelect(newSelectedItems)
  }

  /**
   * @internal
   * @description Activates the row associated with the given event.
   *
   * This method is called when a row is double-clicked or when the Enter/Space key is pressed while a row is focused.
   *
   * @param e - The Event object.
   * @returns void
   */
  protected activateFromCellInfo(info: EventCellInfo): void {
    if (!this.items) {
      return
    }
    if (info.row === 0) {
      // Handle sort.
      this.sortFrom(info.cell)
      return
    }
    const item = this.items.value[info.row - 1]
    if (!item) {
      return
    }
    this.notifyActivate(item)
  }

  /**
   * @internal
   * @description Handles sorting logic based on the clicked column header.
   *
   * This method updates the `sort` and `sortDirection` properties and dispatches the `sort` event.
   *
   * @param column - The header cell element.
   * @returns void
   */
  protected sortFrom(column: HTMLElement): void {
    const sort = column.dataset.sort
    if (!sort) {
      return
    }
    if (this.sort === sort) {
      this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc'
    } else {
      this.sort = sort
      this.sortDirection = 'asc'
    }
    this.notifySort(this.sort, this.sortDirection)
  }

  /**
   * @public
   * @description Checks if an item is currently selected.
   *
   * @param item - The item to check.
   * @returns `true` if the item is selected, `false` otherwise.
   */
  isSelected(item: T): boolean {
    return this.selectedItems.includes(item)
  }

  /**
   * @public
   * @description Toggles the selection state of an item.
   *
   * This method adds or removes an item from the `selectedItems` array.
   *
   * @param item - The item to toggle selection for.
   * @param mode - The selection mode, either 'replace' or 'add' (defaults to 'replace').
   * @returns void
   */
  toggleSelection(item: T, mode: 'replace' | 'add' = 'replace') {
    if (this.selection === 'none') {
      return
    }

    if (this.selection === 'single') {
      this.selectedItems = [item]
    } else {
      // multi-select
      const index = this.selectedItems.indexOf(item)
      if (mode === 'replace') {
        this.selectedItems = [item]
      } else if (index === -1) {
        this.selectedItems = [...this.selectedItems, item]
      } else {
        this.selectedItems = this.selectedItems.filter((i) => i !== item)
      }
    }

    this.notifySelect(this.selectedItems)
  }

  /**
   * @internal
   * @description Renders the table.
   *
   * @returns A `TemplateResult` for rendering.
   */
  override render(): TemplateResult {
    if (!this.items || !this.items.value.length) {
      return this.renderEmpty()
    }
    const isMulti = this.selection === 'multi'
    return html`
      <table
        part="table"
        role="grid"
        aria-multiselectable="${ifDefined(isMulti ? 'true' : undefined)}"
        aria-label="${this.ariaLabel}"
        @keydown="${this.handleKeyDown}"
        @click="${this.handleClick}"
      >
        <thead>
          <tr role="row">
            ${this.renderHeader()}
          </tr>
        </thead>
        <tbody>
          ${this.items.value.map((item, index) => this.renderRow(item, index))}
        </tbody>
      </table>
      ${this.overflow ? html`<div class="scroll-sentinel" ${ref(this.sentinel)}></div>` : nothing}
    `
  }

  /**
   * @internal
   * @description Renders the empty state of the table.
   *
   * @returns A `TemplateResult` for rendering.
   */
  renderEmpty(): TemplateResult {
    return html`
      <slot name="empty">
        <p role="status" class="empty">No data to display.</p>
      </slot>
    `
  }

  /**
   * @internal
   * @description Renders the table header cells. Must be implemented by subclasses.
   *
   * @returns An array of `TemplateResult` objects representing the header cells.
   */
  abstract renderHeader(): TemplateResult[]

  /**
   * @internal
   * @description Renders the cells for a given row item. Must be implemented by subclasses.
   *
   * @param item The data item for the row.
   * @param index The index that should be passed back to the rendering function. This is not the item index.
   * @returns An array of `TemplateResult` objects representing the cells for the row.
   */
  abstract renderRowValues(item: T, index: number): TemplateResult[]

  /**
   * @internal
   * @description Renders a single row in the table.
   *
   * @param item - The data item for the row.
   * @param index - The index of the row.
   * @returns A `TemplateResult` for rendering.
   */
  protected renderRow(item: T, index: number): TemplateResult {
    const rowClasses = {
      selected: this.isSelected(item as T),
    }

    return html`
      <tr
        role="row"
        aria-selected="${this.isSelected(item as T) ? 'true' : 'false'}"
        data-index="${index}"
        class="${classMap(rowClasses)}"
      >
        ${this.renderRowValues(item, index + 1)}
      </tr>
    `
  }

  /**
   * @internal
   * A helper method to render a standard table cell.
   *
   * @param content The content of the cell.
   * @param row The 0-based row index of the rendered cell
   * @param col The 0-based column index of the rendered cell
   * @param opts Options for rendering the cell (classes, icon, data attributes).
   * @returns A `TemplateResult` representing the rendered cell.
   */
  protected renderCell(content: unknown, row: number, col: number, opts: CellRenderOptions = {}): TemplateResult {
    const { position } = this
    const { data = {}, classes = [], ariaLabel, delegateFocus = false, isPrimary = false } = opts
    const cellClasses: Record<string, boolean> = { 'grid-cell': true }
    classes?.forEach((c) => (cellClasses[c] = true))
    const isFocused = position?.col === col && position?.row === row
    let index: number | undefined
    if (!delegateFocus) {
      index = isFocused ? 0 : -1
    }
    const ring = !delegateFocus
      ? html`<ui-focus-ring .inward="${this.overflow ? true : false}"></ui-focus-ring>`
      : nothing
    if (isPrimary) {
      // Lit does not allow dynamic element names so I decided to make a copy.
      return html`
        <th
          role="gridcell"
          class="${classMap(cellClasses)}"
          ${dataAttr(data)}
          aria-label="${ifDefined(ariaLabel)}"
          tabindex="${ifDefined(index)}"
          data-delegate="${ifDefined(delegateFocus ? true : undefined)}"
          data-row="${row}"
          data-col="${col}"
          scope="row"
          part="${opts.part ?? 'cell'}"
        >
          ${ring}
          <div class="cell-content">${content}</div>
        </th>
      `
    }
    return html`
      <td
        role="gridcell"
        class="${classMap(cellClasses)}"
        ${dataAttr(data)}
        aria-label="${ifDefined(ariaLabel)}"
        tabindex="${ifDefined(index)}"
        data-delegate="${ifDefined(delegateFocus ? true : undefined)}"
        data-row="${row}"
        data-col="${col}"
        part="${opts.part ?? 'cell'}"
      >
        ${ring}
        <div class="cell-content">${content}</div>
      </td>
    `
  }

  /**
   * @internal
   * @description Returns the name of the icon to use for sorting indication.
   *
   * @returns The name of the Material Icon to use (either "arrow_upward" or "arrow_downward").
   */
  get sortIconName(): string {
    return this.sortDirection === 'asc' ? 'arrow_upward' : 'arrow_downward'
  }

  /**
   * @internal
   * A helper method to render a table header cell.
   *
   * @param content The content of the header cell.
   * @param opts Options for rendering the header cell (classes, data attributes).
   * @returns A `TemplateResult` representing the rendered header cell.
   */
  protected renderHeaderCell(content: unknown, col: number, opts: HeaderCellRenderOptions = {}): TemplateResult {
    const { position } = this
    const { data = {}, classes = [], sort, delegateFocus = false, ariaLabel } = opts
    const cellClasses: Record<string, boolean> = { 'grid-header': true }
    classes?.forEach((c) => (cellClasses[c] = true))
    const isSortable = !!sort
    const isSorted = this.sort === sort
    const sortIcon = isSorted ? this.sortIconName : undefined
    const isFocused = position?.col === col && position?.row === 0
    let index: number | undefined
    if (!delegateFocus) {
      index = isFocused ? 0 : -1
    }
    let ariaSort: 'ascending' | 'descending' | 'none' | undefined
    if (isSorted) {
      ariaSort = this.sortDirection === 'asc' ? 'ascending' : 'descending'
    } else if (isSortable) {
      ariaSort = 'none'
    }
    return html`<th
      role="columnheader"
      scope="col"
      class="${classMap(cellClasses)}"
      ${dataAttr(data)}
      aria-sort="${ifDefined(ariaSort)}"
      data-sort="${ifDefined(sort)}"
      data-row="0"
      data-col="${col}"
      tabindex="${ifDefined(index)}"
      data-delegate="${ifDefined(delegateFocus ? true : undefined)}"
      aria-label="${ifDefined(ariaLabel)}"
      part="${opts.part ?? 'header-cell'}"
    >
      <ui-focus-ring .inward="${this.overflow ? true : false}"></ui-focus-ring>
      <div class="cell-content">
        ${content}
        ${isSortable && sortIcon ? html`<ui-icon class="sort-icon" role="presentation">${sortIcon}</ui-icon>` : nothing}
      </div>
    </th> `
  }
}
