import { toXC } from "../../helpers/coordinates";
import { clip, deepEquals, isDefined } from "../../helpers/misc";
import {
  doesAnyZoneCrossFrozenPane,
  isEqual,
  overlap,
  positions,
  toZone,
  union,
  zoneToDimension,
  zoneToXc,
} from "../../helpers/zones";
import {
  AddMergeCommand,
  CommandResult,
  CoreCommand,
  TargetDependentCommand,
  UpdateCellCommand,
} from "../../types/commands";
import {
  CellPosition,
  HeaderIndex,
  Merge,
  RangeAdapterFunctions,
  UID,
  Zone,
} from "../../types/misc";
import { Range } from "../../types/range";
import { ExcelWorkbookData, WorkbookData } from "../../types/workbook_data";
import { CorePlugin } from "../core_plugin";

type SheetMergeCellMap = Record<number, Record<number, number | undefined> | undefined>;

interface MergeState {
  readonly merges: Record<UID, Record<number, Range | undefined> | undefined>;
  // readonly mergeCellMap: Record<UID, SheetMergeCellMap | undefined>; // SheetId [ XC ] --> merge ID
  readonly mergeCellMap: Record<UID, SheetMergeCellMap | undefined>; // SheetId [ col ][ row ] --> merge ID
}

export class MergePlugin extends CorePlugin<MergeState> implements MergeState {
  static getters = [
    "isInMerge",
    "isInSameMerge",
    "isMergeHidden",
    "getMainCellPosition",
    "expandZone",
    "doesIntersectMerge",
    "doesColumnsHaveCommonMerges",
    "doesRowsHaveCommonMerges",
    "getMerges",
    "getMerge",
    "getMergesInZone",
    "isSingleCellOrMerge",
    "isMainCellPosition",
  ] as const;

  private nextId: number = 1;

  readonly merges: Record<UID, Record<number, Range | undefined> | undefined> = {};
  readonly mergeCellMap: Record<UID, SheetMergeCellMap | undefined> = {};

  // ---------------------------------------------------------------------------
  // Command Handling
  // ---------------------------------------------------------------------------
  allowDispatch(cmd: CoreCommand) {
    const force = "force" in cmd ? !!cmd.force : false;

    switch (cmd.type) {
      case "ADD_MERGE":
        if (force) {
          return this.checkValidations(cmd, this.checkFrozenPanes);
        }
        return this.checkValidations(
          cmd,
          this.checkDestructiveMerge,
          this.checkOverlap,
          this.checkFrozenPanes
        );
      case "UPDATE_CELL":
        return this.checkMergedContentUpdate(cmd);
      case "REMOVE_MERGE":
        return this.checkMergeExists(cmd);
      default:
        return CommandResult.Success;
    }
  }

  handle(cmd: CoreCommand) {
    switch (cmd.type) {
      case "CREATE_SHEET":
        this.history.update("merges", cmd.sheetId, {});
        this.history.update("mergeCellMap", cmd.sheetId, {});
        break;
      case "DELETE_SHEET":
        this.history.update("merges", cmd.sheetId, {});
        this.history.update("mergeCellMap", cmd.sheetId, {});
        break;
      case "DUPLICATE_SHEET":
        const merges = this.merges[cmd.sheetId];
        if (!merges) {
          break;
        }
        for (const range of Object.values(merges).filter(isDefined)) {
          this.addMerge(cmd.sheetIdTo, range.zone);
        }
        break;
      case "ADD_MERGE":
        for (const zone of cmd.target) {
          this.addMerge(cmd.sheetId, zone);
        }
        break;
      case "REMOVE_MERGE":
        for (const zone of cmd.target) {
          this.removeMerge(cmd.sheetId, zone);
        }
        break;
    }
  }

  adaptRanges(rangeAdapters: RangeAdapterFunctions) {
    for (const sheetId in this.merges) {
      this.applyRangeChangeOnSheet(sheetId, rangeAdapters);
    }
  }

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

  getMerges(sheetId: UID): Merge[] {
    return Object.keys(this.merges[sheetId] || {})
      .map((mergeId) => this.getMergeById(sheetId, parseInt(mergeId, 10)))
      .filter(isDefined);
  }

  getMerge({ sheetId, col, row }: CellPosition): Merge | undefined {
    const sheetMap = this.mergeCellMap[sheetId];
    const mergeId = sheetMap ? col in sheetMap && sheetMap[col]?.[row] : undefined;
    return mergeId ? this.getMergeById(sheetId, mergeId) : undefined;
  }

