/** @flow */
/* eslint-disable react/require-default-props */
/* eslint-disable no-underscore-dangle */
/* eslint-disable jsx-a11y/mouse-events-have-key-events  */

import * as React from 'react';

import cn from 'classnames';
import { findDOMNode } from 'react-dom';
// $FlowFixMe
import { MultiGrid } from 'react-virtualized';
import type {
  Alignment,
  CellPosition,
  ScrollParams,
  RenderedSection,
  // $FlowFixMe
} from 'react-virtualized';

import defaultRowRenderer from './SimpleTable/defaultRowRenderer';
import defaultHeaderRowRenderer from './SimpleTable/defaultHeaderRowRenderer';
import defaultCellRenderer from './defaultCellRenderer';
import SortDirection from './SortDirection';
import accessibilityOverscanIndicesGetter from './accessibilityOverscanIndicesGetter';
import { HeaderCell, CheckboxCell, HeaderLabel } from './styled';

export type CellRendererParams = {
  columns: any[],
  columnIndex: number,
  isScrolling: boolean,
  isVisible: boolean,
  key: string,
  parent: Object,
  rowIndex: number,
  style: Object,
  rowClass?: string,
  rowStyleObject?: CSSStyleDeclaration,
};

export type CellRenderer = (
  props: CellRendererParams,
) => React.Element<*>;

