import React, { ReactElement, ReactNode, useMemo, useState } from 'react';
import {
  useTable,
  useSortBy,
  useRowSelect,
  useFilters,
  useExpanded,
  Column,
  Row,
  Filters,
  IdType,
  SortingRule,
  Hooks,
  HeaderGroup,
} from 'react-table';
import css from '../../utils/css';

import StyledTable, {
  StyledTableWrapper,
  StyledTHead,
  StyledTBody,
  StyledTD,
  StyledPaginationWrapper,
  StyledItemsPerPageSelect,
  StyledEmptyTableContent,
} from './StyledTable';
import TableTH from './TableTH';

import Spinner from '../Spinner';
import Pagination from '../Pagination';

import useRowSelectionUI from './useRowSelectionUI';
import useRowExpansionUI from './useRowExpansionUI';

import { flatMap, fromUndefinedable, getOrElse, map } from '../../fp/Option';
import {
  always,
  equal,
  invokeWith,
  noop,
  pipe,
  isDefined,
} from '../../fp/function';
import { find, reject } from '../../fp/Array';
import { CommonProps } from '../common';
import { useDeprecation, useResizeObserver } from '../../utils/hooks';
import useColumnLefts from './useColumnLefts';

export interface TableProps<D extends Record<string, unknown>>
  extends CommonProps {
  /**
   * Array of table columns. Some notes when you provide this prop:
   * * Must be memoized.
   * * Column must be an object of:
   *     * Cell?: a React component to render the cell at column. Checkout this example http://hero-design.surge.sh/components/table#example-cell-customisation for further information.
   *     * Filter?: @deprecated - a React component to render the column filter.
   *     * Header?: a string or React component to render the column header.
   *     * accessor?: a string as the path of the data element's property.
   *     * align?: one of 'left' | 'right' to handle the text alignment.
   *     * disableSortBy?: a boolean to enable/disable column sorting (default is false)
   *     * displayAtBreakpoint?: one of 'sm' | 'md' | 'lg' | 'xl' to handle the column rendering depending on media query breakpoints.
   *     * width?: a string to specify an explicit width of the column.
   */
  columns: Column<D>[];
  /**
   * Array of D (D is the generic type of data element you pass in).
   */
  data: D[];
  /**
   * Controlled expanded rows state, expandedRows is an object of:
   * - key: row index.
   * - value: boolean. True means the row at index is expanded.
   */
  expandedRows?: Record<IdType<D>, boolean>;
  /**
   * Whether the table has expanded rows. When expansion is available, its data shape must be an object of:
   * - expandedRowRenderer: a render function '(rowData: D) => ReactElement' to render the expanded row.
   * - rowExpandable: a predicate function '(rowData: D) => boolean' to decide a row is expandable or not.
   */
  expansion?: {
    expandedRowRenderer: (rowData: D) => ReactElement;
    rowExpandable: (rowData: D) => boolean;
  };
  /**
   * Controlled filters value, Filters is an array of:
   * - id: column id, this matches with accessor configuration.
   * - value: filter value
   */
  filters?: Filters<D>;
  /**
   * Whether the table's first column(s) sticks to the left when scroll horizontally on small screen size devices.
   */
  horizontallySticky?: boolean;
  /**
   * Controlled items per page, itemsPerPages is an object of:
   * - options: array of [Select](http://hero-design.surge.sh/components/select#select-1) options.
   * - value: selected items per page value.
   */
  itemsPerPage?: {
    options: {
      text: string;
      value: string | number;
    }[];
    value?: string | number;
  };
  /**
   * Loading state of Table, which will render a spinner in the center of table.
   */
  loading?: boolean;
  /**
   * Content to render when there is no data in the table.
   */
  noData?: ReactNode;
  /**
   * Callback invoked when any row is expanded or collapsed. expandedRows is an object of:
   * - key: row index.
   * - value: boolean. True means the row at index is expanded.
   */
  onExpandedRowsChange?: (expandedRows: Record<IdType<D>, boolean>) => void;
  /**
   * Callback invoked when filters are changed. Filters is an array of:
   * - id: column id, this matches with accessor configuration.
   * - value: filter value
   */
  onFiltersChange?: (filters: Filters<D>) => void;
  /**
   * Callback invoked when items per page selection is changed.
   */
  onItemsPerPageChange?: (itemsPerPage: string | number) => void;
  /**
   * Callback invoked when a page in pagination is changed.
   */
  onPaginationChange?: (page: number) => void;
  /**
   * Callback invoked when any row is selected or deselected. selectedRows is an object of:
   * - key: row index.
   * - value: boolean. True means the row at index is selected.
   */
  onSelectedRowsChange?: (selectedRows: Record<IdType<D>, boolean>) => void;
  /**
   * Callback invoked when sorting is changed. sortBy is an array of:
   * - id: column id, this matches with accessor configuration.
   * - desc: boolean, true means sorting descendingly.
   */
  onSortByChange?: (sortBy: SortingRule<D>[]) => void;
  /**
   * Whether the table has pagination. When pagination is ON, its data shape must be an object of:
   * - current: a number which is 1-based indexing to indicate the current selected page.
   * - total: a number indicates the total of pages.
   */
  pagination?: {
    current: number;
    total: number;
  };
  /**
   * An object for rows configuration, it must be memoized.
   */
  rows?: {
    generateClassName?: (row: Row<D>) => string | undefined;
  };
  /**
   * Whether the table is allowed using checkbox to select table rows.
   */
  selectable?: boolean;
  /**
   * Controlled selected rows state, selectedRows is an object of:
   * - key: row index.
   * - value: boolean. True means the row at index is selected.
   */
  selectedRows?: Record<IdType<D>, boolean>;
  /**
   * Controlled sortBy state, sortBy is an array of:
   * - id: column id, this matches with accessor configuration.
   * - desc: boolean, true means sorting descendingly.
   */
  sortBy?: SortingRule<D>[];
  /**
   * Whether the table's header sticks to the top.
   */
  sticky?: boolean;
}