  getMergesInZone(sheetId: UID, zone: Zone): Merge[] {
    const sheetMap = this.mergeCellMap[sheetId];
    if (!sheetMap) {
      return [];
    }
    const mergeIds = new Set<number>();

    for (let col = zone.left; col <= zone.right; col++) {
      for (let row = zone.top; row <= zone.bottom; row++) {
        const mergeId = sheetMap[col]?.[row];
        if (mergeId) {
          mergeIds.add(mergeId);
        }
      }
    }

    return Array.from(mergeIds)
      .map((mergeId) => this.getMergeById(sheetId, mergeId))
      .filter(isDefined);
  }

  /**
   * Return true if the zone intersects an existing merge:
   * if they have at least a common cell
   */
  doesIntersectMerge(sheetId: UID, zone: Zone): boolean {
    for (const merge of this.getMerges(sheetId)) {
      if (overlap(zone, merge)) {
        return true;
      }
    }
    return false;
  }

  /**
   * Returns true if two columns have at least one merge in common
   */
  doesColumnsHaveCommonMerges(sheetId: string, colA: HeaderIndex, colB: HeaderIndex) {
    const sheet = this.getters.getSheet(sheetId);
    for (let row = 0; row < this.getters.getNumberRows(sheetId); row++) {
      if (this.isInSameMerge(sheet.id, colA, row, colB, row)) {
        return true;
      }
    }
    return false;
  }

  /**
   * Returns true if two rows have at least one merge in common
   */
  doesRowsHaveCommonMerges(sheetId: string, rowA: HeaderIndex, rowB: HeaderIndex) {
    const sheet = this.getters.getSheet(sheetId);
    for (let col = 0; col <= this.getters.getNumberCols(sheetId); col++) {
      if (this.isInSameMerge(sheet.id, col, rowA, col, rowB)) {
        return true;
      }
    }
    return false;
  }

  /**
   * Add all necessary merge to the current selection to make it valid
   */
  expandZone(sheetId: UID, zone: Zone): Zone {
    const { left, right, top, bottom } = zone;
    let result: Zone = { left, right, top, bottom };

    for (const id in this.merges[sheetId]) {
      const merge = this.getMergeById(sheetId, parseInt(id));
      if (merge && overlap(merge, result)) {
        result = union(merge, result);
      }
    }
    return isEqual(result, zone) ? result : this.expandZone(sheetId, result);
  }

  isInSameMerge(
    sheetId: UID,
    colA: HeaderIndex,
    rowA: HeaderIndex,
    colB: HeaderIndex,
    rowB: HeaderIndex
  ): boolean {
    const mergeA = this.getMerge({ sheetId, col: colA, row: rowA });
    const mergeB = this.getMerge({ sheetId, col: colB, row: rowB });
    if (!mergeA || !mergeB) {
      return false;
    }
    return isEqual(mergeA, mergeB);
  }

  isInMerge({ sheetId, col, row }: CellPosition): boolean {
    const sheetMap = this.mergeCellMap[sheetId];
    return sheetMap ? col in sheetMap && Boolean(sheetMap[col]?.[row]) : false;
  }

  getMainCellPosition(position: CellPosition): CellPosition {
    const mergeZone = this.getMerge(position);
    return mergeZone
      ? {
          sheetId: position.sheetId,
          col: mergeZone.left,
          row: mergeZone.top,
        }
      : position;
  }

  isMergeHidden(sheetId: UID, merge: Merge): boolean {
    const hiddenColsGroups = this.getters.getHiddenColsGroups(sheetId);
    const hiddenRowsGroups = this.getters.getHiddenRowsGroups(sheetId);

    for (const group of hiddenColsGroups) {
      if (merge.left >= group[0] && merge.right <= group[group.length - 1]) {
        return true;
      }
    }
    for (const group of hiddenRowsGroups) {
      if (merge.top >= group[0] && merge.bottom <= group[group.length - 1]) {
        return true;
      }
    }
    return false;
  }

  /**
   * Check if the zone represents a single cell or a single merge.
   */
  isSingleCellOrMerge(sheetId: UID, zone: Zone): boolean {
    const merge = this.getMerge({ sheetId, col: zone.left, row: zone.top });
    if (merge) {
      return isEqual(zone, merge);
    }
    const { numberOfCols, numberOfRows } = zoneToDimension(zone);
    return numberOfCols === 1 && numberOfRows === 1;
  }