type Props = {
  'aria-label'?: string,

  /**
   * Removes fixed height from the scrollingContainer so that the total height
   * of rows can stretch the window. Intended for use with WindowScroller
   */
  autoHeight?: boolean,

  cellRenderer: CellRenderer,

  /** One or more Columns describing the data displayed in this row */
  children: React.Node,

  /** Optional CSS class name */
  className?: string,

  /** Disable rendering the header at all */
  disableHeader?: boolean,

  /**
   * Used to estimate the total height of a Table before all of its rows have actually been measured.
   * The estimated total height is adjusted as rows are rendered.
   */
  estimatedRowSize: number,

  /** Optional custom CSS class name to attach to inner Grid element. */
  gridClassName?: string,

  /** Optional inline style to attach to inner Grid element. */
  gridStyle?: CSSStyleDeclaration,

  hasHorizontalBorder?: boolean,
  hasVerticalBorder?: boolean,

  /** Optional CSS class to apply to all column headers */
  headerClassName?: string,

  /** Fixed height of header row */
  headerHeight: number,

  /**
   * Responsible for rendering a table row given an array of columns:
   * Should implement the following interface: ({
   *   className: string,
   *   columns: any[],
   *   style: any
   * }): PropTypes.node
   */
  headerRowRenderer?: ({
    className: string,
    column: any[],
    style: CSSStyleDeclaration,
  }) => React.Node,

  /** Optional custom inline style to attach to table header columns. */
  headerStyle?: CSSStyleDeclaration,

  /** Fixed/available height for out DOM element */
  height: number,

  /** Optional id */
  id?: string | number,

  /** Optional renderer to be used in place of table body rows when rowCount is 0 */
  noRowsRenderer?: () => mixed,

  /**
   * Optional callback when a column's header is clicked.
   * ({ columnData: any, dataKey: string }): void
   */
  onHeaderClick?: ({ columnData: any, dataKey: string }) => void,

  /**
   * Callback invoked when a user clicks on a table row.
   * ({ index: number }): void
   */
  onRowClick?: ({ index: number }) => void,

  /**
   * Callback invoked when a user double-clicks on a table row.
   * ({ index: number }): void
   */
  onRowDoubleClick?: ({ index: number }) => void,

  /**
   * Callback invoked when the mouse leaves a table row.
   * ({ index: number }): void
   */
  onRowMouseOut?: ({ index: number }) => void,

  /**
   * Callback invoked when a user moves the mouse over a table row.
   * ({ index: number }): void
   */
  onRowMouseOver?: ({ index: number }) => void,

  /**
   * Callback invoked when a user right-clicks on a table row.
   * ({ index: number }): void
   */
  onRowRightClick?: ({ index: number }) => void,

  /**
   * Callback invoked with information about the slice of rows that were just rendered.
   * ({ startIndex, stopIndex }): void
   */
  onRowsRendered?: ({
    startIndex: number,
    stopIndex: number,
  }) => void,

  /**
   * Callback invoked whenever the scroll offset changes within the inner scrollable region.
   * This callback can be used to sync scrolling between lists, tables, or grids.
   * ({ clientHeight, scrollHeight, scrollTop }): void
   */
  onScroll?: ({
    clientHeight: number,
    scrollHeight: number,
    scrollTop: number,
  }) => void,

  /** See Grid#overscanIndicesGetter */
  overscanIndicesGetter?: () => mixed,

  /**
   * Number of rows to render above/below the visible bounds of the list.
   * These rows can help for smoother scrolling on touch devices.
   */
  overscanRowCount?: number,

  /**
   * Optional CSS class to apply to all table rows (including the header row).
   * This property can be a CSS class name (string) or a function that returns a class name.
   * If a function is provided its signature should be: ({ index: number }): string
   */
  rowClassName: string | (({ index: number }) => string),

  /**
   * Callback responsible for returning a data row given an index.
   * ({ index: number }): any
   */
  rowGetter: ({ index: number }) => any,

  /**
   * Either a fixed row height (number) or a function that returns the height of a row given its index.
   * ({ index: number }): number
   */
  rowHeight?: number | (({ index: number }) => number),

  /** Number of rows in table. */
  rowCount: number,

  /**
   * Responsible for rendering a table row given an array of columns:
   * Should implement the following interface: ({
   *   className: string,
   *   columns: Array,
   *   index: number,
   *   isScrolling: boolean,
   *   onRowClick: ?Function,
   *   onRowDoubleClick: ?Function,
   *   onRowMouseOver: ?Function,
   *   onRowMouseOut: ?Function,
   *   rowData: any,
   *   style: any
   * }): PropTypes.node
   */
  rowRenderer: ({
    className: string,
    columns: any[],
    index: number,
    isScrolling: boolean,
    onRowClick: ?Function,
    onRowDoubleClick: ?Function,
    onRowMouseOver: ?Function,
    onRowMouseOut: ?Function,
    rowData: any,
    style: any,
  }) => React$Element<any>,

  /** Optional custom inline style to attach to table rows. */
  rowStyle?: | CSSStyleDeclaration
    | (({ index: number }) => CSSStyleDeclaration),

  /** See Grid#scrollToAlignment */
  scrollToAlignment: Alignment,

  /** Row index to ensure visible (by forcefully scrolling if necessary) */
  scrollToIndex: number,

  /** Vertical offset. */
  scrollTop?: number,

  striped?: boolean,

  /**
   * Sort function to be called if a sortable header is clicked.
   * Should implement the following interface: ({
   *   defaultSortDirection: 'ASC' | 'DESC',
   *   event: MouseEvent,
   *   sortBy: string,
   *   sortDirection: SortDirection
   * }): void
   */
  sort?: ({
    defaultSortDirection: 'ASC' | 'DESC',
    event: MouseEvent,
    sortBy: string,
    sortDirection: SortDirection,
  }) => void,

  /** Table data is currently sorted by this :dataKey (if it is sorted at all) */
  sortBy?: string,

  /** Table data is currently sorted in this direction (if it is sorted at all) */
  sortDirection?: SortDirection.ASC | SortDirection.DESC,

  /** Optional inline style */
  style?: CSSStyleDeclaration,

  /** Tab index for focus */
  tabIndex?: number,

  /** Width of list */
  width: number,
};

type State = {
  scrollbarWidth: number,
  hoveredColumnIndex?: number,
  hoveredRowIndex?: number,
  selectedIndex: number[],
};

/**
 * Table component with fixed headers and virtualized rows for improved performance with large data sets.
 * This component expects explicit width, height, and padding parameters.
 */
export default class Table extends React.PureComponent<Props, State> {
  static defaultProps = {
    cellRenderer: defaultCellRenderer,
    disableHeader: false,
    estimatedRowSize: 30,
    headerHeight: 40,
    headerStyle: {},
    noRowsRenderer: () => null,
    onRowsRendered: () => null,
    onScroll: () => null,
    overscanIndicesGetter: accessibilityOverscanIndicesGetter,
    overscanRowCount: 10,
    rowRenderer: defaultRowRenderer,
    headerRowRenderer: defaultHeaderRowRenderer,
    hasVerticalBorder: false,
    hasHorizontalBorder: false,
    striped: false,
    rowStyle: {},
    scrollToAlignment: 'auto',
    scrollToIndex: -1,
    style: {},
    rowHeight: 40,
  };

