import { AbstractCellClipboardHandler } from "../../clipboard_handlers/abstract_cell_clipboard_handler";
import { SELECTION_BORDER_COLOR } from "../../constants";
import { getClipboardDataPositions } from "../../helpers/clipboard/clipboard_helpers";
import { clip, deepCopy, range } from "../../helpers/misc";
import { createRange, isFullColRange, isFullRowRange } from "../../helpers/range";
import { getFullReference, splitReference } from "../../helpers/references";
import {
  isEqual,
  isInside,
  isZoneInside,
  overlap,
  positionToZone,
  splitZone,
  uniqueZones,
  updateSelectionOnDeletion,
  updateSelectionOnInsertion,
} from "../../helpers/zones";
import { clipboardHandlersRegistries } from "../../registries/clipboardHandlersRegistries";
import { EvaluatedCell } from "../../types/cells";
import { ClientPosition } from "../../types/collaborative/session";
import {
  AddColumnsRowsCommand,
  Command,
  CommandResult,
  CreateFigureCommand,
  DeleteFigureCommand,
  LocalCommand,
  MoveColumnsRowsCommand,
  RemoveColumnsRowsCommand,
  UpdateFigureCommand,
} from "../../types/commands";
import { SelectionEvent } from "../../types/event_stream/selection_events";
import {
  AnchorZone,
  CellPosition,
  Dimension,
  HeaderIndex,
  Pixel,
  Selection,
  Sheet,
  Style,
  UID,
  UnboundedZone,
  Zone,
} from "../../types/misc";
import { Range } from "../../types/range";
import { GridRenderingContext } from "../../types/rendering";
import { UIPlugin, UIPluginConfig } from "../ui_plugin";

interface SheetInfo {
  gridSelection: Selection;
}

export interface SelectionState {
  sheetId: UID;
  selectedZones: Zone[];
  activeCols: Set<number>;
  activeRows: Set<number>;
  activePosition: CellPosition | undefined;
}

/**
 * SelectionPlugin
 */
export class GridSelectionPlugin extends UIPlugin {
  static layers = ["Selection"] as const;
  static getters = [
    "getActiveSheet",
    "getActiveSheetId",
    "getActiveSheetName",
    "getActiveCell",
    "getActiveCols",
    "getActiveRows",
    "getCurrentStyle",
    "getSelectedZones",
    "getSelectedZone",
    "getSelectedCells",
    "getSelectedFigureIds",
    "getSelection",
    "getActivePosition",
    "getSheetPosition",
    "isSingleColSelected",
    "getElementsFromSelection",
    "tryGetActiveSheetId",
    "isGridSelectionActive",
    "getSelectecUnboundedZone",
    "getSelectionRangeString",
    "getSelectionState",
  ] as const;

  private gridSelection: {
    anchor: AnchorZone;
    zones: Zone[];
  } = {
    anchor: {
      cell: { col: 0, row: 0 },
      zone: { top: 0, left: 0, bottom: 0, right: 0 },
    },
    zones: [{ top: 0, left: 0, bottom: 0, right: 0 }],
  };
  private selectedFiguresIds: UID[] = [];
  private sheetsData: { [sheet: string]: SheetInfo } = {};
  private moveClient: (position: ClientPosition) => void;
  private isUnbounded: boolean;
  private currentStyle: Style | undefined = undefined;

  // This flag is used to avoid to historize the ACTIVE_SHEET command when it's
  // the main command.

  activeSheet: Sheet = null as any;

  constructor(config: UIPluginConfig) {
    super(config);
    this.moveClient = config.moveClient;
    this.isUnbounded = false;
  }

  // ---------------------------------------------------------------------------
  // Command Handling
  // ---------------------------------------------------------------------------

  allowDispatch(cmd: LocalCommand): CommandResult {
    switch (cmd.type) {
      case "ACTIVATE_SHEET":
        try {
          const sheet = this.getters.getSheet(cmd.sheetIdTo);
          if (!sheet.isVisible) {
            return CommandResult.SheetIsHidden;
          }
          break;
        } catch (error) {
          return CommandResult.InvalidSheetId;
        }
      case "MOVE_COLUMNS_ROWS":
        return this.isMoveElementAllowed(cmd);
    }
    return CommandResult.Success;
  }