export const hasFixedLayout = <D extends Record<string, unknown>>(
  columns: Column<D>[]
) => {
  const headerWithWidth = columns.find(header =>
    Object.keys(header).includes('width')
  );

  return headerWithWidth !== undefined;
};

const emptyHook = <D extends Record<string, unknown>>(_hooks: Hooks<D>): void =>
  undefined;

type TableActionType =
  | 'toggleRowSelected'
  | 'toggleAllRowsSelected'
  | 'toggleSortBy'
  | 'setFilter'
  | 'toggleRowExpanded';

function Table<D extends Record<string, unknown>>({
  columns,
  data,
  loading,
  sticky = false,
  horizontallySticky = false,
  selectable = false,
  selectedRows,
  onSelectedRowsChange,
  expansion,
  expandedRows,
  onExpandedRowsChange,
  pagination,
  rows: rowsConfig,
  onPaginationChange,
  itemsPerPage,
  onItemsPerPageChange,
  filters,
  onFiltersChange,
  sortBy,
  onSortByChange,
  id,
  className,
  style,
  sx = {},
  'data-test-id': dataTestId,
  noData,
}: TableProps<D>): ReactElement {
  useDeprecation(
    'Filter property of a column is deprecated. It will be removed in the next major release of Hero-design!',
    columns.find(c => c.Filter !== undefined) !== undefined
  );

  const useControlledState = React.useCallback(
    state => ({
      ...state,
      filters: pipe(
        fromUndefinedable(filters),
        getOrElse(() => state.filters)
      ),
      expanded: pipe(
        fromUndefinedable(expandedRows),
        getOrElse(() => state.expanded)
      ),
      sortBy: pipe(
        fromUndefinedable(sortBy),
        getOrElse(() => state.sortBy)
      ),
      selectedRowIds: pipe(
        fromUndefinedable(selectedRows),
        getOrElse(() => state.selectedRowIds)
      ),
    }),
    [filters, expandedRows, sortBy, selectedRows]
  );

  const {
    getTableProps,
    getTableBodyProps,
    headerGroups,
    rows,
    prepareRow,
  } = useTable(
    {
      columns,
      data,
      manualFilters: true,
      initialState: {
        filters,
        expanded: pipe(
          fromUndefinedable(expandedRows),
          getOrElse(() => ({} as Record<IdType<D>, boolean>))
        ),
        sortBy: pipe(
          fromUndefinedable(sortBy),
          getOrElse(() => [])
        ),
        selectedRowIds: pipe(
          fromUndefinedable(selectedRows),
          getOrElse(() => ({} as Record<IdType<D>, boolean>))
        ),
      },
      stateReducer: (newState, action) => {
        switch (action.type as TableActionType) {
          case 'toggleRowSelected':
          case 'toggleAllRowsSelected':
            pipe(
              fromUndefinedable(onSelectedRowsChange),
              getOrElse(() => noop),
              invokeWith(newState.selectedRowIds)
            );
            break;
          case 'toggleSortBy':
            pipe(
              fromUndefinedable(onSortByChange),
              getOrElse(() => noop),
              invokeWith(newState.sortBy)
            );
            break;
          case 'setFilter':
            pipe(
              fromUndefinedable(onFiltersChange),
              getOrElse(() => noop),
              invokeWith(newState.filters)
            );
            break;
          case 'toggleRowExpanded':
            pipe(
              fromUndefinedable(onExpandedRowsChange),
              getOrElse(() => noop),
              invokeWith(newState.expanded)
            );
            break;
          default:
            break;
        }
        return newState;
      },
      useControlledState,
      customRowExpandable:
        expansion !== undefined ? expansion.rowExpandable : always(false),
    },
    ...pipe(
      [
        filters !== undefined && onFiltersChange !== undefined
          ? useFilters
          : emptyHook,
        useSortBy,
        expansion !== undefined ? useExpanded : emptyHook,
        expansion !== undefined ? useRowExpansionUI : emptyHook,
        selectable === true ? useRowSelect : emptyHook,
        selectable === true ? useRowSelectionUI : emptyHook,
      ],
      reject(equal(emptyHook))
    )
  );

  const tableProps = getTableProps();
  const bodyProps = getTableBodyProps();

  // We are using headerGroups[0] here because currently we don't support multiple header groups
  const [columnLefts, columnRefs] = useColumnLefts<HTMLTableHeaderCellElement>(
    headerGroups[0]?.headers.length
  );
  // Column divider divides the sticky and scrollable parts of the table.
  // We want to stick expansion/selection column and the first column after expansion/selection column.
  // Since expansion/selection column does NOT have accessor, the first column with accessor will be the divider.
  const columnDividerIndex =
    headerGroups[0] !== undefined
      ? headerGroups[0].headers.findIndex(col => col.accessor !== undefined)
      : 0;

  const [horizontallyScrollable, setHorizontallyScrollable] = useState(false);
  const [
    tableWrapperElement,
    setTableWrapperElement,
  ] = useState<HTMLDivElement | null>(null);
  useResizeObserver(({ clientWidth, scrollWidth }) => {
    setHorizontallyScrollable(clientWidth < scrollWidth);
  }, tableWrapperElement);

  const isHorizontallySticky = useMemo(
    () => (columnIndex: number) =>
      horizontallySticky &&
      horizontallyScrollable &&
      columnIndex <= columnDividerIndex,
    [horizontallySticky, columnDividerIndex, horizontallyScrollable]
  );
  const shouldShowDivider = useMemo(
    () => (columnIndex: number) =>
      horizontallySticky &&
      horizontallyScrollable &&
      columnIndex === columnDividerIndex,
    [horizontallySticky, columnDividerIndex, horizontallyScrollable]
  );

  return (
    <Spinner loading={loading}>
      <StyledTableWrapper
        themeSticky={sticky}
        id={id}
        className={className}
        style={{ ...style, ...css(sx) }}
        data-test-id={dataTestId}
        ref={setTableWrapperElement}
      >
        <StyledTable
          style={tableProps.style}
          className={tableProps.className}
          role={tableProps.role}
          themeFixedLayout={hasFixedLayout(columns)}
        >
          <StyledTHead>
            {headerGroups.map((headerGroup: HeaderGroup<D>) => {
              const headerGroupProps = headerGroup.getHeaderGroupProps();

              return (
                <tr key={headerGroupProps.key}>
                  {headerGroup.headers.map((column, columnIndex) => {
                    const headerProps = column.getHeaderProps(
                      column.getSortByToggleProps()
                    );
                    const hasFilterInput = pipe(
                      fromUndefinedable(filters),
                      flatMap(find(filter => filter.id === column.id)),
                      map(
                        ({ value }) =>
                          isDefined(value) &&
                          !(typeof value === 'string' && value === '')
                      ),
                      getOrElse(() => false)
                    );

                    return (
                      <TableTH
                        // XXX: Intentionally having spreading props to accept custom props from react-table
                        {...headerProps}
                        key={headerProps.key}
                        sortingProps={{
                          toggleSortBy: column.toggleSortBy,
                          sortable: column.canSort,
                          sorted: column.isSorted,
                          sortedDesc: column.isSortedDesc,
                        }}
                        sticky={sticky}
                        displayAtBreakpoint={column.displayAtBreakpoint}
                        filterable={
                          column.canFilter === true &&
                          column.Filter !== undefined
                        }
                        renderFilter={(props: {
                          onClose: () => void;
                        }): ReactNode => column.render('Filter', props)}
                        hasFilterInput={hasFilterInput}
                        data-test-id={`table__${headerProps.key}`}
                        align={column.align}
                        themeWidth={column.width}
                        themeHorizontallySticky={isHorizontallySticky(
                          columnIndex
                        )}
                        themeLeft={columnLefts[columnIndex]}
                        themeShowDivider={shouldShowDivider(columnIndex)}
                        ref={columnRefs[columnIndex]}
                      >
                        {column.render('Header')}
                      </TableTH>
                    );
                  })}
                </tr>
              );
            })}
          </StyledTHead>
          <StyledTBody
            style={bodyProps.style}
            className={bodyProps.className}
            role={bodyProps.role}
          >
            {rows.map((row: Row<D>) => {
              prepareRow(row);
              const rowProps = row.getRowProps();
              const rowClassName =
                rowsConfig !== undefined &&
                rowsConfig.generateClassName !== undefined
                  ? rowsConfig.generateClassName(row)
                  : undefined;

              return (
                <React.Fragment key={rowProps.key}>
                  <tr
                    className={rowClassName}
                    data-test-id={`table__${rowProps.key}`}
                  >
                    {row.cells.map((cell, cellIndex) => {
                      const cellProps = cell.getCellProps();

                      return (
                        <StyledTD
                          key={cellProps.key}
                          data-test-id={`table__${cellProps.key}`}
                          themeAlign={cell.column.align}
                          themeDisplayAtBreakpoint={
                            cell.column.displayAtBreakpoint
                          }
                          themeHorizontallySticky={isHorizontallySticky(
                            cellIndex
                          )}
                          themeLeft={columnLefts[cellIndex]}
                          themeShowDivider={shouldShowDivider(cellIndex)}
                        >
                          {cell.render('Cell')}
                        </StyledTD>
                      );
                    })}
                  </tr>
                  {expansion !== undefined &&
                    row.isExpanded === true &&
                    expansion.expandedRowRenderer(row.original)}
                </React.Fragment>
              );
            })}
          </StyledTBody>
        </StyledTable>
        {rows.length === 0 && noData !== undefined && (
          <StyledEmptyTableContent>{noData}</StyledEmptyTableContent>
        )}
      </StyledTableWrapper>
      {pipe(
        fromUndefinedable(pagination),
        map(paginationValue => (
          <StyledPaginationWrapper>
            <Pagination
              current={paginationValue.current}
              total={paginationValue.total}
              onChange={
                onPaginationChange !== undefined ? onPaginationChange : noop
              }
            />
            {pipe(
              fromUndefinedable(itemsPerPage),
              map(ipp => (
                <StyledItemsPerPageSelect
                  size="small"
                  onChange={(val): void => {
                    if (
                      val !== undefined &&
                      onItemsPerPageChange !== undefined
                    ) {
                      onItemsPerPageChange(val);
                    }
                  }}
                  options={ipp.options}
                  value={ipp.value}
                  optionMenuStyle={{ width: 'auto' }}
                />
              )),
              getOrElse(() => null)
            )}
          </StyledPaginationWrapper>
        )),
        getOrElse(() => null)
      )}
    </Spinner>
  );
}

export default Table;