  constructor(props: Props) {
    super(props);

    this.state = {
      scrollbarWidth: 0,
      hoveredColumnIndex: undefined,
      hoveredRowIndex: undefined,
      selectedIndex: [],
    };
  }

  Grid: MultiGrid;

  _cachedColumnStyles: any[];

  forceUpdateGrid() {
    if (this.Grid) {
      this.Grid.forceUpdate();
    }
  }

  /** TODO: See Grid#getOffsetForCell */
  getOffsetForRow({
    alignment,
    index,
  }: {
    alignment?: Alignment,
    index?: number,
  }) {
    if (this.Grid) {
      const { scrollTop } = this.Grid.getOffsetForCell({
        alignment,
        rowIndex: index,
      });

      return scrollTop;
    }
    return 0;
  }

  /** CellMeasurer compatibility */
  invalidateCellSizeAfterRender({
    columnIndex,
    rowIndex,
  }: CellPosition) {
    if (this.Grid) {
      this.Grid.invalidateCellSizeAfterRender({
        rowIndex,
        columnIndex,
      });
    }
  }

  /** See Grid#measureAllCells */
  measureAllRows() {
    if (this.Grid) {
      this.Grid.measureAllCells();
    }
  }

  /** CellMeasurer compatibility */
  recomputeGridSize({
    columnIndex = 0,
    rowIndex = 0,
  }: CellPosition = {}) {
    if (this.Grid) {
      this.Grid.recomputeGridSize({
        rowIndex,
        columnIndex,
      });
    }
  }

  /** See Grid#recomputeGridSize */
  recomputeRowHeights(index: number = 0) {
    if (this.Grid) {
      this.Grid.recomputeGridSize({
        rowIndex: index,
      });
    }
  }

  /** TODO: See Grid#scrollToPosition */
  scrollToPosition(scrollTop: number = 0) {
    if (this.Grid) {
      this.Grid.scrollToPosition({ scrollTop });
    }
  }

  /** TODO: See Grid#scrollToCell */
  scrollToRow(index: number = 0) {
    if (this.Grid) {
      this.Grid.scrollToCell({
        columnIndex: 0,
        rowIndex: index,
      });
    }
  }

  handleCheckboxChange = ({
    rowIndex,
    checked,
  }: {
    rowIndex: number,
    checked: boolean,
  }) => {
    if (rowIndex === 0) {
      this.setState({
        selectedIndex: checked
          ? []
          : [...Array(this.props.rowCount).keys()],
      });
    } else {
      this.setState(prevState => ({
        selectedIndex: checked
          ? prevState.selectedIndex.filter(x => x !== rowIndex)
          : [...prevState.selectedIndex, rowIndex],
      }));
    }
    this.forceUpdateGrid();
  };

  componentDidMount() {
    this._setScrollbarWidth();
  }

  componentDidUpdate() {
    this._setScrollbarWidth();
  }

  render() {
    const {
      className,
      disableHeader,
      gridClassName,
      gridStyle,
      headerHeight,
      height,
      id,
      noRowsRenderer,
      rowClassName,
      rowStyle,
      scrollToIndex,
      style,
      width,
      rowCount,
    } = this.props;

    const { scrollbarWidth } = this.state;

    const availableRowsHeight = disableHeader
      ? height
      : height - headerHeight;

    const rowClass =
      typeof rowClassName === 'function'
        ? rowClassName({ index: -1 })
        : rowClassName;
    const rowStyleObject =
      typeof rowStyle === 'function'
        ? rowStyle({ index: -1 })
        : rowStyle;

    // Precompute and cache column styles before rendering rows and columns to speed things up
    this._cachedColumnStyles = [];
    const columns = this._getHeaderColumns();
    const fixedColumnCount = columns.filter(
      column => column.props.fixed === true,
    ).length;
    // Note that we specify :rowCount, :scrollbarWidth, :sortBy, and :sortDirection as properties on Grid even though these have nothing to do with Grid.
    // This is done because Grid is a pure component and won't update unless its properties or state has changed.
    // Any property that should trigger a re-render of Grid then is specified here to avoid a stale display.
    return (
      <div
        className={cn('ReactVirtualized__Table', className)}
        id={id}
        role="grid"
        style={style}
      >
        <MultiGrid
          {...this.props}
          width={width}
          className={cn(
            'ReactVirtualized__Table__Grid',
            gridClassName,
          )}
          cellRenderer={props =>
            this._createGrid({
              columns,
              ...props,
              rowClass,
              rowStyleObject,
            })
          }
          columnWidth={({ index }) => columns[index].props.width}
          columnCount={columns.length}
          enableFixedColumnScroll
          enableFixedRowScroll
          fixedRowCount={1}
          fixedColumnCount={fixedColumnCount}
          height={availableRowsHeight}
          id={undefined}
          noContentRenderer={noRowsRenderer}
          onScroll={this._onScroll}
          rowCount={rowCount}
          onSectionRendered={this._onSectionRendered}
          ref={this._setRef}
          role="rowgroup"
          scrollbarWidth={scrollbarWidth}
          scrollToRow={scrollToIndex}
          style={{
            ...gridStyle,
            overflowX: 'hidden',
          }}
        />
      </div>
    );
  }

