import { CompiledFormula } from "../../formulas/compiler";
import { deepCopy, deepEquals, getCanonicalSymbolName } from "../../helpers/misc";
import { createPivotFormula, getMaxObjectId } from "../../helpers/pivot/pivot_helpers";
import { pivotRegistry } from "../../helpers/pivot/pivot_registry";
import { SpreadsheetPivotTable } from "../../helpers/pivot/table_spreadsheet_pivot";
import { CommandResult, CoreCommand } from "../../types/commands";
import { CellPosition, RangeAdapterFunctions, UID } from "../../types/misc";

import { CellValue } from "../../types/cells";
import { Position } from "../../types/misc";
import { PivotCoreDefinition, PivotCoreMeasure } from "../../types/pivot";
import { Range } from "../../types/range";
import { WorkbookData } from "../../types/workbook_data";
import { CorePlugin } from "../core_plugin";

interface Pivot {
  definition: PivotCoreDefinition;
  formulaId: string;
}

interface MeasureState {
  formula: CompiledFormula;
  dependencies: Range[];
}

interface CoreState {
  nextFormulaId: number;
  pivots: Record<UID, Pivot | undefined>;
  formulaIds: Record<UID, string | undefined>;
  compiledMeasureFormulas: Record<UID, Record<string, MeasureState | undefined>>;
}

export class PivotCorePlugin extends CorePlugin<CoreState> implements CoreState {
  static getters = [
    "getPivotCoreDefinition",
    "getPivotDisplayName",
    "getPivotId",
    "getPivotFormulaId",
    "getPivotIds",
    "getMeasureCompiledFormula",
    "getPivotName",
    "isExistingPivot",
    "getMeasureFullDependencies",
  ] as const;

  readonly nextFormulaId: number = 1;
  public readonly pivots: {
    [pivotId: UID]: Pivot | undefined;
  } = {};
  public readonly formulaIds: { [formulaId: UID]: UID | undefined } = {};
  public readonly compiledMeasureFormulas: Record<UID, Record<string, MeasureState>> = {};

  allowDispatch(cmd: CoreCommand) {
    switch (cmd.type) {
      case "ADD_PIVOT": {
        if (cmd.pivotId in this.pivots) {
          return CommandResult.PivotIdTaken;
        }
        return this.checkValidations(
          cmd.pivot,
          this.checkDuplicatedMeasureIds,
          this.checkSortedColumnInMeasures,
          this.checkCustomFieldsAreValid
        );
      }
      case "UPDATE_PIVOT": {
        if (!(cmd.pivotId in this.pivots)) {
          return CommandResult.PivotIdNotFound;
        }
        if (deepEquals(cmd.pivot, this.pivots[cmd.pivotId]?.definition)) {
          return CommandResult.NoChanges;
        }
        if (cmd.pivot.name === "") {
          return CommandResult.EmptyName;
        }
        return this.checkValidations(
          cmd.pivot,
          this.checkDuplicatedMeasureIds,
          this.checkSortedColumnInMeasures,
          this.checkCustomFieldsAreValid
        );
      }
      case "RENAME_PIVOT":
        if (!(cmd.pivotId in this.pivots)) {
          return CommandResult.PivotIdNotFound;
        }
        if (cmd.name === "") {
          return CommandResult.EmptyName;
        }
        break;
      case "REMOVE_PIVOT":
      case "DUPLICATE_PIVOT":
      case "INSERT_PIVOT": {
        if (!(cmd.pivotId in this.pivots)) {
          return CommandResult.PivotIdNotFound;
        }
        break;
      }
      case "DUPLICATE_PIVOT":
        if (!(cmd.pivotId in this.pivots)) {
          return CommandResult.PivotIdNotFound;
        }
    }
    return CommandResult.Success;
  }

