import { CompiledFormula } from "../../formulas/compiler";
import {
  createInvalidRange,
  createRange,
  createRangeFromXc,
  duplicateRangeInDuplicatedSheet,
  getRangeString,
  isFullColRange,
  isFullRowRange,
  orderRange,
} from "../../helpers/range";
import { getRangeAdapterFunctions } from "../../helpers/range_adapter_functions";
import { recomputeZones } from "../../helpers/recompute_zones";
import { rangeReference, splitReference } from "../../helpers/references";

import { intersection, isZoneInside, isZoneValid, unionUnboundedZones } from "../../helpers/zones";
import { Command, CommandHandler, CommandResult, CoreCommand } from "../../types/commands";
import { CellErrorType } from "../../types/errors";
import { CoreGetters } from "../../types/getters";
import {
  Dimension,
  RangeAdapterFunctions,
  RangeProvider,
  UID,
  UnboundedZone,
  Zone,
} from "../../types/misc";
import { Range, RangeData, RangeStringOptions } from "../../types/range";

export class RangeAdapterPlugin implements CommandHandler<CoreCommand> {
  private getters: CoreGetters;
  private providers: Array<RangeProvider["adaptRanges"]> = [];
  private isAdaptingRanges: boolean = false;
  constructor(getters: CoreGetters) {
    this.getters = getters;
  }

  static getters = [
    "copyFormulaStringForSheet",
    "extendRange",
    "getRangeString",
    "getRangeFromSheetXC",
    "clipRangesToSheet",
    "clipRangeToSheet",
    "createAdaptedRanges",
    "getRangeData",
    "getRangeDataFromXc",
    "getRangeDataFromZone",
    "getRangeFromRangeData",
    "getRangeFromZone",
    "getRangesUnion",
    "recomputeRanges",
    "isRangeValid",
    "removeRangesSheetPrefix",
  ] as const;

  // ---------------------------------------------------------------------------
  // Command Handling
  // ---------------------------------------------------------------------------
  allowDispatch(cmd: CoreCommand): CommandResult {
    if (cmd.type === "MOVE_RANGES") {
      return cmd.target.length === 1 ? CommandResult.Success : CommandResult.InvalidZones;
    }
    return CommandResult.Success;
  }
  beforeHandle(command: Command) {}

  handle(cmd: CoreCommand) {
    if (this.isAdaptingRanges) {
      throw new Error("Plugins cannot dispatch commands during adaptRanges phase");
    }
    const adapterFunctions = getRangeAdapterFunctions(cmd);
    if (adapterFunctions) {
      this.executeOnAllRanges(adapterFunctions);
    }
  }

  finalize() {}

  private executeOnAllRanges(adapterFunctions: RangeAdapterFunctions) {
    this.isAdaptingRanges = true;
    for (const provider of this.providers) {
      provider(adapterFunctions);
    }
    this.isAdaptingRanges = false;
  }

  /**
   * Stores the functions bound to each plugin to be able to iterate over all ranges of the application,
   * without knowing any details of the internal data structure of the plugins and without storing ranges
   * in the range adapter.
   *
   * @param provider a function bound to a plugin that will loop over its internal data structure to find
   * all ranges
   */
  addRangeProvider(provider: RangeProvider["adaptRanges"]) {
    this.providers.push(provider);
  }

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

  clipRangeToSheet(range: Range): Range | undefined {
    if (range.invalidXc) {
      return range;
    }
    const sheetZone = this.getters.getSheetZone(range.sheetId);
    if (isZoneInside(range.zone, sheetZone)) {
      // fast path: most ranges are within the sheet zone
      return range;
    }
    const newZone = intersection(range.zone, sheetZone);
    if (!newZone) {
      return undefined;
    }
    return this.getters.getRangeFromZone(range.sheetId, newZone);
  }

  clipRangesToSheet(ranges: Range[]): Range[] {
    const clippedRanges: Range[] = [];
    for (const range of ranges) {
      const clippedRange = this.clipRangeToSheet(range);
      if (clippedRange) {
        clippedRanges.push(clippedRange);
      }
    }
    return clippedRanges;
  }