  _createHeader({
    column,
    index,
    style,
  }: {
    column: React$Element<any>,
    index: number,
    style: CSSStyleDeclaration,
  }) {
    const {
      headerClassName,
      headerStyle,
      onHeaderClick,
      sort,
      sortBy,
      sortDirection,
      hasHorizontalBorder,
      hasVerticalBorder,
    } = this.props;

    const {
      columnData,
      dataKey,
      defaultSortDirection,
      disableSort,
      checkboxRenderer,
      headerRenderer,
      id,
      label,
    } = column.props;
    const sortEnabled =
      !disableSort && sort && dataKey !== 'checkbox';

    const classNames = cn(
      'ReactVirtualized__Table__headerColumn',
      headerClassName,
      column.props.headerClassName,
      {
        ReactVirtualized__Table__sortableHeaderColumn: sortEnabled,
      },
    );

    const renderedHeader =
      dataKey === 'checkbox'
        ? this._checkboxWrapper({
            label,
            checkbox: checkboxRenderer({
              handleCheckboxChange: this.handleCheckboxChange,
              rowIndex: index,
              selectedIndex: this.state.selectedIndex,
            }),
          })
        : headerRenderer({
            columnData,
            dataKey,
            disableSort,
            label,
            sortBy,
            sortDirection,
          });

    let headerOnClick;
    let headerOnKeyDown;
    let headerTabIndex;
    let headerAriaSort;
    let headerAriaLabel;

    if (sortEnabled || onHeaderClick) {
      // If this is a sortable header, clicking it should update the table data's sorting.
      const isFirstTimeSort = sortBy !== dataKey;

      // If this is the firstTime sort of this column, use the column default sort order.
      // Otherwise, invert the direction of the sort.
      // eslint-disable-next-line
      const newSortDirection = isFirstTimeSort
        ? defaultSortDirection
        : sortDirection === SortDirection.DESC
          ? SortDirection.ASC
          : SortDirection.DESC;

      const onClick = event => {
        if (sortEnabled && sort) {
          sort({
            defaultSortDirection,
            event,
            sortBy: dataKey,
            sortDirection: newSortDirection,
          });
        }
        if (onHeaderClick) {
          onHeaderClick({ columnData, dataKey, event });
        }
      };

      const onKeyDown = event => {
        if (event.key === 'Enter' || event.key === ' ') {
          onClick(event);
        }
      };

      headerAriaLabel =
        column.props['aria-label'] || label || dataKey;
      headerTabIndex = 0;
      headerOnClick = onClick;
      headerOnKeyDown = onKeyDown;
    }

    if (sortBy === dataKey) {
      headerAriaSort =
        sortDirection === SortDirection.ASC
          ? 'ascending'
          : 'descending';
    }

    // Avoid using object-spread syntax with multiple objects here,
    // Since it results in an extra method call to 'babel-runtime/helpers/extends'
    // See PR https://github.com/bvaughn/react-virtualized/pull/942
    return (
      <HeaderCell
        hasHorizontalBorder={hasHorizontalBorder}
        hasVerticalBorder={hasVerticalBorder}
        aria-label={headerAriaLabel}
        aria-sort={headerAriaSort}
        className={classNames}
        id={id}
        key={`Header-Col${index}`}
        onClick={headerOnClick}
        onKeyDown={headerOnKeyDown}
        role="columnheader"
        style={{
          ...headerStyle,
          ...style,
        }}
        tabIndex={headerTabIndex}
      >
        {renderedHeader}
      </HeaderCell>
    );
  }

