import { register } from "./register";
import { getSelectedElements } from "../scene";
import { getNonDeletedElements } from "../element";
import { mutateElement } from "../element/mutateElement";
import { ExcalidrawElement, NonDeleted } from "../element/types";
import { normalizeAngle, resizeSingleElement } from "../element/resizeElements";
import { AppState } from "../types";
import { getTransformHandles } from "../element/transformHandles";
import { updateBoundElements } from "../element/binding";
import { arrayToMap } from "../utils";
import {
  getElementAbsoluteCoords,
  getElementPointsCoords,
} from "../element/bounds";
import { isLinearElement } from "../element/typeChecks";
import { LinearElementEditor } from "../element/linearElementEditor";
import { KEYS } from "../keys";

const enableActionFlipHorizontal = (
  elements: readonly ExcalidrawElement[],
  appState: AppState,
) => {
  const eligibleElements = getSelectedElements(
    getNonDeletedElements(elements),
    appState,
  );
  return eligibleElements.length === 1 && eligibleElements[0].type !== "text";
};

const enableActionFlipVertical = (
  elements: readonly ExcalidrawElement[],
  appState: AppState,
) => {
  const eligibleElements = getSelectedElements(
    getNonDeletedElements(elements),
    appState,
  );
  return eligibleElements.length === 1;
};

export const actionFlipHorizontal = register({
  name: "flipHorizontal",
  trackEvent: { category: "element" },
  perform: (elements, appState) => {
    return {
      elements: flipSelectedElements(elements, appState, "horizontal"),
      appState,
      commitToHistory: true,
    };
  },
  keyTest: (event) => event.shiftKey && event.code === "KeyH",
  contextItemLabel: "labels.flipHorizontal",
  predicate: (elements, appState) =>
    enableActionFlipHorizontal(elements, appState),
});

export const actionFlipVertical = register({
  name: "flipVertical",
  trackEvent: { category: "element" },
  perform: (elements, appState) => {
    return {
      elements: flipSelectedElements(elements, appState, "vertical"),
      appState,
      commitToHistory: true,
    };
  },
  keyTest: (event) =>
    event.shiftKey && event.code === "KeyV" && !event[KEYS.CTRL_OR_CMD],
  contextItemLabel: "labels.flipVertical",
  predicate: (elements, appState) =>
    enableActionFlipVertical(elements, appState),
});

const flipSelectedElements = (
  elements: readonly ExcalidrawElement[],
  appState: Readonly<AppState>,
  flipDirection: "horizontal" | "vertical",
) => {
  const selectedElements = getSelectedElements(
    getNonDeletedElements(elements),
    appState,
  );

  // remove once we allow for groups of elements to be flipped
  if (selectedElements.length > 1) {
    return elements;
  }

  const updatedElements = flipElements(
    selectedElements,
    appState,
    flipDirection,
  );

  const updatedElementsMap = arrayToMap(updatedElements);

  return elements.map(
    (element) => updatedElementsMap.get(element.id) || element,
  );
};

const flipElements = (
  elements: NonDeleted<ExcalidrawElement>[],
  appState: AppState,
  flipDirection: "horizontal" | "vertical",
): ExcalidrawElement[] => {
  elements.forEach((element) => {
    flipElement(element, appState);
    // If vertical flip, rotate an extra 180
    if (flipDirection === "vertical") {
      rotateElement(element, Math.PI);
    }
  });
  return elements;
};

const flipElement = (
  element: NonDeleted<ExcalidrawElement>,
  appState: AppState,
) => {
  const originalX = element.x;
  const originalY = element.y;
  const width = element.width;
  const height = element.height;
  const originalAngle = normalizeAngle(element.angle);

  // Rotate back to zero, if necessary
  mutateElement(element, {
    angle: normalizeAngle(0),
  });
  // Flip unrotated by pulling TransformHandle to opposite side
  const transformHandles = getTransformHandles(element, appState.zoom);
  let usingNWHandle = true;
  let nHandle = transformHandles.nw;
  if (!nHandle) {
    // Use ne handle instead
    usingNWHandle = false;
    nHandle = transformHandles.ne;
    if (!nHandle) {
      mutateElement(element, {
        angle: originalAngle,
      });
      return;
    }
  }

  let finalOffsetX = 0;
  if (isLinearElement(element) && element.points.length < 3) {
    finalOffsetX =
      element.points.reduce((max, point) => Math.max(max, point[0]), 0) * 2 -
      element.width;
  }

  let initialPointsCoords;
  if (isLinearElement(element)) {
    initialPointsCoords = getElementPointsCoords(element, element.points);
  }
  const initialElementAbsoluteCoords = getElementAbsoluteCoords(element);

  if (isLinearElement(element) && element.points.length < 3) {
    for (let index = 1; index < element.points.length; index++) {
      LinearElementEditor.movePoints(element, [
        {
          index,
          point: [-element.points[index][0], element.points[index][1]],
        },
      ]);
    }
    LinearElementEditor.normalizePoints(element);
  } else {
    const elWidth = initialPointsCoords
      ? initialPointsCoords[2] - initialPointsCoords[0]
      : initialElementAbsoluteCoords[2] - initialElementAbsoluteCoords[0];

    const startPoint = initialPointsCoords
      ? [initialPointsCoords[0], initialPointsCoords[1]]
      : [initialElementAbsoluteCoords[0], initialElementAbsoluteCoords[1]];

    resizeSingleElement(
      new Map().set(element.id, element),
      false,
      element,
      usingNWHandle ? "nw" : "ne",
      true,
      usingNWHandle ? startPoint[0] + elWidth : startPoint[0] - elWidth,
      startPoint[1],
    );
  }

  // Rotate by (360 degrees - original angle)
  let angle = normalizeAngle(2 * Math.PI - originalAngle);
  if (angle < 0) {
    // check, probably unnecessary
    angle = normalizeAngle(angle + 2 * Math.PI);
  }
  mutateElement(element, {
    angle,
  });

  // Move back to original spot to appear "flipped in place"
  mutateElement(element, {
    x: originalX + finalOffsetX,
    y: originalY,
    width,
    height,
  });

  updateBoundElements(element);

  if (initialPointsCoords && isLinearElement(element)) {
    // Adjusting origin because when a beizer curve path exceeds min/max points it offsets the origin.
    // There's still room for improvement since when the line roughness is > 1
    // we still have a small offset of the origin when fliipping the element.
    const finalPointsCoords = getElementPointsCoords(element, element.points);

    const topLeftCoordsDiff = initialPointsCoords[0] - finalPointsCoords[0];
    const topRightCoordDiff = initialPointsCoords[2] - finalPointsCoords[2];

    const coordsDiff = topLeftCoordsDiff + topRightCoordDiff;

    mutateElement(element, {
      x: element.x + coordsDiff * 0.5,
      y: element.y,
      width,
      height,
    });
  }
};

const rotateElement = (element: ExcalidrawElement, rotationAngle: number) => {
  const originalX = element.x;
  const originalY = element.y;
  let angle = normalizeAngle(element.angle + rotationAngle);
  if (angle < 0) {
    // check, probably unnecessary
    angle = normalizeAngle(2 * Math.PI + angle);
  }
  mutateElement(element, {
    angle,
  });

  // Move back to original spot
  mutateElement(element, {
    x: originalX,
    y: originalY,
  });
};