  createAdaptedRanges(ranges: Range[], offsetX: number, offsetY: number, sheetId: UID): Range[] {
    return ranges.map((range) => {
      if (!isZoneValid(range.zone)) {
        return range;
      }
      const copySheetId = range.prefixSheet ? range.sheetId : sheetId;
      const isFullRow = isFullRowRange(range);
      const isFullCol = isFullColRange(range);
      const unboundZone = {
        ...range.unboundedZone,
        // Don't shift left if the range is a full row without header
        left:
          isFullRow && !range.unboundedZone.hasHeader
            ? range.unboundedZone.left
            : range.unboundedZone.left + (range.parts[0].colFixed ? 0 : offsetX),
        // Don't shift right if the range is a full row
        right: isFullRow
          ? range.unboundedZone.right
          : range.unboundedZone.right! +
            ((range.parts[1] || range.parts[0]).colFixed ? 0 : offsetX),
        // Don't shift up if the range is a column row without header
        top:
          isFullCol && !range.unboundedZone.hasHeader
            ? range.unboundedZone.top
            : range.unboundedZone.top + (range.parts[0].rowFixed ? 0 : offsetY),
        // Don't shift down if the range is a full column
        bottom: isFullCol
          ? range.unboundedZone.bottom
          : range.unboundedZone.bottom! +
            ((range.parts[1] || range.parts[0]).rowFixed ? 0 : offsetY),
      };
      return orderRange(
        createRange(
          { ...range, sheetId: copySheetId, zone: unboundZone },
          this.getters.getSheetSize
        )
      );
    });
  }

  /**
   * Remove the sheet name prefix if a range is part of the given sheet.
   */
  removeRangesSheetPrefix(sheetId: UID, ranges: Range[]): Range[] {
    return ranges.map((range) => {
      if (range.prefixSheet && range.sheetId === sheetId) {
        return { ...range, prefixSheet: false };
      }
      return range;
    });
  }

  extendRange(range: Range, dimension: Dimension, quantity: number): Range {
    const right = dimension === "COL" ? range.zone.right + quantity : range.zone.right;
    const bottom = dimension === "ROW" ? range.zone.bottom + quantity : range.zone.bottom;
    const unboundedZone = {
      left: range.zone.left,
      top: range.zone.top,
      right: isFullRowRange(range) ? undefined : right,
      bottom: isFullColRange(range) ? undefined : bottom,
    };
    return createRange({ ...range, zone: unboundedZone }, this.getters.getSheetSize);
  }

  /**
   * Creates a range from a XC reference that can contain a sheet reference
   * @param defaultSheetId the sheet to default to if the sheetXC parameter does not contain a sheet reference (usually the active sheet Id)
   * @param sheetXC the string description of a range, in the form SheetName!XC:XC
   */
  getRangeFromSheetXC(defaultSheetId: UID | undefined, sheetXC: string): Range {
    if (!rangeReference.test(sheetXC)) {
      return createInvalidRange(sheetXC);
    }

    const { sheetName } = splitReference(sheetXC);
    const sheetId = this.getters.getSheetIdByName(sheetName) || defaultSheetId;
    if (!sheetId || !this.getters.tryGetSheet(sheetId)) {
      return createInvalidRange(sheetXC);
    }

    const invalidSheetName =
      sheetName && !this.getters.getSheetIdByName(sheetName) ? sheetName : undefined;

    return createRangeFromXc({ xc: sheetXC, sheetId, invalidSheetName }, this.getters.getSheetSize);
  }

  /**
   * Gets the string that represents the range as it is at the moment of the call.
   * The string will be prefixed with the sheet name if the call specified a sheet id in `forSheetId`
   * different than the sheet on which the range has been created or if `forSheetId` is not specified.
   *
   * @param range the range (received from getRangeFromXC or getRangeFromZone)
   * @param forSheetId the id of the sheet where the range string is supposed to be used.
   * @param options
   * @param options.useBoundedReference if true, the range will be returned with bounded row and column
   * @param options.useFixedReference if true, the range will be returned with fixed row and column
   */
  getRangeString(
    range: Range,
    forSheetId?: UID,
    options: RangeStringOptions = { useBoundedReference: false, useFixedReference: false }
  ): string {
    if (!range) {
      return CellErrorType.InvalidReference;
    }
    if (range.invalidXc) {
      return range.invalidXc;
    }
    if (!this.getters.tryGetSheet(range.sheetId)) {
      return CellErrorType.InvalidReference;
    }
    return getRangeString(range, forSheetId, this.getters.getSheetName, options);
  }