  _checkboxWrapper = ({
    label,
    checkbox,
  }: {
    label: string,
    checkbox: React$Element<any>,
  }) => (
    <HeaderLabel
      className="ReactVirtualized__Table__headerTruncatedText"
      key="label"
      title={label}
    >
      {checkbox}
    </HeaderLabel>
  );

  _createGrid: CellRenderer = ({
    columns,
    rowIndex,
    isScrolling,
    key,
    parent,
    style,
    columnIndex,
    isVisible,
    rowStyleObject,
    rowClass,
  }) => {
    const {
      hasHorizontalBorder,
      hasVerticalBorder,
      striped,
    } = this.props;
    if (rowIndex === 0) {
      const column = columns[columnIndex];
      return this._createHeader({
        column,
        index: columnIndex,
        style,
      });
    }

    const isEven = rowIndex % 2 === 0 && striped;
    const cellProps = {
      isVisible,
      key,
      style,
      columnIndex,
      rowIndex: rowIndex - 1,
      parent,
      isScrolling,
      columns,
      rowStyleObject,
      rowClass,
      hasHorizontalBorder,
      hasVerticalBorder,
      striped,
      isEven,
      hoveredColumn:
        this.state.hoveredColumnIndex === columnIndex - 1,
      hoveredRow: this.state.hoveredRowIndex === rowIndex - 1,
      onHover: this._onHoverCell,
    };
    const hasCheckbox =
      columns.length > 0 && columns[0].props.dataKey === 'checkbox';
    if (hasCheckbox && columnIndex === 0) {
      const checkbox = columns[columnIndex].props.checkboxRenderer({
        rowIndex,
        selectedIndex: this.state.selectedIndex,
        handleCheckboxChange: this.handleCheckboxChange,
      });
      return (
        <CheckboxCell
          isEven={isEven}
          {...cellProps}
          onMouseOver={() =>
            cellProps.onHover({
              rowIndex: rowIndex - 1,
              columnIndex,
            })
          }
        >
          {checkbox}
        </CheckboxCell>
      );
    }
    return this.props.cellRenderer(cellProps);
  };

  _onHoverCell = ({ rowIndex, columnIndex }: CellPosition) => {
    this.setState({
      hoveredColumnIndex: columnIndex,
      hoveredRowIndex: rowIndex,
    });
  };

  _getHeaderColumns = () => {
    const { children, disableHeader } = this.props;
    const items: React$Element<any>[] = disableHeader
      ? []
      : React.Children.toArray(children);
    return items;
  };

  _getRowHeight(rowIndex: number) {
    const { rowHeight } = this.props;

    return typeof rowHeight === 'function'
      ? rowHeight({ index: rowIndex })
      : rowHeight;
  }

  _onScroll = ({
    clientHeight,
    scrollHeight,
    scrollTop,
  }: ScrollParams) => {
    const { onScroll } = this.props;
    if (onScroll) {
      onScroll({ clientHeight, scrollHeight, scrollTop });
    }
  };

  _onSectionRendered = ({
    rowOverscanStartIndex,
    rowOverscanStopIndex,
    rowStartIndex,
    rowStopIndex,
  }: RenderedSection) => {
    const { onRowsRendered } = this.props;
    if (onRowsRendered) {
      onRowsRendered({
        overscanStartIndex: rowOverscanStartIndex,
        overscanStopIndex: rowOverscanStopIndex,
        startIndex: rowStartIndex,
        stopIndex: rowStopIndex,
      });
    }
  };

  _setRef = (ref: ?HTMLElement) => {
    this.Grid = ref;
  };

  _setScrollbarWidth() {
    if (this.Grid) {
      // eslint-disable-next-line
      const Grid = findDOMNode(this.Grid);
      if (Grid) {
        // $FlowFixMe
        const clientWidth: number = Grid.clientWidth || 0;
        // $FlowFixMe
        const offsetWidth: number = Grid.offsetWidth || 0;
        const scrollbarWidth = offsetWidth - clientWidth;
        this.setState({ scrollbarWidth });
      }
    }
  }
}