  private handleEvent(event: SelectionEvent) {
    let anchor = event.anchor;
    let zones: Zone[] = [...this.gridSelection.zones];

    this.isUnbounded = event.options?.unbounded || false;

    switch (event.mode) {
      case "overrideSelection":
        zones = [anchor.zone];
        break;
      case "updateAnchor":
        const index = zones.findIndex((z: Zone) => isEqual(z, event.previousAnchor.zone));
        if (index >= 0) {
          zones[index] = anchor.zone;
        }
        break;
      case "newAnchor":
        zones.push(anchor.zone);
        break;
      case "commitSelection":
        const zoneToSplit = zones.find(
          (zone) => isZoneInside(anchor.zone, zone) && !isEqual(anchor.zone, zone)
        );
        const removeFullAnchor = zones.filter((zone) => isEqual(anchor.zone, zone)).length > 1;
        if (removeFullAnchor && zones.length > 2) {
          zones = zones.filter((zone) => !isEqual(zone, anchor.zone));
        } else if (zoneToSplit) {
          const splittedZones = splitZone(anchor.zone, zoneToSplit);
          zones = zones
            .filter((z) => !isEqual(z, anchor.zone) && !isEqual(z, zoneToSplit))
            .concat(splittedZones);
        }
        zones = uniqueZones(zones);

        const lastZone = zones[zones.length - 1];
        const isAnchorCellInLastZone = isInside(anchor.cell.col, anchor.cell.row, lastZone);
        anchor = {
          cell: isAnchorCellInLastZone ? anchor.cell : { col: lastZone.left, row: lastZone.top },
          zone: lastZone,
        };
        break;
    }
    this.setSelectionMixin(anchor, zones);
    /** Any change to the selection has to be reflected in the selection processor. */
    this.selection.resetDefaultAnchor(this, deepCopy(this.gridSelection.anchor));
    const { col, row } = this.gridSelection.anchor.cell;
    this.moveClient({
      sheetId: this.getters.getActiveSheetId(),
      col,
      row,
    });
    this.selectedFiguresIds = [];
    this.currentStyle = undefined;
  }

  handle(cmd: Command) {
    switch (cmd.type) {
      case "DELETE_FIGURE":
        this.selectedFiguresIds = this.selectedFiguresIds.filter((id) => id !== cmd.figureId);
        break;
      case "START":
        const firstSheetId = this.getters.getVisibleSheetIds()[0];
        this.activateSheet(firstSheetId, firstSheetId);
        const { col, row } = this.getters.getNextVisibleCellPosition({
          sheetId: firstSheetId,
          col: 0,
          row: 0,
        });
        this.selectCell(col, row);
        this.selection.registerAsDefault(this, this.gridSelection.anchor, {
          handleEvent: this.handleEvent.bind(this),
        });
        this.moveClient({ sheetId: firstSheetId, col: 0, row: 0 });
        break;
      case "ACTIVATE_SHEET": {
        this.activateSheet(cmd.sheetIdFrom, cmd.sheetIdTo);
        break;
      }
      case "REMOVE_COLUMNS_ROWS": {
        const sheetId = this.getters.getActiveSheetId();
        if (cmd.sheetId === sheetId) {
          if (cmd.dimension === "COL") {
            this.onColumnsRemoved(cmd);
          } else {
            this.onRowsRemoved(cmd);
          }
          const { col, row } = this.gridSelection.anchor.cell;
          this.moveClient({ sheetId, col, row });
        }
        break;
      }
      case "ADD_COLUMNS_ROWS": {
        const sheetId = this.getters.getActiveSheetId();
        if (cmd.sheetId === sheetId) {
          this.onAddElements(cmd);
          const { col, row } = this.gridSelection.anchor.cell;
          this.moveClient({ sheetId, col, row });
        }
        break;
      }
      case "MOVE_COLUMNS_ROWS":
        if (cmd.sheetId === this.getActiveSheetId()) {
          this.onMoveElements(cmd);
        }
        break;
      case "SELECT_FIGURE":
        if (cmd.selectMultiple) {
          if (cmd.figureId) {
            this.selectedFiguresIds = this.selectedFiguresIds.filter((id) => id !== cmd.figureId);
            this.selectedFiguresIds.unshift(cmd.figureId);
          }
        } else {
          this.selectedFiguresIds = cmd.figureId ? [cmd.figureId] : [];
        }
        break;
      case "UNSELECT_FIGURE":
        this.selectedFiguresIds = this.selectedFiguresIds.filter((id) => id !== cmd.figureId);
        break;
      case "ACTIVATE_NEXT_SHEET":
        this.activateNextSheet("right");
        break;
      case "ACTIVATE_PREVIOUS_SHEET":
        this.activateNextSheet("left");
        break;
      case "HIDE_SHEET":
        if (cmd.sheetId === this.getActiveSheetId()) {
          this.dispatch("ACTIVATE_SHEET", {
            sheetIdFrom: cmd.sheetId,
            sheetIdTo: this.getters.getVisibleSheetIds()[0],
          });
        }
        break;
      case "UNDO":
      case "REDO":
      case "DELETE_SHEET":
        const deletedSheetIds = Object.keys(this.sheetsData).filter(
          (sheetId) => !this.getters.tryGetSheet(sheetId)
        );
        for (const sheetId of deletedSheetIds) {
          delete this.sheetsData[sheetId];
        }
        for (const sheetId in this.sheetsData) {
          const gridSelection = this.clipSelection(sheetId, this.sheetsData[sheetId].gridSelection);
          this.sheetsData[sheetId] = {
            gridSelection: deepCopy(gridSelection),
          };
        }
        this.fallbackToVisibleSheet();
        const sheetId = this.getters.getActiveSheetId();
        this.gridSelection.zones = this.gridSelection.zones.map((z) =>
          this.getters.expandZone(sheetId, z)
        );
        this.gridSelection.anchor.zone = this.getters.expandZone(
          sheetId,
          this.gridSelection.anchor.zone
        );
        this.setSelectionMixin(this.gridSelection.anchor, this.gridSelection.zones);
        if (cmd.type === "UNDO") {
          this.selectedFiguresIds = cmd.commands
            .filter(
              (cmd): cmd is DeleteFigureCommand =>
                cmd.type === "DELETE_FIGURE" && cmd.sheetId === sheetId
            )
            .map((cmd) => cmd.figureId);
        } else if (cmd.type === "REDO") {
          this.selectedFiguresIds = cmd.commands
            .filter(
              (cmd): cmd is CreateFigureCommand =>
                cmd.type === "CREATE_FIGURE" && cmd.sheetId === sheetId
            )
            .map((cmd) => cmd.figureId);
        } else {
          this.selectedFiguresIds = [];
        }
        break;
    }
  }