  handle(cmd: CoreCommand) {
    switch (cmd.type) {
      case "ADD_PIVOT": {
        const { pivotId, pivot } = cmd;
        this.addPivot(pivotId, pivot);
        break;
      }
      case "INSERT_PIVOT": {
        const { sheetId, col, row, pivotId, table } = cmd;
        const position = { sheetId, col, row };
        const { cols, rows, measures, fieldsType } = table;
        const spTable = new SpreadsheetPivotTable(cols, rows, measures, fieldsType || {});
        const formulaId = this.getPivotFormulaId(pivotId);
        this.insertPivot(position, formulaId, spTable);
        break;
      }
      case "RENAME_PIVOT": {
        this.history.update("pivots", cmd.pivotId, "definition", "name", cmd.name);
        break;
      }
      case "REMOVE_PIVOT": {
        const pivots = { ...this.pivots };
        delete pivots[cmd.pivotId];
        const formulaId = this.getPivotFormulaId(cmd.pivotId);
        this.history.update("formulaIds", formulaId, undefined);
        this.history.update("pivots", pivots);
        break;
      }
      case "DUPLICATE_PIVOT": {
        const { pivotId, newPivotId } = cmd;
        const pivot = deepCopy(this.getPivotCore(pivotId).definition);
        pivot.name = cmd.duplicatedPivotName ?? pivot.name + " (copy)";
        this.addPivot(newPivotId, pivot);
        break;
      }
      case "UPDATE_PIVOT": {
        this.history.update("pivots", cmd.pivotId, "definition", deepCopy(cmd.pivot));
        this.compileCalculatedMeasures(cmd.pivotId, cmd.pivot.measures);
        break;
      }
      case "CREATE_NAMED_RANGE":
      case "UPDATE_NAMED_RANGE":
      case "DELETE_NAMED_RANGE": {
        for (const pivotId in this.pivots) {
          const pivot = this.pivots[pivotId];
          if (!pivot) {
            continue;
          }
          this.compileCalculatedMeasures(pivotId, pivot.definition.measures);
        }
      }
    }
  }

  adaptRanges(adapters: RangeAdapterFunctions) {
    const { applyChange, adaptFormulaString } = adapters;
    for (const pivotId in this.pivots) {
      const definition = deepCopy(this.pivots[pivotId]?.definition);
      if (!definition) {
        continue;
      }
      const newDefinition = pivotRegistry
        .get(definition.type)
        ?.adaptRanges?.(this.getters, definition, applyChange);
      if (newDefinition && !deepEquals(definition, newDefinition)) {
        this.history.update("pivots", pivotId, "definition", newDefinition);
      }
    }
    for (const pivotId in this.compiledMeasureFormulas) {
      for (const measureId in this.compiledMeasureFormulas[pivotId]) {
        const measure = this.pivots[pivotId]?.definition.measures.find((m) => m.id === measureId);
        if (!measure || !measure.computedBy) {
          continue;
        }
        const sheetId = measure.computedBy.sheetId;
        const { formula: compiledFormula, dependencies: indirectDependencies } =
          this.compiledMeasureFormulas[pivotId][measureId];

        // adapt direct dependencies
        const newCompiledFormula = adapters.adaptCompiledFormula(compiledFormula);
        if (newCompiledFormula !== compiledFormula) {
          this.history.update(
            "compiledMeasureFormulas",
            pivotId,
            measureId,
            "formula",
            newCompiledFormula
          );
        }

        // adapt all dependencies (including indirect)
        this.history.update(
          "compiledMeasureFormulas",
          pivotId,
          measure.id,
          "dependencies",
          indirectDependencies.map((range) => applyChange(range).range)
        );
        const oldFormulaString = measure.computedBy.formula;
        const newFormulaString = adaptFormulaString(sheetId, oldFormulaString);
        if (newFormulaString !== oldFormulaString) {
          this.replaceMeasureFormula(pivotId, measure, newFormulaString);
        }
      }
    }
  }

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

  getPivotDisplayName(pivotId: UID) {
    const formulaId = this.getPivotFormulaId(pivotId);
    return `(#${formulaId}) ${this.getPivotName(pivotId)}`;
  }

  getPivotName(pivotId: UID) {
    return this.getPivotCore(pivotId).definition.name;
  }

  /**
   * Returns the pivot core definition of the pivot with the given id.
   * Be careful, this is the core definition, this should be used only in a
   * context where the pivot is not loaded yet.
   */
  getPivotCoreDefinition(pivotId: UID): PivotCoreDefinition {
    return this.getPivotCore(pivotId).definition;
  }

  /**
   * Get the pivot ID (UID) from the formula ID (the one used in the formula)
   */
  getPivotId(formulaId: string) {
    return this.formulaIds[formulaId];
  }