  isMainCellPosition(position: CellPosition): boolean {
    return deepEquals(this.getMainCellPosition(position), position);
  }

  // ---------------------------------------------------------------------------
  // Merges
  // ---------------------------------------------------------------------------

  /**
   * Return true if the current selection requires losing state if it is merged.
   * This happens when there is some textual content in other cells than the
   * top left.
   */
  private isMergeDestructive(sheetId: UID, zone: Zone): boolean {
    let { left, right, top, bottom } = zone;
    right = clip(right, 0, this.getters.getNumberCols(sheetId) - 1);
    bottom = clip(bottom, 0, this.getters.getNumberRows(sheetId) - 1);
    for (let row = top; row <= bottom; row++) {
      for (let col = left; col <= right; col++) {
        if (col !== left || row !== top) {
          const cell = this.getters.getCell({ sheetId, col, row });
          if (cell && (cell.isFormula || cell.content !== "")) {
            return true;
          }
        }
      }
    }
    return false;
  }

  private getMergeById(sheetId: UID, mergeId: number): Merge | undefined {
    const range = this.merges[sheetId]?.[mergeId];
    return range !== undefined ? rangeToMerge(mergeId, range) : undefined;
  }

  private checkDestructiveMerge({ sheetId, target }: AddMergeCommand): CommandResult {
    const sheet = this.getters.tryGetSheet(sheetId);
    if (!sheet) {
      return CommandResult.Success;
    }
    const isDestructive = target.some((zone) => this.isMergeDestructive(sheetId, zone));
    return isDestructive ? CommandResult.MergeIsDestructive : CommandResult.Success;
  }

  private checkOverlap({ target }: AddMergeCommand): CommandResult {
    for (const zone of target) {
      for (const zone2 of target) {
        if (zone !== zone2 && overlap(zone, zone2)) {
          return CommandResult.MergeOverlap;
        }
      }
    }
    return CommandResult.Success;
  }

  private checkFrozenPanes({ sheetId, target }: AddMergeCommand): CommandResult {
    const sheet = this.getters.tryGetSheet(sheetId);
    if (!sheet) {
      return CommandResult.Success;
    }
    const { xSplit, ySplit } = this.getters.getPaneDivisions(sheetId);
    if (doesAnyZoneCrossFrozenPane(target, xSplit, ySplit)) {
      return CommandResult.FrozenPaneOverlap;
    }
    return CommandResult.Success;
  }

  /**
   * The content of a merged cell should always be empty.
   * Except for the top-left cell.
   */
  private checkMergedContentUpdate(cmd: UpdateCellCommand): CommandResult {
    const { col, row, content } = cmd;
    if (content === undefined) {
      return CommandResult.Success;
    }
    const { col: mainCol, row: mainRow } = this.getMainCellPosition(cmd);
    if (mainCol === col && mainRow === row) {
      return CommandResult.Success;
    }
    return CommandResult.CellIsMerged;
  }

  private checkMergeExists(cmd: TargetDependentCommand): CommandResult {
    const { sheetId, target } = cmd;
    for (const zone of target) {
      const { left, top } = zone;
      const merge = this.getMerge({ sheetId, col: left, row: top });
      if (merge === undefined || !isEqual(zone, merge)) {
        return CommandResult.InvalidTarget;
      }
    }
    return CommandResult.Success;
  }