  finalize(): void {
    this.fallbackToVisibleSheet();
    this.currentStyle = undefined;
    /** Any change to the selection has to be  reflected in the selection processor. */
    this.selection.resetDefaultAnchor(this, deepCopy(this.gridSelection.anchor));
  }

  // ---------------------------------------------------------------------------
  // Getters
  // ---------------------------------------------------------------------------

  isGridSelectionActive(): boolean {
    return this.selection.isListening(this);
  }

  getActiveSheet(): Readonly<Sheet> {
    return this.activeSheet;
  }

  getActiveSheetId(): UID {
    return this.activeSheet.id;
  }

  getActiveSheetName(): string {
    return this.activeSheet.name;
  }

  tryGetActiveSheetId(): UID | undefined {
    return this.activeSheet?.id;
  }

  getActiveCell(): EvaluatedCell {
    return this.getters.getEvaluatedCell(this.getActivePosition());
  }

  getActiveCols(): Set<number> {
    const activeCols = new Set<number>();
    for (const zone of this.gridSelection.zones) {
      if (
        zone.top === 0 &&
        zone.bottom === this.getters.getNumberRows(this.getters.getActiveSheetId()) - 1
      ) {
        for (let i = zone.left; i <= zone.right; i++) {
          activeCols.add(i);
        }
      }
    }
    return activeCols;
  }

  getActiveRows(): Set<number> {
    const activeRows = new Set<number>();
    const sheetId = this.getters.getActiveSheetId();
    for (const zone of this.gridSelection.zones) {
      if (zone.left === 0 && zone.right === this.getters.getNumberCols(sheetId) - 1) {
        for (let i = zone.top; i <= zone.bottom; i++) {
          activeRows.add(i);
        }
      }
    }
    return activeRows;
  }

  getCurrentStyle(): Style {
    if (!this.currentStyle) {
      const zone = this.getters.getSelectedZone();
      const sheetId = this.getters.getActiveSheetId();
      this.currentStyle = this.getters.getCellStyle({
        sheetId,
        col: zone.left,
        row: zone.top,
      });

      if (!this.currentStyle.fillColor) {
        const sheet = this.getters.getSheet(sheetId);
        this.currentStyle = { ...this.currentStyle, fillColor: sheet.backgroundColor };
      }
    }
    return this.currentStyle;
  }

  getSelectedZones(): Zone[] {
    return deepCopy(this.gridSelection.zones);
  }

  getSelectedZone(): Zone {
    return deepCopy(this.gridSelection.anchor.zone);
  }

  getSelectecUnboundedZone(): UnboundedZone {
    const zone = this.isUnbounded
      ? this.getters.getUnboundedZone(this.activeSheet.id, this.gridSelection.anchor.zone)
      : this.gridSelection.anchor.zone;
    return deepCopy(zone);
  }

  getSelection(): Selection {
    return deepCopy(this.gridSelection);
  }

  getSelectedCells(): EvaluatedCell[] {
    const sheetId = this.getters.getActiveSheetId();
    const cells: EvaluatedCell[] = [];
    for (const zone of this.gridSelection.zones) {
      cells.push(...this.getters.getEvaluatedCellsInZone(sheetId, zone));
    }
    return cells;
  }