  getPivotFormulaId(pivotId: UID) {
    return this.getPivotCore(pivotId).formulaId;
  }

  getPivotIds(): UID[] {
    return Object.keys(this.pivots);
  }

  isExistingPivot(pivotId: UID) {
    return pivotId in this.pivots;
  }

  getMeasureCompiledFormula(pivotId: UID, measure: PivotCoreMeasure): CompiledFormula {
    if (!measure.computedBy) {
      throw new Error(`Measure ${measure.fieldName} is not computed by formula`);
    }
    return this.compiledMeasureFormulas[pivotId][measure.id].formula;
  }

  getMeasureFullDependencies(pivotId: UID, measure: PivotCoreMeasure): Range[] {
    if (!measure.computedBy) {
      throw new Error(`Measure ${measure.fieldName} is not computed by formula`);
    }
    return this.compiledMeasureFormulas[pivotId][measure.id].dependencies;
  }

  // -------------------------------------------------------------------------
  // Private
  // -------------------------------------------------------------------------

  private addPivot(
    pivotId: UID,
    pivot: PivotCoreDefinition,
    formulaId = this.nextFormulaId.toString()
  ) {
    this.history.update("pivots", pivotId, { definition: deepCopy(pivot), formulaId });
    this.compileCalculatedMeasures(pivotId, pivot.measures);
    this.history.update("formulaIds", formulaId, pivotId);
    this.history.update("nextFormulaId", this.nextFormulaId + 1);
  }

  private compileCalculatedMeasures(pivotId: UID, measures: PivotCoreMeasure[]) {
    for (const measure of measures) {
      if (measure.computedBy) {
        const compiledFormula = CompiledFormula.Compile(
          measure.computedBy.formula,
          measure.computedBy.sheetId,
          this.getters
        );
        this.history.update(
          "compiledMeasureFormulas",
          pivotId,
          measure.id,
          "formula",
          compiledFormula
        );
      }
    }
    for (const measure of measures) {
      if (measure.computedBy) {
        const dependencies = this.computeMeasureFullDependencies(pivotId, measure);
        this.history.update(
          "compiledMeasureFormulas",
          pivotId,
          measure.id,
          "dependencies",
          dependencies
        );
      }
    }
  }

  private computeMeasureFullDependencies(
    pivotId: UID,
    measure: PivotCoreMeasure,
    exploredMeasures: Set<string> = new Set()
  ): Range[] {
    const rangeDependencies: Range[] = [];
    const definition = this.getPivotCoreDefinition(pivotId);
    const formula = this.getMeasureCompiledFormula(pivotId, measure);
    exploredMeasures.add(measure.id);
    for (const usedSymbols of formula.symbols) {
      const otherMeasure = definition.measures.find(
        (measureCandidate) =>
          getCanonicalSymbolName(measureCandidate.id) === usedSymbols &&
          measure.id !== measureCandidate.id
      );

      if (!otherMeasure || exploredMeasures.has(otherMeasure.id) || !otherMeasure.computedBy) {
        continue;
      }

      rangeDependencies.push(
        ...this.computeMeasureFullDependencies(pivotId, otherMeasure, exploredMeasures)
      );
    }
    rangeDependencies.push(...formula.rangeDependencies.filter((range) => !range.invalidXc));
    rangeDependencies.push(
      ...formula.getNamedRangesInFormula(this.getters).map((namedRange) => namedRange.range)
    );
    return rangeDependencies;
  }

  private insertPivot(position: CellPosition, formulaId: UID, table: SpreadsheetPivotTable) {
    this.resizeSheet(position.sheetId, position, table);

    const pivotCells = table.getPivotCells();
    for (let col = 0; col < pivotCells.length; col++) {
      for (let row = 0; row < pivotCells[col].length; row++) {
        const pivotCell = pivotCells[col][row];
        this.dispatch("UPDATE_CELL", {
          sheetId: position.sheetId,
          col: position.col + col,
          row: position.row + row,
          content: createPivotFormula(formulaId, pivotCell),
        });
      }
    }
  }