  /**
   * Merge the current selection. Note that:
   * - it assumes that we have a valid selection (no intersection with other
   *   merges)
   * - it does nothing if the merge is trivial: A1:A1
   */
  private addMerge(sheetId: UID, zone: Zone) {
    let { left, right, top, bottom } = zone;
    right = clip(right, 0, this.getters.getNumberCols(sheetId) - 1);
    bottom = clip(bottom, 0, this.getters.getNumberRows(sheetId) - 1);
    const tl = toXC(left, top);
    const br = toXC(right, bottom);
    if (tl === br) {
      return;
    }
    const topLeftStyle = this.getters.getCellStyle({ sheetId, col: left, row: top });
    const topLeftFormat = this.getters.getCellFormat({ sheetId, col: left, row: top });

    const id = this.nextId++;
    this.history.update(
      "merges",
      sheetId,
      id,
      this.getters.getRangeFromSheetXC(sheetId, zoneToXc({ left, top, right, bottom }))
    );
    const previousMerges: Set<number> = new Set();
    for (let row = top; row <= bottom; row++) {
      for (let col = left; col <= right; col++) {
        if (col !== left || row !== top) {
          this.dispatch("UPDATE_CELL", {
            sheetId,
            col,
            row,
            style: null,
            format: null,
            content: "",
          });
        }
        const merge = this.getMerge({ sheetId, col, row });
        if (merge) {
          previousMerges.add(merge.id);
        }
        this.history.update("mergeCellMap", sheetId, col, row, id);
      }
    }
    if (topLeftStyle || topLeftFormat) {
      this.dispatch("SET_FORMATTING", {
        sheetId,
        target: [zone],
        style: topLeftStyle,
        ...(topLeftFormat ? { format: topLeftFormat } : {}),
      });
    }

    for (const mergeId of previousMerges) {
      const { top, bottom, left, right } = this.getMergeById(sheetId, mergeId)!;
      for (let row = top; row <= bottom; row++) {
        for (let col = left; col <= right; col++) {
          const position = { sheetId, col, row };
          const merge = this.getMerge(position);
          if (!merge || merge.id !== id) {
            this.history.update("mergeCellMap", sheetId, col, row, undefined);
            this.dispatch("CLEAR_CELL", position);
          }
        }
      }
      this.history.update("merges", sheetId, mergeId, undefined);
    }
  }

  private removeMerge(sheetId: string, zone: Zone) {
    const { left, top, bottom, right } = zone;
    const merge = this.getMerge({ sheetId, col: left, row: top });
    if (merge === undefined || !isEqual(zone, merge)) {
      return;
    }
    this.history.update("merges", sheetId, merge.id, undefined);
    for (let r = top; r <= bottom; r++) {
      for (let c = left; c <= right; c++) {
        this.history.update("mergeCellMap", sheetId, c, r, undefined);
      }
    }
  }

  /**
   * Apply a range change on merges of a particular sheet.
   */
  private applyRangeChangeOnSheet(sheetId: UID, { applyChange }: RangeAdapterFunctions) {
    const merges = Object.entries(this.merges[sheetId] || {});
    for (const [mergeId, range] of merges) {
      if (range) {
        const currentZone = range.zone;
        const result = applyChange(range);
        switch (result.changeType) {
          case "NONE":
            break;
          case "REMOVE":
            this.removeMerge(sheetId, currentZone);
            break;
          default:
            const { numberOfCols, numberOfRows } = zoneToDimension(result.range.zone);
            if (numberOfCols === 1 && numberOfRows === 1) {
              this.removeMerge(sheetId, currentZone);
            } else {
              this.history.update("merges", sheetId, parseInt(mergeId, 10), result.range);
            }
            break;
        }
      }
    }
    this.history.update("mergeCellMap", sheetId, {});
    for (const merge of this.getMerges(sheetId)) {
      for (const { col, row } of positions(merge)) {
        this.history.update("mergeCellMap", sheetId, col, row, merge.id);
      }
    }
  }

  // ---------------------------------------------------------------------------
  // Import/Export
  // ---------------------------------------------------------------------------

  import(data: WorkbookData) {
    const sheets = data.sheets || [];
    for (const sheetData of sheets) {
      this.history.update("merges", sheetData.id, {});
      this.history.update("mergeCellMap", sheetData.id, {});
      if (sheetData.merges) {
        this.importMerges(sheetData.id, sheetData.merges);
      }
    }
  }

  private importMerges(sheetId: string, merges: string[]) {
    for (const merge of merges) {
      this.addMerge(sheetId, toZone(merge));
    }
  }
  export(data: WorkbookData) {
    for (const sheetData of data.sheets) {
      const merges = this.merges[sheetData.id];
      if (merges) {
        sheetData.merges.push(...exportMerges(merges));
      }
    }
  }

  exportForExcel(data: ExcelWorkbookData) {
    this.export(data);
  }
}

function exportMerges(merges: Record<number, Range | undefined>): string[] {
  return Object.entries(merges)
    .map(([mergeId, range]) => (range ? rangeToMerge(parseInt(mergeId, 10), range) : undefined))
    .filter(isDefined)
    .map((merge) => toXC(merge.left, merge.top) + ":" + toXC(merge.right, merge.bottom));
}

function rangeToMerge(mergeId: number, range: Range): Merge {
  return {
    ...range.zone,
    id: mergeId,
  };
}