  getSelectedFigureIds(): UID[] {
    return this.selectedFiguresIds;
  }

  getActivePosition(): CellPosition {
    return this.getters.getMainCellPosition({
      sheetId: this.getActiveSheetId(),
      col: this.gridSelection.anchor.cell.col,
      row: this.gridSelection.anchor.cell.row,
    });
  }

  getSheetPosition(sheetId: UID): CellPosition {
    if (sheetId === this.getters.getActiveSheetId()) {
      return this.getActivePosition();
    } else {
      const sheetData = this.sheetsData[sheetId];
      return sheetData
        ? {
            sheetId,
            col: sheetData.gridSelection.anchor.cell.col,
            row: sheetData.gridSelection.anchor.cell.row,
          }
        : this.getters.getNextVisibleCellPosition({ sheetId, col: 0, row: 0 });
    }
  }

  isSingleColSelected() {
    const selection = this.getters.getSelectedZones();
    if (selection.length !== 1 || selection[0].left !== selection[0].right) {
      return false;
    }
    return true;
  }

  /**
   * Returns a sorted array of indexes of all columns (respectively rows depending
   * on the dimension parameter) intersected by the currently selected zones.
   *
   * example:
   * assume selectedZones: [{left:0, right: 2, top :2, bottom: 4}, {left:5, right: 6, top :3, bottom: 5}]
   *
   * if dimension === "COL" => [0,1,2,5,6]
   * if dimension === "ROW" => [2,3,4,5]
   */
  getElementsFromSelection(dimension: Dimension): number[] {
    if (dimension === "COL" && this.getters.getActiveCols().size === 0) {
      return [];
    }
    if (dimension === "ROW" && this.getters.getActiveRows().size === 0) {
      return [];
    }
    const zones = this.getters.getSelectedZones();
    let elements: number[] = [];
    const start: "left" | "top" = dimension === "COL" ? "left" : "top";
    const end: "right" | "bottom" = dimension === "COL" ? "right" : "bottom";
    for (const zone of zones) {
      const zoneRows = Array.from(
        { length: zone[end] - zone[start] + 1 },
        (_, i) => zone[start] + i
      );
      elements = elements.concat(zoneRows);
    }
    return [...new Set(elements)].sort();
  }

  /**
   * Same as `getRangeString` but add:
   * - all necessary merge to the range to make it a valid selection
   * - the "#" suffix if the range is a spilled reference if `allowSpilledReferences` is true
   */
  getSelectionRangeString(
    range: Range,
    forSheetId: UID,
    { allowSpilledReferences } = { allowSpilledReferences: false }
  ): string {
    const expandedZone = this.getters.expandZone(range.sheetId, range.zone);
    const expandedRange = createRange(
      {
        ...range,
        zone: {
          ...expandedZone,
          bottom: isFullColRange(range) ? undefined : expandedZone.bottom,
          right: isFullRowRange(range) ? undefined : expandedZone.right,
        },
      },
      this.getters.getSheetSize
    );
    const rangeString = this.getters.getRangeString(expandedRange, forSheetId);
    if (this.getters.isSingleCellOrMerge(range.sheetId, range.zone)) {
      const { sheetName, xc } = splitReference(rangeString);
      return getFullReference(sheetName, xc.split(":")[0]);
    }

    if (allowSpilledReferences) {
      const spreaderPosition = this.getters.getArrayFormulaSpreadingOn({
        sheetId: expandedRange.sheetId,
        col: expandedRange.zone.left,
        row: expandedRange.zone.top,
      });

      const spreadZone =
        spreaderPosition &&
        this.getters.getSpreadZone(spreaderPosition, { ignoreSpillError: true });
      if (spreadZone && isEqual(spreadZone, expandedRange.zone)) {
        const { sheetName, xc } = splitReference(rangeString);
        return getFullReference(sheetName, xc.split(":")[0]) + "#";
      }
    }

    return rangeString;
  }
  // ---------------------------------------------------------------------------
  // Other
  // ---------------------------------------------------------------------------

  private activateSheet(sheetIdFrom: UID, sheetIdTo: UID) {
    this.setActiveSheet(sheetIdTo);
    this.sheetsData[sheetIdFrom] = {
      gridSelection: deepCopy(this.gridSelection),
    };
    this.selectedFiguresIds = [];
    if (sheetIdTo in this.sheetsData) {
      Object.assign(this, this.sheetsData[sheetIdTo]);
      this.selection.resetDefaultAnchor(this, deepCopy(this.gridSelection.anchor));
    } else {
      const { col, row } = this.getters.getNextVisibleCellPosition({
        sheetId: sheetIdTo,
        col: 0,
        row: 0,
      });
      this.selectCell(col, row);
    }
    const { col, row } = this.gridSelection.anchor.cell;
    this.moveClient({ sheetId: this.activeSheet.id, col, row });
  }