  private resizeSheet(sheetId: UID, { col, row }: Position, table: SpreadsheetPivotTable) {
    const colLimit = table.getNumberOfDataColumns() + 1; // +1 for the Top-Left
    const numberCols = this.getters.getNumberCols(sheetId);
    const deltaCol = numberCols - col;
    if (deltaCol < colLimit) {
      this.dispatch("ADD_COLUMNS_ROWS", {
        dimension: "COL",
        base: numberCols - 1,
        sheetId: sheetId,
        sheetName: this.getters.getSheetName(sheetId),
        quantity: colLimit - deltaCol,
        position: "after",
      });
    }
    const rowLimit = table.columns.length + table.rows.length;
    const numberRows = this.getters.getNumberRows(sheetId);
    const deltaRow = numberRows - row;
    if (deltaRow < rowLimit) {
      this.dispatch("ADD_COLUMNS_ROWS", {
        dimension: "ROW",
        base: numberRows - 1,
        sheetId: sheetId,
        sheetName: this.getters.getSheetName(sheetId),
        quantity: rowLimit - deltaRow,
        position: "after",
      });
    }
  }

  private getPivotCore(pivotId: UID): Pivot {
    const pivot = this.pivots[pivotId];
    if (!pivot) {
      throw new Error(`Pivot with id ${pivotId} not found`);
    }
    return pivot;
  }

  private replaceMeasureFormula(pivotId: UID, measure: PivotCoreMeasure, newFormulaString: string) {
    const pivot = this.pivots[pivotId];
    if (!pivot) {
      return;
    }
    const measureIndex = pivot.definition.measures.indexOf(measure);
    this.history.update("pivots", pivotId, "definition", "measures", measureIndex, "computedBy", {
      formula: newFormulaString,
      sheetId: measure.computedBy!.sheetId,
    });
  }

  private checkSortedColumnInMeasures(definition: PivotCoreDefinition) {
    const measures = definition.measures.map((measure) => measure.id);
    if (definition.sortedColumn && !measures.includes(definition.sortedColumn.measure)) {
      return CommandResult.InvalidDefinition;
    }
    return CommandResult.Success;
  }

  private checkDuplicatedMeasureIds(definition: PivotCoreDefinition) {
    const uniqueIds = new Set(definition.measures.map((m) => m.id));
    if (definition.measures.length !== uniqueIds.size) {
      return CommandResult.InvalidDefinition;
    }
    return CommandResult.Success;
  }

  private checkCustomFieldsAreValid(definition: PivotCoreDefinition) {
    for (const customFieldName in definition.customFields) {
      const customField = definition.customFields[customFieldName];
      const groupedValues = new Set<CellValue>();
      const groupNames = new Set<string>();
      let hasOtherGroup = false;
      for (const group of customField.groups) {
        if (!group.name || groupNames.has(group.name)) {
          return CommandResult.InvalidPivotCustomField;
        }
        if (group.values.some((value) => groupedValues.has(value))) {
          return CommandResult.InvalidPivotCustomField;
        }
        if (group.isOtherGroup && hasOtherGroup) {
          return CommandResult.InvalidPivotCustomField;
        }
        group.values.forEach((value) => groupedValues.add(value));
        groupNames.add(group.name);
        hasOtherGroup ||= !!group.isOtherGroup;
      }
    }
    return CommandResult.Success;
  }

  getFormulas(): CompiledFormula[] {
    return Object.keys(this.pivots).flatMap((pivotId) => {
      const pivot = this.pivots[pivotId];
      if (!pivot) {
        return [];
      }
      return pivot.definition.measures
        .filter((measure) => measure.computedBy)
        .map((measure) => this.getMeasureCompiledFormula(pivotId, measure));
    });
  }

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

  /**
   * Import the pivots
   */
  import(data: WorkbookData) {
    if (data.pivots) {
      for (const [id, pivot] of Object.entries(data.pivots)) {
        this.addPivot(id, pivot, pivot.formulaId);
      }
    }
    this.history.update("nextFormulaId", data.pivotNextId || getMaxObjectId(this.pivots) + 1);
  }
  /**
   * Export the pivots
   */
  export(data: WorkbookData) {
    data.pivots = {};
    for (const pivotId in this.pivots) {
      data.pivots[pivotId] = {
        ...this.getPivotCoreDefinition(pivotId),
        formulaId: this.getPivotFormulaId(pivotId),
      };
    }
    data.pivotNextId = this.nextFormulaId;
  }
}
