import {
  AgGridEvent,
  AllCommunityModule,
  CellClassParams,
  CellClickedEvent,
  CellDoubleClickedEvent,
  CellFocusedEvent,
  CellKeyDownEvent,
  ColDef,
  ColGroupDef,
  ColumnMovedEvent,
  ColumnResizedEvent,
  EditableCallback,
  EditableCallbackParams,
  GetRowIdParams,
  GridOptions,
  GridReadyEvent,
  GridSizeChangedEvent,
  IRowNode,
  ModelUpdatedEvent,
  ModuleRegistry,
  RowClickedEvent,
  RowDoubleClickedEvent,
  RowDragEndEvent,
  RowDragMoveEvent,
  SelectionChangedEvent,
  SelectionColumnDef,
  ValueFormatterParams,
} from 'ag-grid-community';
import { AgGridReact } from 'ag-grid-react';
import clsx from 'clsx';
import { defer, delay, difference, isEmpty, last, omit, xorBy } from 'lodash-es';
import { ReactElement, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';

import { AutoSizeColumnsResult, StartCellEditingProps, useGridContext } from '../contexts/GridContext';
import { GridUpdatingContext } from '../contexts/GridUpdatingContext';
import { isStorybook } from '../utils/storybook';
import { useInterval } from '../utils/useInterval';
import { compareNaturalInsensitive, fnOrVar, isNotEmpty } from '../utils/util';
import { clickInputWhenContainingCellClicked } from './clickInputWhenContainingCellClicked';
import { GridHeaderSelect } from './gridHeader';
import { GridContextMenuComponent, useGridContextMenu } from './gridHook';
import { useGridCopy } from './gridHook/useGridCopy';
import { CellLocation, useGridRangeSelection } from './gridHook/useGridRangeSelection';
import { GridNoRowsOverlay } from './GridNoRowsOverlay';
import { usePostSortRowsHook } from './PostSortRowsHook';
import { ColDefT, GridBaseRow, GridOnRowDragEndProps } from './types';

ModuleRegistry.registerModules([AllCommunityModule]);

export interface GridProps<TData extends GridBaseRow = GridBaseRow> {
  ['data-testid']?: string;
  theme?: string;

  // ─── Grid State ────────────────────────────────────────────────
  loading?: boolean;
  readOnly?: boolean;
  suppressReadOnlyStyle?: boolean;

  // ─── Data & Columns ────────────────────────────────────────────
  columnDefs: ColDef<TData>[] | ColGroupDef<TData>[];
  defaultColDef?: GridOptions['defaultColDef'];
  defaultPostSort?: boolean;
  domLayout?: GridOptions['domLayout'];
  pinnedBottomRowData?: GridOptions['pinnedBottomRowData'];
  pinnedTopRowData?: GridOptions['pinnedTopRowData'];
  rowData: GridOptions['rowData'];
  rowClassRules?: GridOptions['rowClassRules'];
  rowHeight?: number;
  rowSelection?: 'single' | 'multiple';
  sizeColumns?: 'fit' | 'auto' | 'auto-skip-headers' | 'none';

  // ─── Selection ────────────────────────────────────────────────
  autoSelectFirstRow?: boolean;
  enableClickSelection?: boolean;
  enableRangeSelection?: boolean;
  enableSelectionWithoutKeys?: boolean;
  enableMultilineBulkEdit?: boolean;
  externalSelectedIds?: TData['id'][];
  externalSelectedItems?: TData[];
  hideSelectColumn?: boolean;
  hideSelectedRow?: boolean;
  selectColumnPinned?: ColDef['pinned'];
  selectable?: boolean;
  setExternalSelectedIds?: (ids: TData['id'][]) => void;
  setExternalSelectedItems?: (items: TData[]) => void;

  // ─── Editing ──────────────────────────────────────────────────
  onBulkEditingComplete?: () => Promise<void> | void;
  singleClickEdit?: boolean;

  // ─── Context Menu ─────────────────────────────────────────────
  contextMenu?: GridContextMenuComponent<TData>;
  contextMenuSelectRow?: boolean;

  // ─── Callbacks / Events ───────────────────────────────────────
  onCellFocused?: (props: { colDef: ColDef<TData>; data: TData }) => void;
  onColumnMoved?: GridOptions['onColumnMoved'];
  onContentSize?: (props: { width: number }) => void;

  /**
   * @deprecated You should drive your app off selection states. This will be deleted.
   */
  onRowClicked?: (event: RowClickedEvent) => void;
  /**
   * @deprecated You should drive your app off selection states. This will be deleted.
   */
  onRowDoubleClicked?: (event: RowDoubleClickedEvent) => void;
  onRowDragEnd?: (props: GridOnRowDragEndProps<TData>) => Promise<void> | void;

  // ─── Row Behavior ─────────────────────────────────────────────
  animateRows?: boolean;
  alwaysShowVerticalScroll?: boolean;
  rowDragText?: GridOptions['rowDragText'];

  // ─── Overlays / Messages ──────────────────────────────────────
  noRowsOverlayText?: string;
  noRowsMatchingOverlayText?: string;

  // ─── Miscellaneous ────────────────────────────────────────────
  maxInitialWidth?: number;
  suppressCellFocus?: boolean;
  suppressColumnVirtualization?: GridOptions['suppressColumnVirtualisation'];
}

/**
 * Wrapper for AgGrid to add commonly used functionality.
 */
export const Grid = <TData extends GridBaseRow = GridBaseRow>({
  theme = 'ag-theme-step-default',
  'data-testid': dataTestId,

  // ─── Grid State ───────────────────────────────
  suppressReadOnlyStyle = false,

  // ─── Data & Columns ───────────────────────────
  defaultPostSort = true,
  rowData,
  rowHeight = theme === 'ag-theme-step-default' ? 40 : theme === 'ag-theme-step-compact' ? 36 : 40,
  rowSelection = 'multiple',
  sizeColumns = 'auto',

  // ─── Selection ────────────────────────────────
  autoSelectFirstRow,
  enableRangeSelection = false,
  externalSelectedIds,
  externalSelectedItems,
  selectColumnPinned = 'left',
  selectable,
  setExternalSelectedIds,
  setExternalSelectedItems,

  // ─── Editing ──────────────────────────────────
  singleClickEdit = false,
  enableMultilineBulkEdit = false,

  // ─── Context Menu ─────────────────────────────
  contextMenuSelectRow = false,
  contextMenu,

  // ─── Callbacks / Events ───────────────────────
  onCellFocused: paramsOnCellFocused,
  onColumnMoved,

  // ─── Row Behavior ─────────────────────────────
  suppressColumnVirtualization = true,

  // ─── Miscellaneous ────────────────────────────
  maxInitialWidth,

  // ─── Spread Remaining Params ──────────────────
  ...params
}: GridProps<TData>): ReactElement => {
  const {
    setApis,
    setExternallySelectedItemsAreInSync,
    setOnBulkEditingComplete,
    gridReady,
    gridRenderState,
    externallySelectedItemsAreInSync,
    showNoRowsOverlay,
    autoSizeColumns,
    sizeColumnsToFit,
    doesExternalFilterPass,
    ensureRowVisible,
    ensureSelectedRowIsVisible,
    focusByRowById,
    getColDef,
    getFirstRowId,
    isExternalFilterPresent,
    prePopupOps,
    selectRowsById,

    startCellEditing: propStartCellEditing,
  } = useGridContext<TData>();

  // CellEditingStop event happens too much for one edit
  const startedEditRef = useRef(false);
  const startCellEditing = useCallback(
    (props: StartCellEditingProps<TData>) => {
      startedEditRef.current = true;
      return propStartCellEditing(props);
    },
    [propStartCellEditing],
  );

  const { updatedDep, anyUpdating, updatingCols } = useContext(GridUpdatingContext);

  const gridDivRef = useRef<HTMLDivElement>(null);
  const lastSelectedIds = useRef<TData['id'][]>(undefined);

  const [staleGrid, setStaleGrid] = useState(false);
  const [autoSized, setAutoSized] = useState(false);

  const postSortRows = usePostSortRowsHook({ setStaleGrid });

  /**
   * onContentSize should only be called at maximum twice.
   * Once when an empty grid is loaded.
   * And again when the grid has content.
   */
  const hasSetContentSize = useRef(false);
  const hasSetContentSizeEmpty = useRef(false);
  const needsAutoSize = useRef(true);

  const autoSizeResultRef = useRef<AutoSizeColumnsResult | null>(null);
  const prevRowsVisibleRef = useRef(false);
  const initialContentSizeInProgressRef = useRef(false);
  const setInitialContentSize = useCallback(async (): Promise<void> => {
    if (initialContentSizeInProgressRef.current) {
      return;
    }
    initialContentSizeInProgressRef.current = true;
    try {
      needsAutoSize.current = false;

      if (!gridDivRef.current?.clientWidth || rowData == null) {
        // Don't resize grids if they are offscreen as it doesn't work.
        needsAutoSize.current = true;
        return;
      }

      const gridRendered = gridRenderState();
      if (gridRendered === null) {
        // Don't resize until grid has rendered, or it has 0 rows.
        needsAutoSize.current = true;
        return;
      }

      // 1. First we autosize to get the size of the columns on an infinite grid.
      if (sizeColumns === 'auto' || sizeColumns === 'auto-skip-headers') {
        // You can't skip headers until the grid has content
        const rowsVisible = gridRendered === 'rows-visible';
        const skipHeader = sizeColumns === 'auto-skip-headers' && rowsVisible;
        // If grid was empty and now has content we need to autosize
        if (rowsVisible !== prevRowsVisibleRef.current && rowsVisible) {
          prevRowsVisibleRef.current = rowsVisible;
          autoSizeResultRef.current = null;
        }
        const autoSizeResult =
          autoSizeResultRef.current ??
          (await autoSizeColumns({
            skipHeader,
            userSizedColIds: new Set(userSizedColIds.current.keys()),
          }));
        // Auto-size failed retry later
        if (!autoSizeResult) {
          needsAutoSize.current = true;
          return;
        }

        autoSizeResultRef.current = autoSizeResult;
        // Calculate the auto-sized width, limit it to maxInitialWidth
        autoSizeResult.width = maxInitialWidth ? Math.min(autoSizeResult.width, maxInitialWidth) : autoSizeResult.width;
        if (gridRendered === 'empty') {
          // If the grid is empty we still do an onContentSize callback, we will do another callback when grid has data
          // We don't do this callback if we have previously had row data, or have already called back for empty
          if (!hasSetContentSizeEmpty.current && !hasSetContentSize.current) {
            hasSetContentSizeEmpty.current = true;
            params.onContentSize?.(autoSizeResult);
          }
        } else if (gridRendered === 'rows-visible') {
          // we have rows now so callback grid size
          if (!hasSetContentSize.current) {
            hasSetContentSize.current = true;
            params.onContentSize?.(autoSizeResult);
          }
        } else {
          // It should be impossible to get here
          console.error('Unknown value returned from hasGridRendered');
        }
      } else {
        sizeColumnsToFit();
      }

      setAutoSized(true);
      needsAutoSize.current = false;
    } finally {
      initialContentSizeInProgressRef.current = false;
    }
  }, [autoSizeColumns, gridRenderState, maxInitialWidth, params, rowData, sizeColumns, sizeColumnsToFit]);

  const lastOwnerDocumentRef = useRef<Document>(undefined);
  const wasVisibleRef = useRef(false);

  /**
   * Auto-size windows that had deferred auto-size
   * Reset focus if panel went from invisible to visible.
   */
  useInterval(() => {
    // If grid has become visible after previously being hidden, then refocus the last focused cell.
    const visible = !!gridDivRef.current?.checkVisibility?.();
    if (visible && !wasVisibleRef.current) {
      wasVisibleRef.current = true;
      const el = (window as any).__stepaggrid_lastfocuseventtarget;
      if (el) {
        // Setting this to null will cause a new refocus event
        (window as any).__stepaggrid_lastfocuseventtarget = null;
        // Check element is still part of document
        if (el.checkVisibility()) {
          el.focus();
        }
      }
    }
    wasVisibleRef.current = visible;

    // Check if window has been popped out and needs resize
    const currentDocument = gridDivRef.current?.ownerDocument;
    if (currentDocument !== lastOwnerDocumentRef.current) {
      lastOwnerDocumentRef.current = currentDocument;
      if (currentDocument) {
        needsAutoSize.current = true;
      }
    }
    if (
      needsAutoSize.current ||
      (!hasSetContentSize.current && (sizeColumns === 'auto' || sizeColumns === 'auto-skip-headers'))
    ) {
      void setInitialContentSize();
    }
  }, 200);

  /**
   * On data load select the first row of the grid if required.
   */
  const hasSelectedFirstItem = useRef(false);
  useEffect(() => {
    if (!gridReady || hasSelectedFirstItem.current || !rowData || !externallySelectedItemsAreInSync) {
      return;
    }
    hasSelectedFirstItem.current = true;
    if (isNotEmpty(rowData) && isEmpty(externalSelectedItems) && isEmpty(externalSelectedIds)) {
      const firstRowId = getFirstRowId();
      if (autoSelectFirstRow && selectable) {
        selectRowsById([firstRowId]);
      } else {
        if (!isStorybook) {
          focusByRowById(firstRowId, true);
        }
      }
    }
  }, [
    externallySelectedItemsAreInSync,
    focusByRowById,
    gridReady,
    externalSelectedItems,
    autoSelectFirstRow,
    rowData,
    selectRowsById,
    getFirstRowId,
    selectable,
    externalSelectedIds,
  ]);

  /**
   * Ensure external selected items list is in sync with panel.
   */
  const synchroniseExternalStateToGridSelection = useCallback(
    ({ api }: SelectionChangedEvent) => {
      if (externalSelectedIds && setExternalSelectedIds) {
        const selectedRowsIds = api.getSelectedRows().map((row: TData) => row.id);

        // We don't want to update selected Items if it hasn't changed to prevent excess renders
        if (
          externalSelectedIds.length !== selectedRowsIds.length ||
          isNotEmpty(xorBy(selectedRowsIds, externalSelectedIds))
        ) {
          setExternallySelectedItemsAreInSync(false);
          setExternalSelectedIds([...selectedRowsIds]);
        } else {
          setExternallySelectedItemsAreInSync(true);
        }
      } else if (externalSelectedItems && setExternalSelectedItems) {
        const selectedRows = api.getSelectedRows();

        // We don't want to update selected Items if it hasn't changed to prevent excess renders
        if (
          externalSelectedItems.length !== selectedRows.length ||
          isNotEmpty(xorBy(selectedRows, externalSelectedItems, (row) => row.id))
        ) {
          setExternallySelectedItemsAreInSync(false);
          setExternalSelectedItems([...selectedRows]);
        } else {
          setExternallySelectedItemsAreInSync(true);
        }
      } else {
        setExternallySelectedItemsAreInSync(true);
      }
    },
    [
      externalSelectedIds,
      externalSelectedItems,
      setExternalSelectedIds,
      setExternalSelectedItems,
      setExternallySelectedItemsAreInSync,
    ],
  );

  /**
   * Synchronise externally selected items to grid.
   * If new ids are selected scroll them into view.
   */
  const synchroniseExternallySelectedItemsToGrid = useCallback(() => {
    if (!gridReady) return;
    if (!externalSelectedItems && !externalSelectedIds) {
      setExternallySelectedItemsAreInSync(true);
      return;
    }

    const selectedIds = externalSelectedIds ?? externalSelectedItems?.map((row) => row.id);
    const lastNewId = last(difference(selectedIds, lastSelectedIds.current ?? []));
    if (lastNewId != null) {
      ensureRowVisible(lastNewId);
    }
    lastSelectedIds.current = selectedIds;
    selectRowsById(selectedIds);
    setExternallySelectedItemsAreInSync(true);
  }, [
    gridReady,
    externalSelectedItems,
    externalSelectedIds,
    selectRowsById,
    setExternallySelectedItemsAreInSync,
    ensureRowVisible,
  ]);

  /**
   * Combine grid and cell editable into one function
   */
  const combineEditables =
    (...editables: (boolean | EditableCallback | undefined)[]) =>
    (params: EditableCallbackParams): boolean => {
      const results = editables.map((editable) => fnOrVar(editable, params));
      // If editable is not set anywhere then it's non-editable
      if (results.every((v) => v == null)) return false;
      // If any editable value is or returns false then it's non-editable
      return !results.some((v) => v == false);
    };

  /**
   * Synchronise externally selected items to grid on externalSelectedItems change
   */
  useEffect(synchroniseExternallySelectedItemsToGrid, [synchroniseExternallySelectedItemsToGrid]);

  const mapColDef = useCallback(
    (colDef: ColDef | ColGroupDef): ColDef | ColGroupDef => {
      if ('children' in colDef) {
        return {
          ...colDef,
          children: colDef.children.map(mapColDef),
        };
      } else {
        const colDefEditable = colDef.editable;
        const ignoreGridReadOnly = (colDef as ColDefT<TData>).ignoreGridReadOnly === true;
        const editable = combineEditables(
          params.loading !== true,
          ignoreGridReadOnly ? true : params.readOnly !== true,
          params.defaultColDef?.editable,
          colDefEditable,
        );
        return {
          // ag-grid warns on unrecognised colDef properties, so it can't be left on the returned colDef
          ...omit(colDef, 'ignoreGridReadOnly'),
          editable,
          cellClassRules: {
            ...colDef.cellClassRules,
            'GridCell-readonly': (ccp: CellClassParams<TData>) =>
              !suppressReadOnlyStyle && !editable(ccp as unknown as EditableCallbackParams<TData>),
          },
        };
      }
    },
    [params.defaultColDef?.editable, params.loading, params.readOnly, suppressReadOnlyStyle],
  );

  /**
   * Add selectable column to colDefs.  Adjust column defs to block fit for auto sized columns.
   */
  const columnDefs = useMemo((): (ColDef | ColGroupDef)[] => {
    return params.columnDefs.map(mapColDef);
  }, [params.columnDefs, mapColDef]);

  const hasExternallySelectedItems = !!setExternalSelectedItems || !!setExternalSelectedIds;

  /**
   * When grid is ready set the apis to the grid context and sync selected items to grid.
   */
  const onGridReady = useCallback(
    (event: GridReadyEvent) => {
      setApis(event.api, hasExternallySelectedItems, enableMultilineBulkEdit, dataTestId);
      event.api.showNoRowsOverlay();
      synchroniseExternallySelectedItemsToGrid();
    },
    [
      dataTestId,
      enableMultilineBulkEdit,
      hasExternallySelectedItems,
      setApis,
      synchroniseExternallySelectedItemsToGrid,
    ],
  );

  /**
   * When the grid is being initialized the data may be empty.
   * This will resize columns when we have at least one row.
   */
  const previousRowDataLength = useRef(0);

  const onRowDataUpdated = useCallback(() => {
    const length = rowData?.length ?? 0;
    if (previousRowDataLength.current !== length) {
      // We need to autosize all cells again
      autoSizeResultRef.current = null;
      previousRowDataLength.current = length;
      void setInitialContentSize();
      return;
    }

    if (lastUpdatedDep.current === updatedDep || isEmpty(colIdsEdited.current)) {
      return;
    }
    lastUpdatedDep.current = updatedDep;

    // Don't update while there are spinners
    if (anyUpdating()) {
      return;
    }

    const skipHeader = sizeColumns === 'auto-skip-headers';
    if ((sizeColumns === 'auto' || sizeColumns === 'auto-skip-headers') && hasSetContentSize.current) {
      void autoSizeColumns({
        skipHeader,
        userSizedColIds: new Set(userSizedColIds.current.keys()),
        colIds: colIdsEdited.current,
      });
    }
    colIdsEdited.current.clear();
  }, [autoSizeColumns, rowData?.length, setInitialContentSize, sizeColumns, updatedDep, anyUpdating]);

  /**
   * Show/hide no rows overlay when model changes.
   */
  const onModelUpdated = useCallback((event: ModelUpdatedEvent) => {
    event.api.showNoRowsOverlay();
  }, []);

  /**
   * Handle double click edit
   */
  const onCellDoubleClick = useCallback(
    (event: CellDoubleClickedEvent) => {
      const cellEditing = event.api.getEditingCells()?.[0];
      if (cellEditing && cellEditing.colId === event.column.getColId() && cellEditing.rowIndex === event.rowIndex) {
        // Ignore double click in already editing cell
        return;
      }
      const editable = fnOrVar(event.colDef?.editable, event);
      if (editable && !invokeEditAction(event)) {
        void startCellEditing({ rowId: event.data.id, colId: event.column.getColId() });
      }
    },
    [startCellEditing],
  );

  const onCellEditingStopped = useCallback(
    (event: AgGridEvent<TData>) => {
      if (!startedEditRef.current) {
        return;
      }
      startedEditRef.current = false;
      const api = event.api;
      // We need to redraw on fit as the updated row heights aren't visible
      if (sizeColumns === 'fit') {
        delay(() => {
          // Don't update if currently editing, that will stop the edit
          if (!anyUpdating() && document.querySelectorAll('.szh-menu--state-open').length === 0) {
            if (!api.isDestroyed()) {
              api.redrawRows();
            }
          }
        }, 500);
      }
    },
    [anyUpdating, sizeColumns],
  );

  /**
   * If cell has an edit action invoke it (if editable)
   */
  const invokeEditAction = (e: CellDoubleClickedEvent | CellKeyDownEvent): boolean => {
    const editAction = e.colDef?.cellRendererParams?.editAction;
    if (!editAction) return false;
    const editable = fnOrVar(e.colDef?.editable, e);
    if (editable) {
      if (!e.node.isSelected()) {
        e.node.setSelected(true, true);
      }
      editAction([e.data, ...e.api.getSelectedRows().filter((row) => row.id !== e.data.id)]);
    }
    return true;
  };

  /**
   * Start editing on pressing Enter
   */
  const onCellKeyPress = useCallback(
    (e: CellKeyDownEvent) => {
      const kbe = e.event as KeyboardEvent;
      if (kbe.key === 'Enter') {
        if (!invokeEditAction(e)) {
          void startCellEditing({ rowId: e.data.id, colId: e.column.getColId() });
        }
      }
      if (kbe.key === 'Tab') {
        prePopupOps();
      }
    },
    [prePopupOps, startCellEditing],
  );

  const { cellContextMenu, contextMenuComponent } = useGridContextMenu({
    contextMenu,
    contextMenuSelectRow,
  });

  const hasSelectedMoreThanOneCellRef = useRef(false);
  const rangeStartRef = useRef<CellLocation | null>(null);
  const rangeEndRef = useRef<CellLocation | null>(null);
  const rangeSortedNodesRef = useRef<IRowNode<TData>[] | null>(null);

  const { clearRangeSelection, ranges, onCellMouseDown, onCellMouseOver } = useGridRangeSelection({
    enableRangeSelection,
    gridDivRef,
    rangeStartRef,
    rangeEndRef,
    hasSelectedMoreThanOneCellRef,
    rangeSortedNodesRef,
  });

  const { onCopyEvent, rangeSelectInterceptContextMenu, rangeSelectContextMenuComponent } = useGridCopy({
    ranges,
    rangeStartRef,
    rangeEndRef,
    hasSelectedMoreThanOneCellRef,
    cellContextMenu,
  });

  const onSortChanged = useCallback(() => {
    clearRangeSelection();
    ensureSelectedRowIsVisible();
  }, [clearRangeSelection, ensureSelectedRowIsVisible]);

  /**
   * Handle single click edits
   */
  const onCellClicked = useCallback(
    (event: CellClickedEvent) => {
      if (enableRangeSelection && rangeStartRef.current && rangeEndRef.current) {
        // This is to detect difference between a single click and a click drag return to cell.
        const maxDistanceBetweenC = Math.max(
          Math.abs(rangeEndRef.current.clickLocation[0] - rangeStartRef.current.clickLocation[0]),
          Math.abs(rangeEndRef.current.clickLocation[1] - rangeStartRef.current.clickLocation[1]),
        );
        if (maxDistanceBetweenC > 8) {
          return;
        }
        clearRangeSelection();
      }

      const editable = fnOrVar(event.colDef?.editable, event);
      if ((editable && event.colDef.singleClickEdit) ?? singleClickEdit) {
        const editingCell = event.api.getEditingCells()[0];
        // If we are editing and single click we don't want the editing  to stop
        if (editingCell && editingCell.colId === event.column.getColId() && editingCell.rowIndex === event.rowIndex) {
          return;
        }
        event.api.setFocusedCell(event.rowIndex ?? 0, event.column.getColId(), event.rowPinned);
        void startCellEditing({ rowId: event.data.id, colId: event.column.getColId() });
      }
    },
    [clearRangeSelection, enableRangeSelection, singleClickEdit, startCellEditing],
  );

  /**
   * Set of column I'd's that are prevented from auto-sizing as they are user set
   */
  const userSizedColIds = useRef(new Map<string, number>());

  /**
   * Lock/unlock column width on user edit/reset.
   */
  const onColumnResized = useCallback((e: ColumnResizedEvent) => {
    const colId = e.column?.getColId();
    if (colId == null) {
      return;
    }
    const width = e.column?.getActualWidth();
    if (width == null) {
      return;
    }
    switch (e.source) {
      case 'uiColumnResized':
        userSizedColIds.current.set(colId, width);
        break;
      case 'autosizeColumns':
        userSizedColIds.current.delete(colId);
        break;
    }
  }, []);

  const columnMoved = useCallback(
    (e: ColumnMovedEvent<TData>) => {
      clearRangeSelection();
      onColumnMoved?.(e);
    },
    [clearRangeSelection, onColumnMoved],
  );

  /**
   * Once the grid has auto-sized we want to run fit to fit the grid in its container,
   * but we don't want the non-flex auto-sized columns to "fit" size, so suppressSizeToFit is set to true.
   */
  const columnDefsAdjusted = useMemo(() => {
    const adjustColDefOrGroup = (colDef: ColDef<TData> | ColGroupDef<TData>) =>
      'children' in colDef ? adjustGroupColDef(colDef) : adjustColDef(colDef);

    const adjustGroupColDef = (colDef: ColGroupDef<TData>): ColGroupDef<TData> => ({
      ...colDef,
      children: colDef.children.map((colDef) => adjustColDefOrGroup(colDef)),
    });

    const adjustColDef = (colDef: ColDef<TData>): ColDef<TData> => {
      const flex = colDef.flex ?? (sizeColumns === 'fit' ? 1 : undefined);
      const valueFormatter = colDef.valueFormatter;
      const sortable = colDef.sortable && params.defaultColDef?.sortable !== false;
      let comparator = colDef.comparator;
      if (sortable && !comparator) {
        comparator = (value1, value2, node1, node2) => {
          let r;
          if (typeof valueFormatter === 'function') {
            r = compareNaturalInsensitive(
              valueFormatter({
                data: node1.data,
                value: value1,
                node: node1,
                colDef: adjustedColDef,
                ...NotAGridValueFormatterCall,
              } as ValueFormatterParams<TData>),
              valueFormatter({
                data: node2.data,
                value: value2,
                node: node2,
                colDef: adjustedColDef,
                ...NotAGridValueFormatterCall,
              } as ValueFormatterParams<TData>),
            );
          } else {
            r = compareNaturalInsensitive(value1, value2);
          }
          return r;
        };
      }
      const adjustedColDef = {
        ...colDef,
        // You cannot pass a width to a flex
        width: colDef.flex ? undefined : colDef.width,
        ...(!!colDef.flex && { flexAutoSizeWidth: colDef.width }),
        // If this is allowed flex columns don't size based on flex
        suppressSizeToFit: true,
        // Auto-sizing flex columns breaks everything
        flex,
        suppressAutoSize: !!flex,
        sortable,
        comparator,
      } as ColDef<TData> & { flexAutoSizeWidth?: number };

      return adjustedColDef;
    };

    return columnDefs.map((colDef) => adjustColDefOrGroup(colDef));
  }, [columnDefs, params.defaultColDef?.sortable, sizeColumns]);

  /**
   * Set of colIds that need auto-sizing.
   */
  const colIdsEdited = useRef(new Set<string>());
  const lastUpdatedDep = useRef(updatedDep);

  /**
   * When cell editing has completed the colId as needing auto-sizing
   */
  useEffect(() => {
    if (lastUpdatedDep.current === updatedDep) {
      return;
    }
    lastUpdatedDep.current = updatedDep;
    const colIds = updatingCols();
    // Updating possibly completed
    if (isEmpty(colIds)) {
      // Columns to resize?
      if (!isEmpty(colIdsEdited.current)) {
        const skipHeader = sizeColumns === 'auto-skip-headers';
        if (sizeColumns === 'auto' || skipHeader) {
          defer(() => {
            if (hasSetContentSize.current) {
              void autoSizeColumns({
                skipHeader,
                userSizedColIds: new Set(userSizedColIds.current.keys()),
                colIds: colIdsEdited.current,
              });
            }
          });
        }
        colIdsEdited.current.clear();
      }
    } else {
      // Updates not complete, add them to a list of columns needing resize
      colIds.forEach((colId) => {
        if (colId && !getColDef(colId)?.flex) {
          colIdsEdited.current.add(colId);
        }
      });
    }
  }, [autoSizeColumns, getColDef, sizeColumns, updatedDep, updatingCols]);

  const prevLoading = useRef(false);
  useEffect(() => {
    const newLoading = !rowData || params.loading === true;
    if (newLoading && !prevLoading.current) {
      showNoRowsOverlay();
    }
    prevLoading.current = newLoading;
  }, [params.loading, rowData, showNoRowsOverlay]);

  const startDragYRef = useRef<number | null>(null);

  const clearHighlightRowClasses = useCallback(() => {
    document.querySelectorAll(`.ag-row-highlight-above`)?.forEach((el) => {
      el.classList.remove('ag-row-highlight-above');
    });
    document.querySelectorAll(`.ag-row-highlight-below`)?.forEach((el) => {
      el.classList.remove('ag-row-highlight-below');
    });
  }, []);

  const clearDragRowClasses = useCallback(() => {
    document.querySelectorAll(`.ag-row-dragging`)?.forEach((el) => {
      el.classList.remove('ag-row-dragging');
    });
  }, []);

  const gridElementRef = useRef<Element>(undefined);
  const multiDragAppliedRef = useRef(false);
  const multiDragCountRef = useRef(0);
  const onRowDragMove = useCallback(
    (event: RowDragMoveEvent) => {
      if (startDragYRef.current === null) {
        startDragYRef.current = event.y;

        // On first move event, apply ag-row-dragging class to all selected rows for multi-drag
        if (rowSelection === 'multiple' && event.node.isSelected()) {
          const selectedNodes = event.api.getSelectedNodes().filter((n) => n.displayed && n.data != null);
          if (selectedNodes.length > 1) {
            multiDragAppliedRef.current = true;
            multiDragCountRef.current = selectedNodes.length;
            // Find the grid body element for scoped queries
            const targetEl = event.event.target as Element | undefined;
            const gridElement = targetEl?.closest('.ag-body');
            if (gridElement) {
              gridElementRef.current = gridElement;
              for (const node of selectedNodes) {
                gridElement.querySelectorAll(`[row-id='${node.data.id}']`)?.forEach((el) => {
                  el.classList.add('ag-row-dragging');
                });
              }
            }
          }
        }
      }

      const yDiff = event.y - startDragYRef.current;
      const data = event.overNode?.data;
      if (data) {
        clearHighlightRowClasses();
        // Find the grid element, this can only be found on start drag.
        // Once dragging is no progress the event target is the drag element not the start drag column.
        const targetEl = event.event.target as Element | undefined;
        if (targetEl) {
          const gridElement = targetEl.closest('.ag-body');
          if (gridElement) {
            gridElementRef.current = gridElement;
          }
        }
        gridElementRef.current?.querySelectorAll(`[row-id='${data.id}']`)?.forEach((el) => {
          el.classList.add(yDiff < 0 ? 'ag-row-highlight-above' : 'ag-row-highlight-below');
        });
      }
    },
    [clearHighlightRowClasses, rowSelection],
  );

  const onCellFocused = useCallback(
    (event: CellFocusedEvent<TData>) => {
      if (event.rowIndex == null) {
        return;
      }
      const api = event.api;
      const rowNode = api.getDisplayedRowAtIndex(event.rowIndex);
      const data = rowNode?.data;
      const column = event.column;
      if (!data || !column || typeof column === 'string') {
        return;
      }
      const colDef = column.getColDef();
      if (!colDef || typeof colDef === 'string') {
        return;
      }
      // Prevent repeated callbacks to cell focus when focus didn't change
      const { sourceEvent } = event;
      if (sourceEvent) {
        const cell = (sourceEvent.target as Element | undefined)?.closest?.('.ag-cell') ?? null;
        if ((window as any).__stepaggrid_lastfocuseventtarget === cell) {
          return;
        }
        (window as any).__stepaggrid_lastfocuseventtarget = cell;
      }

      paramsOnCellFocused?.({ colDef, data });
    },
    [paramsOnCellFocused],
  );

  const onRowDragEnd = useCallback(
    (event: RowDragEndEvent<TData>) => {
      clearHighlightRowClasses();
      clearDragRowClasses();
      multiDragAppliedRef.current = false;
      multiDragCountRef.current = 0;
      gridElementRef.current = undefined;
      if (!params.onRowDragEnd || startDragYRef.current === null) {
        return;
      }
      const yDiff = event.y - startDragYRef.current;
      startDragYRef.current = null;
      if (event.node.rowIndex != null) {
        const movedRow = event.node.data;
        const targetRow = event.overNode?.data;
        if (!movedRow || !targetRow || movedRow === targetRow || yDiff === 0) {
          return;
        }

        // Determine movedRows: if dragged row is part of a multi-selection, include all selected & displayed rows
        let movedRows: TData[];
        if (rowSelection === 'multiple' && event.node.isSelected()) {
          movedRows = event.api
            .getSelectedNodes()
            .filter((n) => n.displayed && n.data != null)
            .sort((a, b) => (a.rowIndex ?? 0) - (b.rowIndex ?? 0))
            .map((n) => n.data as TData);
        } else {
          movedRows = [movedRow];
        }

        // If targetRow is one of the moved rows, no-op
        if (movedRows.some((r) => r.id === targetRow.id)) {
          return;
        }

        const direction: -1 | 1 = yDiff > 0 ? 1 : -1;
        void params.onRowDragEnd({ movedRow, movedRows, targetRow, direction });
      }
    },
    [params, clearHighlightRowClasses, clearDragRowClasses, rowSelection],
  );

  useEffect(
    () => {
      if ((setExternalSelectedItems || setExternalSelectedIds) && selectable == null) {
        console.warn(
          '<Grid/> has setExternalSelectedItems/setExternalSelectedIds parameter, but is missing selectable parameter,' +
            'this will cause weird delays in editing.\nIf you need to hide selection column use hideSelectColumn=true',
        );
      }
    },
    // Only needs to run on startup
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [selectable],
  );

  // This is setting a ref in the GridContext so won't be triggering an update loop
  setOnBulkEditingComplete(params.onBulkEditingComplete);

  const getRowId = useCallback((params: GetRowIdParams<TData, unknown>) => `${params.data.id}`, []);
  const defaultColDef = useMemo(
    (): ColDef<TData, unknown> => ({
      minWidth: 48,
      ...omit(params.defaultColDef, ['editable', 'field', 'colId', 'tooltipField']),
    }),
    [params.defaultColDef],
  );

  const noRowsOverlayComponent = useCallback(
    (event: AgGridEvent) => {
      const headerRowCount = columnDefs.some((c) => (c as any).children) ? 2 : 1;

      let rowCount = 0;
      event.api.forEachNode(() => rowCount++);
      return (
        <GridNoRowsOverlay
          loading={!rowData || params.loading === true}
          rowCount={rowCount}
          headerRowHeight={headerRowCount * rowHeight}
          filteredRowCount={event.api.getDisplayedRowCount()}
          noRowsOverlayText={params.noRowsOverlayText}
          noRowsMatchingOverlayText={params.noRowsMatchingOverlayText}
        />
      );
    },
    [columnDefs, params.loading, params.noRowsMatchingOverlayText, params.noRowsOverlayText, rowData, rowHeight],
  );

  const selectionColumnDef = useMemo((): SelectionColumnDef | undefined => {
    // Note this has to be 1 for hidden otherwise ag-grid does crazy things whilst resizing
    const selectWidth = params.onRowDragEnd ? 76 : 48;
    return {
      suppressNavigable: params.hideSelectColumn,
      rowDrag: !!params.onRowDragEnd,
      minWidth: selectWidth,
      maxWidth: selectWidth,
      pinned: selectColumnPinned,
      headerComponentParams: {
        exportable: false,
      },
      suppressAutoSize: true,
      suppressSizeToFit: true,
      headerClass: clsx('ag-header-hide-default-select', params.onRowDragEnd && 'ag-header-select-draggable'),
      headerComponent: rowSelection == 'multiple' ? GridHeaderSelect : undefined,
      suppressHeaderKeyboardEvent: (e) => {
        if (!selectable) return false;
        if ((e.event.key === 'Enter' || e.event.key === ' ') && !e.event.repeat) {
          if (isEmpty(e.api.getSelectedRows())) {
            e.api.selectAll('filtered');
          } else {
            e.api.deselectAll();
          }
          return true;
        }
        return false;
      },
      // enableClickSelection = true means ag-grid auto selects row if you click outside the checkbox
      // if it's false we have to do it.
      onCellClicked: params.enableClickSelection ? undefined : clickInputWhenContainingCellClicked,
    };
  }, [
    params.hideSelectColumn,
    params.onRowDragEnd,
    rowSelection,
    selectColumnPinned,
    selectable,
    params.enableClickSelection,
  ]);

  const onGridSizeChanged = useCallback(
    (event: GridSizeChangedEvent<TData>) => {
      if (sizeColumns === 'fit' || (['auto', 'auto-skip-headers'].includes(sizeColumns) && hasSetContentSize.current)) {
        event.api.sizeColumnsToFit();
      }
    },
    [sizeColumns],
  );

  return (
    <div
      data-testid={dataTestId}
      className={clsx(
        'Grid-container',
        theme,
        'theme-specific',
        staleGrid && 'Grid-sortIsStale',
        gridReady && rowData && autoSized && 'Grid-ready',
        params.hideSelectedRow && 'Grid-hideSelectedRow',
      )}
    >
      {contextMenuComponent}
      {rangeSelectContextMenuComponent}
      <div style={{ flex: 1 }} ref={gridDivRef} onCopy={onCopyEvent}>
        <AgGridReact
          theme={'legacy'}
          domLayout={params.domLayout}
          defaultColDef={defaultColDef}
          columnDefs={columnDefsAdjusted}
          getRowId={getRowId}
          rowData={rowData}
          alwaysShowVerticalScroll={params.alwaysShowVerticalScroll}
          animateRows={params.animateRows ?? false}
          doesExternalFilterPass={doesExternalFilterPass}
          isExternalFilterPresent={isExternalFilterPresent}
          maintainColumnOrder={true}
          noRowsOverlayComponent={noRowsOverlayComponent}
          onCellClicked={onCellClicked}
          onCellContextMenu={rangeSelectInterceptContextMenu}
          onCellDoubleClicked={onCellDoubleClick}
          onCellEditingStopped={onCellEditingStopped}
          onCellFocused={onCellFocused}
          onCellKeyDown={onCellKeyPress}
          onCellMouseDown={onCellMouseDown}
          onCellMouseOver={onCellMouseOver}
          onColumnMoved={columnMoved}
          onColumnResized={onColumnResized}
          onColumnVisible={() => void setInitialContentSize()}
          onGridReady={onGridReady}
          onGridSizeChanged={onGridSizeChanged}
          onModelUpdated={onModelUpdated}
          onRowClicked={params.onRowClicked}
          onRowDataUpdated={onRowDataUpdated}
          onRowDoubleClicked={params.onRowDoubleClicked}
          onRowDragCancel={() => {
            clearHighlightRowClasses();
            clearDragRowClasses();
            multiDragAppliedRef.current = false;
            multiDragCountRef.current = 0;
          }}
          onRowDragEnd={onRowDragEnd}
          onRowDragMove={onRowDragMove}
          onSelectionChanged={synchroniseExternalStateToGridSelection}
          onSortChanged={onSortChanged}
          pinnedBottomRowData={params.pinnedBottomRowData}
          pinnedTopRowData={params.pinnedTopRowData}
          postSortRows={params.onRowDragEnd || !defaultPostSort ? undefined : postSortRows}
          preventDefaultOnContextMenu={true}
          quickFilterParser={quickFilterParser}
          rowClassRules={params.rowClassRules}
          rowDragText={
            params.rowDragText ??
            (rowSelection === 'multiple'
              ? (dragItem) => {
                  const count = multiDragCountRef.current;
                  if (count > 1) return `Moving ${count} rows`;
                  return `${dragItem.rowNode?.data?.id ?? ''}`;
                }
              : undefined)
          }
          rowHeight={rowHeight}
          rowSelection={
            selectable
              ? {
                  enableSelectionWithoutKeys: params.enableSelectionWithoutKeys ?? false,
                  enableClickSelection: params.enableClickSelection ?? false,
                  mode: rowSelection === 'single' ? 'singleRow' : 'multiRow',
                  ...(params.hideSelectColumn && {
                    checkboxes: false,
                    headerCheckbox: false,
                  }),
                }
              : undefined
          }
          selectionColumnDef={selectionColumnDef}
          suppressCellFocus={params.suppressCellFocus}
          suppressClickEdit={true}
          suppressColumnVirtualisation={suppressColumnVirtualization}
          suppressStartEditOnTab={true}
        />
      </div>
    </div>
  );
};

const quickFilterParser = (filterStr: string) => {
  // filter is exact matches exactly groups separated by commas
  return filterStr.split(',').map((str) => str.trim());
};

/**
 * Columns to indicate to user when they debug why things are broken in the default comparator if their valueFormatter
 * is too complicated.
 */
const NotAGridValueFormatterCall = {
  column: 'Default comparator has no access to column, write your own comparator' as unknown,
  api: 'Default comparator has no access to api, write your own comparator' as unknown,
  context: 'Default comparator has no access to context, write your own comparator' as unknown,
};