  /**
   * Ensure selections are not outside sheet boundaries.
   * They are clipped to fit inside the sheet if needed.
   */
  private setSelectionMixin(anchor: AnchorZone, zones: Zone[]) {
    const { anchor: clippedAnchor, zones: clippedZones } = this.clipSelection(
      this.getters.getActiveSheetId(),
      { anchor, zones }
    );
    this.gridSelection.anchor = clippedAnchor;
    this.gridSelection.zones = clippedZones;
  }
  /**
   * Change the anchor of the selection active cell to an absolute col and row index.
   *
   * This is a non trivial task. We need to stop the editing process and update
   * properly the current selection.  Also, this method can optionally create a new
   * range in the selection.
   */
  private selectCell(col: HeaderIndex, row: HeaderIndex) {
    const sheetId = this.getters.getActiveSheetId();
    const zone = this.getters.expandZone(sheetId, { left: col, right: col, top: row, bottom: row });
    this.setSelectionMixin({ zone, cell: { col, row } }, [zone]);
  }

  private setActiveSheet(id: UID) {
    this.activeSheet = this.getters.getSheet(id);
  }

  private activateNextSheet(direction: "left" | "right") {
    const sheetIds = this.getters.getSheetIds();
    const oldSheetPosition = sheetIds.findIndex((id) => id === this.activeSheet.id);
    const delta = direction === "left" ? sheetIds.length - 1 : 1;
    const newPosition = (oldSheetPosition + delta) % sheetIds.length;
    this.dispatch("ACTIVATE_SHEET", {
      sheetIdFrom: this.getActiveSheetId(),
      sheetIdTo: sheetIds[newPosition],
    });
  }

  private onColumnsRemoved(cmd: RemoveColumnsRowsCommand) {
    const { cell, zone } = this.gridSelection.anchor;
    const selectedZone = updateSelectionOnDeletion(zone, "left", [...cmd.elements]);
    let anchorZone = { left: cell.col, right: cell.col, top: cell.row, bottom: cell.row };
    anchorZone = updateSelectionOnDeletion(anchorZone, "left", [...cmd.elements]);

    const anchor = {
      cell: {
        col: anchorZone.left,
        row: anchorZone.top,
      },
      zone: selectedZone,
    };

    const selections = this.gridSelection.zones.map((zone) =>
      updateSelectionOnDeletion(zone, "left", [...cmd.elements])
    );
    this.setSelectionMixin(anchor, selections);
  }

  private onRowsRemoved(cmd: RemoveColumnsRowsCommand) {
    const { cell, zone } = this.gridSelection.anchor;
    const selectedZone = updateSelectionOnDeletion(zone, "top", [...cmd.elements]);
    let anchorZone = { left: cell.col, right: cell.col, top: cell.row, bottom: cell.row };
    anchorZone = updateSelectionOnDeletion(anchorZone, "top", [...cmd.elements]);
    const anchor = {
      cell: {
        col: anchorZone.left,
        row: anchorZone.top,
      },
      zone: selectedZone,
    };
    const selections = this.gridSelection.zones.map((zone) =>
      updateSelectionOnDeletion(zone, "top", [...cmd.elements])
    );
    this.setSelectionMixin(anchor, selections);
  }

  private onAddElements(cmd: AddColumnsRowsCommand) {
    const start = cmd.dimension === "COL" ? "left" : "top";
    const anchorZone = updateSelectionOnInsertion(
      this.gridSelection.anchor.zone,
      start,
      cmd.base,
      cmd.position,
      cmd.quantity
    );
    const selection = this.gridSelection.zones.map((zone) =>
      updateSelectionOnInsertion(zone, start, cmd.base, cmd.position, cmd.quantity)
    );
    const anchor = {
      cell: { col: anchorZone.left, row: anchorZone.top },
      zone: anchorZone,
    };
    this.setSelectionMixin(anchor, selection);
  }