  getRangeDataFromXc(sheetId: UID, xc: string): RangeData {
    const range = this.getters.getRangeFromSheetXC(sheetId, xc);
    return this.getRangeDataFromZone(range.sheetId, range.unboundedZone);
  }

  getRangeDataFromZone(sheetId: UID, zone: Zone | UnboundedZone): RangeData {
    zone = this.getters.getUnboundedZone(sheetId, zone);
    return { _sheetId: sheetId, _zone: zone };
  }

  getRangeData(range: Range): RangeData {
    return this.getRangeDataFromZone(range.sheetId, range.unboundedZone);
  }

  getRangeFromZone(sheetId: UID, zone: Zone | UnboundedZone): Range {
    return createRange(
      {
        sheetId,
        zone,
        parts: [
          { colFixed: false, rowFixed: false },
          { colFixed: false, rowFixed: false },
        ],
        prefixSheet: false,
      },
      this.getters.getSheetSize
    );
  }

  /**
   * Allows you to recompute ranges from the same sheet
   */
  recomputeRanges(ranges: Range[], rangesToRemove: Range[]): Range[] {
    const zones = ranges.map((range) => range.unboundedZone);
    const zonesToRemove = rangesToRemove.map((range) => range.unboundedZone);
    return recomputeZones(zones, zonesToRemove).map((zone) =>
      this.getRangeFromZone(ranges[0].sheetId, zone)
    );
  }

  getRangeFromRangeData(data: RangeData): Range {
    if (!this.getters.tryGetSheet(data._sheetId)) {
      return createInvalidRange(CellErrorType.InvalidReference);
    }
    const rangeInterface = {
      prefixSheet: false,
      zone: data._zone,
      sheetId: data._sheetId,
      invalidSheetName: undefined,
      parts: [
        { colFixed: false, rowFixed: false },
        { colFixed: false, rowFixed: false },
      ],
    };
    return createRange(rangeInterface, this.getters.getSheetSize);
  }

  isRangeValid(rangeStr: string): boolean {
    if (!rangeStr) {
      return false;
    }
    const { xc, sheetName } = splitReference(rangeStr);
    return (
      xc.match(rangeReference) !== null &&
      (!sheetName || this.getters.getSheetIdByName(sheetName) !== undefined)
    );
  }

  getRangesUnion(ranges: Range[]): Range {
    const zones = ranges.map((range) => range.unboundedZone);
    const unionOfZones = unionUnboundedZones(...zones);
    return this.getRangeFromZone(ranges[0].sheetId, unionOfZones);
  }

  /**
   * Copy a formula string to another sheet.
   *
   * @param sheetIdFrom
   * @param sheetIdTo
   * @param formula
   * @param mode
   * `keepSameReference` will make the formula reference the exact same ranges,
   * `moveReference` will change all the references to `sheetIdFrom` into references to `sheetIdTo`.
   */
  copyFormulaStringForSheet(
    sheetIdFrom: UID,
    sheetIdTo: UID,
    formula: string,
    mode: "keepSameReference" | "moveReference"
  ): string {
    if (!formula.startsWith("=")) {
      return formula;
    }

    const compiledFormula = CompiledFormula.Compile(formula, sheetIdFrom, this.getters);
    const updatedDependencies = compiledFormula.rangeDependencies.map((range) => {
      return mode === "keepSameReference"
        ? range
        : duplicateRangeInDuplicatedSheet(sheetIdFrom, sheetIdTo, range);
    });
    return CompiledFormula.CopyWithDependencies(
      compiledFormula,
      sheetIdTo,
      updatedDependencies
    ).toFormulaString(this.getters);
  }
}