  private onMoveElements(cmd: MoveColumnsRowsCommand) {
    const thickness = cmd.elements.length;
    const figureUpdateCmds = this.getFiguresUpdates(cmd);

    this.dispatch("ADD_COLUMNS_ROWS", {
      dimension: cmd.dimension,
      sheetId: cmd.sheetId,
      sheetName: cmd.sheetName,
      base: cmd.base,
      quantity: thickness,
      position: cmd.position,
    });

    const isCol = cmd.dimension === "COL";
    const start = cmd.elements[0];
    const end = cmd.elements[thickness - 1];
    const isBasedBefore = cmd.base < start;
    const deltaCol = isBasedBefore && isCol ? thickness : 0;
    const deltaRow = isBasedBefore && !isCol ? thickness : 0;
    const toRemove = isBasedBefore ? cmd.elements.map((el) => el + thickness) : cmd.elements;
    const originalSize: Record<HeaderIndex, Pixel> = {};
    for (const element of toRemove) {
      originalSize[element] = this.getters.getHeaderSize(cmd.sheetId, cmd.dimension, element);
    }
    const target = [
      {
        left: isCol ? start + deltaCol : 0,
        right: isCol ? end + deltaCol : this.getters.getNumberCols(cmd.sheetId) - 1,
        top: !isCol ? start + deltaRow : 0,
        bottom: !isCol ? end + deltaRow : this.getters.getNumberRows(cmd.sheetId) - 1,
      },
    ];

    const sheetId = this.getActiveSheetId();
    const base = isBasedBefore ? cmd.base : cmd.base + 1;
    const pasteTarget = [
      {
        left: isCol ? base : 0,
        right: isCol ? base + thickness - 1 : this.getters.getNumberCols(cmd.sheetId) - 1,
        top: !isCol ? base : 0,
        bottom: !isCol ? base + thickness - 1 : this.getters.getNumberRows(cmd.sheetId) - 1,
      },
    ];

    // The clipboards copy the data before pasting to ensure that
    // clipboardA paste doesn't interfere with clipboardB copy
    const handlers: [string, AbstractCellClipboardHandler<unknown, unknown>][] =
      clipboardHandlersRegistries.cellHandlers.getKeys().map((name) => {
        const Handler = clipboardHandlersRegistries.cellHandlers.get(name);
        return [name, new Handler(this.getters, this.dispatch)];
      });
    const data: Record<string, unknown> = {};

    for (const [handlerName, handler] of handlers) {
      data[handlerName] = handler.copy(
        getClipboardDataPositions(sheetId, target),
        false,
        "shiftCells"
      );
    }

    for (const [handlerName, handler] of handlers) {
      if (!data[handlerName]) {
        continue;
      }
      handler.paste({ zones: pasteTarget, sheetId }, data[handlerName], { isCutOperation: true });
    }

    const selection = pasteTarget[0];
    const col = selection.left;
    const row = selection.top;
    this.setSelectionMixin({ zone: selection, cell: { col, row } }, [selection]);

    let currentIndex = isBasedBefore ? cmd.base : cmd.base + 1;
    const resizingGroups: Record<Pixel, HeaderIndex[]> = {};
    for (const element of toRemove) {
      const size = originalSize[element];
      const currentSize = this.getters.getHeaderSize(cmd.sheetId, cmd.dimension, currentIndex);
      if (size !== currentSize) {
        resizingGroups[size] ??= [];
        resizingGroups[size].push(currentIndex);
      }
      currentIndex += 1;
    }

    for (const size in resizingGroups) {
      this.dispatch("RESIZE_COLUMNS_ROWS", {
        dimension: cmd.dimension,
        sheetId: cmd.sheetId,
        size: parseInt(size, 10),
        elements: resizingGroups[size],
      });
    }

    this.dispatch("REMOVE_COLUMNS_ROWS", {
      dimension: cmd.dimension,
      sheetId: cmd.sheetId,
      sheetName: cmd.sheetName,
      elements: toRemove,
    });
    this.applyFigureUpdates(figureUpdateCmds);
  }

  private getFiguresUpdates(cmd: MoveColumnsRowsCommand): UpdateFigureCommand[] {
    const cmds: UpdateFigureCommand[] = [];
    const moveDict = {};
    const base = cmd.elements[0] < cmd.base ? cmd.base - cmd.elements.length + 1 : cmd.base;
    for (let index = 0; index < cmd.elements.length; index++) {
      moveDict[cmd.elements[index]] = base + index;
    }
    for (const figure of this.getters.getFigures(cmd.sheetId)) {
      if (cmd.dimension === "COL" && figure.col in moveDict) {
        cmds.push({
          type: "UPDATE_FIGURE",
          sheetId: cmd.sheetId,
          figureId: figure.id,
          col: moveDict[figure.col],
          row: figure.row,
        });
      }
      if (cmd.dimension === "ROW" && figure.row in moveDict) {
        cmds.push({
          type: "UPDATE_FIGURE",
          sheetId: cmd.sheetId,
          figureId: figure.id,
          row: moveDict[figure.row],
          col: figure.col,
        });
      }
    }
    return cmds;
  }

  private applyFigureUpdates(cmds: UpdateFigureCommand[]) {
    for (const cmd of cmds) {
      this.dispatch(cmd.type, { ...cmd });
    }
  }

  private isMoveElementAllowed(cmd: MoveColumnsRowsCommand): CommandResult {
    const isCol = cmd.dimension === "COL";
    const start = cmd.elements[0];
    const end = cmd.elements[cmd.elements.length - 1];
    const id = cmd.sheetId;
    const doesElementsHaveCommonMerges = isCol
      ? this.getters.doesColumnsHaveCommonMerges
      : this.getters.doesRowsHaveCommonMerges;
    if (
      doesElementsHaveCommonMerges(id, start - 1, start) ||
      doesElementsHaveCommonMerges(id, end, end + 1) ||
      doesElementsHaveCommonMerges(id, cmd.base - 1, cmd.base)
    ) {
      return CommandResult.WillRemoveExistingMerge;
    }
    const headers = [cmd.base, ...cmd.elements];
    const maxHeaderValue = isCol ? this.getters.getNumberCols(id) : this.getters.getNumberRows(id);
    if (headers.some((h) => h < 0 || h >= maxHeaderValue)) {
      return CommandResult.InvalidHeaderIndex;
    }
    if (!isCol && !this.isTableRowMoveAllowed(id, cmd.elements)) {
      return CommandResult.CannotMoveTableHeader;
    }
    if (this.willMoveOverlapTables(cmd)) {
      return CommandResult.WillOverlapTable;
    }
    return CommandResult.Success;
  }

  private willMoveOverlapTables(cmd: MoveColumnsRowsCommand): boolean {
    const tables = this.getters.getCoreTables(cmd.sheetId);
    if (tables.length < 2) {
      return false;
    }
    const isCol = cmd.dimension === "COL";
    const start = cmd.elements[0];
    const end = cmd.elements[cmd.elements.length - 1];
    const startAfterMove = cmd.base < start ? cmd.base : cmd.base - end + start;

    const movedZones: Zone[] = [];
    for (const table of tables) {
      const zone = this.getTableZoneAfterMove(table.range.zone, isCol, start, end, startAfterMove);
      if (zone) {
        movedZones.push(zone);
      }
    }

    for (let i = 0; i < movedZones.length; i++) {
      for (let j = i + 1; j < movedZones.length; j++) {
        if (overlap(movedZones[i], movedZones[j])) {
          return true;
        }
      }
    }
    return false;
  }

  private getTableZoneAfterMove(
    zone: Zone,
    isCol: boolean,
    start: HeaderIndex,
    end: HeaderIndex,
    startAfterMove: HeaderIndex
  ): Zone | undefined {
    const thickness = end - start + 1;
    const from = isCol ? zone.left : zone.top;
    const to = isCol ? zone.right : zone.bottom;
    const isWholeTableMoved = start <= from && to <= end;
    if (isWholeTableMoved) {
      const delta = startAfterMove - start;
      return isCol
        ? { ...zone, left: from + delta, right: to + delta }
        : { ...zone, top: from + delta, bottom: to + delta };
    }

    const newHeaders: HeaderIndex[] = [];
    for (let header = from; header <= to; header++) {
      if (header < start || header > end) {
        const afterRemoval = header < start ? header : header - thickness;
        newHeaders.push(afterRemoval < startAfterMove ? afterRemoval : afterRemoval + thickness);
      }
    }
    if (!newHeaders.length) {
      return undefined;
    }
    const newFrom = Math.min(...newHeaders);
    const newTo = Math.max(...newHeaders);
    return isCol
      ? { ...zone, left: newFrom, right: newTo }
      : { ...zone, top: newFrom, bottom: newTo };
  }

  private isTableRowMoveAllowed(sheetId: UID, selectedRows: HeaderIndex[]): boolean {
    const tables = this.getters.getCoreTables(sheetId);
    if (tables.length === 0) {
      return true;
    }

    const selectedRowSet = new Set(selectedRows);
    return tables.every(({ range: { zone }, config }) => {
      const { top, bottom } = zone;

      if (config.numberOfHeaders === 0) {
        return true;
      }

      const headerRowEnd = top + config.numberOfHeaders - 1;

      // Moving the table is allowed if table header rows are not part of the selection
      // Or if the entire table (including header) is selected
      const isHeaderSelected = selectedRows.some((row) => row >= top && row <= headerRowEnd);
      if (!isHeaderSelected) {
        return true;
      }

      const isWholeTableSelected = range(top, bottom + 1).every((r) => selectedRowSet.has(r));
      return isWholeTableSelected;
    });
  }

  private fallbackToVisibleSheet() {
    if (!this.getters.tryGetSheet(this.getters.getActiveSheetId())) {
      const currentSheetIds = this.getters.getVisibleSheetIds();
      this.activeSheet = this.getters.getSheet(currentSheetIds[0]);
      if (this.activeSheet.id in this.sheetsData) {
        const { anchor } = this.clipSelection(
          this.activeSheet.id,
          this.sheetsData[this.activeSheet.id].gridSelection
        );
        this.selectCell(anchor.cell.col, anchor.cell.row);
      } else {
        this.selectCell(0, 0);
      }
      const { col, row } = this.gridSelection.anchor.cell;
      this.moveClient({
        sheetId: this.getters.getActiveSheetId(),
        col,
        row,
      });
    }
  }

  //-------------------------------------------
  // Helpers for extensions
  // ------------------------------------------
  /**
   * Clip the selection if it spans outside the sheet
   */
  private clipSelection(sheetId: UID, selection: Selection): Selection {
    const cols = this.getters.getNumberCols(sheetId) - 1;
    const rows = this.getters.getNumberRows(sheetId) - 1;
    const zones = selection.zones.map((z) => {
      return {
        left: clip(z.left, 0, cols),
        right: clip(z.right, 0, cols),
        top: clip(z.top, 0, rows),
        bottom: clip(z.bottom, 0, rows),
      };
    });
    const anchorCol = clip(selection.anchor.cell.col, 0, cols);
    const anchorRow = clip(selection.anchor.cell.row, 0, rows);
    const anchorZone = {
      left: clip(selection.anchor.zone.left, 0, cols),
      right: clip(selection.anchor.zone.right, 0, cols),
      top: clip(selection.anchor.zone.top, 0, rows),
      bottom: clip(selection.anchor.zone.bottom, 0, rows),
    };
    return {
      zones,
      anchor: {
        cell: { col: anchorCol, row: anchorRow },
        zone: anchorZone,
      },
    };
  }

  // ---------------------------------------------------------------------------
  // Grid rendering
  // ---------------------------------------------------------------------------

  drawLayer(renderingContext: GridRenderingContext) {
    if (this.getters.isDashboard()) {
      return;
    }
    // FIXME: during the rendering process, we need to render the sheet/viewports of the GridRenderingContext
    // But in plugins, there's nothing preventing us to call the getters or to use the plugin state, and draw
    // at the wrong position. We should think of a way to improve that.
    const { ctx, thinLineWidth, viewports, sheetId, selectedZones: zones } = renderingContext;
    // selection
    const theme = this.getters.getSpreadsheetTheme();
    const onlyOneCell =
      zones.length === 1 && zones[0].left === zones[0].right && zones[0].top === zones[0].bottom;
    ctx.fillStyle = onlyOneCell
      ? theme.singleCellSelectionBackgroundColor
      : theme.multipleCellsSelectionBackgroundColor;
    ctx.strokeStyle = SELECTION_BORDER_COLOR;
    ctx.lineWidth = 1.5 * thinLineWidth;
    const isDarkMode = this.getters.isDarkMode();
    for (const zone of zones) {
      if (!viewports.isZoneVisibleInViewport(sheetId, zone)) {
        continue;
      }
      const { x, y, width, height } = viewports.getVisibleRect(sheetId, zone);
      const currentLineWidth = ctx.lineWidth;
      if (!isDarkMode) {
        ctx.globalCompositeOperation = "multiply";
      }
      if (height === 0 || width === 0) {
        ctx.lineWidth = 3 * thinLineWidth;
      }
      if (height === 0 && width === 0) {
        ctx.beginPath();
        ctx.arc(x, y, 3, 0, 2 * Math.PI);
        ctx.fill();
        ctx.globalCompositeOperation = "source-over";
        ctx.stroke();
      } else {
        ctx.fillRect(x, y, width, height);
        ctx.globalCompositeOperation = "source-over";
        ctx.strokeRect(x, y, width, height);
      }
      ctx.lineWidth = currentLineWidth;
    }

    ctx.globalCompositeOperation = "source-over";
    // active zone
    const position = renderingContext.activePosition;
    if (!position) {
      return;
    }

    ctx.strokeStyle = SELECTION_BORDER_COLOR;
    ctx.lineWidth = 3 * thinLineWidth;
    let zone: Zone;
    if (this.getters.isInMerge(position)) {
      zone = this.getters.getMerge(position)!;
    } else {
      zone = positionToZone(position);
    }
    const { x, y, width, height } = viewports.getVisibleRect(sheetId, zone);
    if (width > 0 && height > 0) {
      ctx.strokeRect(x, y, width, height);
    }
  }

  getSelectionState(): SelectionState {
    return {
      sheetId: this.getters.getActiveSheetId(),
      selectedZones: this.getters.getSelectedZones(),
      activeCols: this.getters.getActiveCols(),
      activeRows: this.getters.getActiveRows(),
      activePosition: this.getActivePosition(),
    };
  }
}
