import {
  BoundsType,
  LibrarySetup,
  PositionType,
  VelocityType,
  AnimationType,
  ReactZoomPanPinchProps,
  ReactZoomPanPinchState,
  ReactZoomPanPinchRef,
  DeviceType,
} from "../models";
import {
  getContext,
  createSetup,
  createState,
  handleCallback,
  getTransformStyles,
  makePassiveEventOption,
  getCenterPosition,
  assignRef,
} from "../utils";
import { handleCancelAnimation } from "./animations/animations.utils";
import { isWheelAllowed, isWheelPanningAllowed } from "./wheel/wheel.utils";
import { isPinchAllowed, isPinchStartAllowed } from "./pinch/pinch.utils";
import { handleCalculateBounds } from "./bounds/bounds.utils";
import {
  handleWheelStart,
  handleWheelZoom,
  handleWheelStop,
  handleWheelPanningStop,
  handleWheelPanningStart,
} from "./wheel/wheel.logic";
import {
  getPaddingValue,
  handleNewPosition,
  isPanningAllowed,
  isPanningStartAllowed,
} from "./pan/panning.utils";
import {
  handlePanning,
  handlePanningEnd,
  handlePanningStart,
} from "./pan/panning.logic";
import {
  handlePinchStart,
  handlePinchStop,
  handlePinchZoom,
} from "./pinch/pinch.logic";
import {
  handleDoubleClick,
  isDoubleClickAllowed,
} from "./double-click/double-click.logic";

type StartCoordsType = { x: number; y: number } | null;

export class ZoomPanPinch {
  public props: ReactZoomPanPinchProps;

  public mounted = true;

  public state: ReactZoomPanPinchState;
  public setup: LibrarySetup;
  public observer?: ResizeObserver;
  public onChangeCallbacks: Set<(ctx: ReactZoomPanPinchRef) => void> =
    new Set();
  public onInitCallbacks: Set<(ctx: ReactZoomPanPinchRef) => void> = new Set();
  public onTransformCallbacks: Set<
    (data: {
      scale: number;
      positionX: number;
      positionY: number;
      previousScale: number;
      ref: ReactZoomPanPinchRef;
    }) => void
  > = new Set();

  // Components
  public wrapperComponent: HTMLDivElement | null = null;
  public contentComponent: HTMLDivElement | null = null;
  // Initialization
  public isInitialized = false;
  public bounds: BoundsType | null = null;
  // wheel helpers
  public previousWheelEvent: WheelEvent | null = null;
  public wheelStopEventTimer: ReturnType<typeof setTimeout> | null = null;
  public wheelAnimationTimer: ReturnType<typeof setTimeout> | null = null;
  // panning helpers
  public isPanning = false;
  public isWheelPanning = false;
  public startCoords: StartCoordsType = null;
  public panStartPosition: { x: number; y: number } | null = null;
  public lastTouch: number | null = null;
  // pinch helpers
  public isPinching = false;
  public distance: null | number = null;
  public lastDistance: null | number = null;
  public pinchStartDistance: null | number = null;
  public pinchStartScale: null | number = null;
  public pinchMidpoint: null | PositionType = null;
  public pinchPreviousCenter: null | PositionType = null;
  // double click helpers
  public doubleClickStopEventTimer: ReturnType<typeof setTimeout> | null = null;
  // velocity helpers
  public velocity: VelocityType | null = null;
  public velocityTime: number | null = null;
  public lastMousePosition: PositionType | null = null;
  // animations helpers
  public isAnimating = false;
  public animation: AnimationType | null = null;
  // key press
  public pressedKeys: { [key: string]: boolean } = {};

  constructor(props: ReactZoomPanPinchProps) {
    this.props = props;
    this.setup = createSetup(this.props);
    this.state = createState(this.props);
  }

  mount = () => {
    this.initializeWindowEvents();
  };

  unmount = () => {
    this.cleanupWindowEvents();
  };

  update = (newProps: ReactZoomPanPinchProps) => {
    this.props = newProps;
    if (this.wrapperComponent && this.contentComponent) {
      handleCalculateBounds(this, this.state.scale);
    }
    this.setup = createSetup(newProps);
  };

  initializeWindowEvents = (): void => {
    const passive = makePassiveEventOption();
    const currentDocument = this.wrapperComponent?.ownerDocument;
    const currentWindow = currentDocument?.defaultView;
    this.wrapperComponent?.addEventListener(
      "wheel",
      this.onWheelPanning,
      passive,
    );
    this.wrapperComponent?.addEventListener(
      "keyup",
      this.setKeyUnPressed,
      passive,
    );
    this.wrapperComponent?.addEventListener(
      "keydown",
      this.setKeyPressed,
      passive,
    );
    // Panning on window to allow panning when mouse is out of component wrapper
    currentWindow?.addEventListener("mousedown", this.onPanningStart, passive);
    currentWindow?.addEventListener("mousemove", this.onPanning, passive);
    currentWindow?.addEventListener("mouseup", this.onPanningStop, passive);
    currentDocument?.addEventListener("mouseleave", this.clearPanning, passive);
    currentWindow?.addEventListener("keyup", this.setKeyUnPressed, passive);
    currentWindow?.addEventListener("keydown", this.setKeyPressed, passive);
    currentWindow?.addEventListener("blur", this.handleWindowBlur);
  };

  cleanupWindowEvents = (): void => {
    const passive = makePassiveEventOption();
    const currentDocument = this.wrapperComponent?.ownerDocument;
    const currentWindow = currentDocument?.defaultView;
    currentWindow?.removeEventListener(
      "mousedown",
      this.onPanningStart,
      passive,
    );
    currentWindow?.removeEventListener("mousemove", this.onPanning, passive);
    currentWindow?.removeEventListener("mouseup", this.onPanningStop, passive);
    currentDocument?.removeEventListener(
      "mouseleave",
      this.clearPanning,
      passive,
    );
    currentWindow?.removeEventListener("keyup", this.setKeyUnPressed, passive);
    currentWindow?.removeEventListener("keydown", this.setKeyPressed, passive);
    currentWindow?.removeEventListener("blur", this.handleWindowBlur);
    document.removeEventListener("mouseleave", this.clearPanning, passive);
    this.wrapperComponent?.removeEventListener(
      "wheel",
      this.onWheelPanning,
      passive,
    );
    this.wrapperComponent?.removeEventListener(
      "keyup",
      this.setKeyUnPressed,
      passive,
    );
    this.wrapperComponent?.removeEventListener(
      "keydown",
      this.setKeyPressed,
      passive,
    );
    handleCancelAnimation(this);
    this.observer?.disconnect();
  };

  handleInitializeWrapperEvents = (wrapper: HTMLDivElement): void => {
    // Zooming events on wrapper
    const passive = makePassiveEventOption();

    wrapper.addEventListener("wheel", this.onWheelZoom, passive);
    wrapper.addEventListener("dblclick", this.onDoubleClick, passive);
    wrapper.addEventListener("touchstart", this.onTouchPanningStart, passive);
    wrapper.addEventListener("touchmove", this.onTouchPanning, passive);
    wrapper.addEventListener("touchend", this.onTouchPanningStop, passive);
  };

  handleInitialize = (contentComponent: HTMLDivElement): void => {
    const { centerOnInit } = this.setup;
    this.applyTransformation();
    this.onInitCallbacks.forEach((callback) => callback(getContext(this)));

    if (centerOnInit) {
      this.setCenter();
      this.observer = new ResizeObserver(() => {
        const currentWidth = contentComponent.offsetWidth;
        const currentHeight = contentComponent.offsetHeight;

        if (currentWidth > 0 || currentHeight > 0) {
          this.onInitCallbacks.forEach((callback) =>
            callback(getContext(this)),
          );
          this.setCenter();

          this.observer?.disconnect();
        }
      });

      // TODO: CHANGE to first interaction?
      // if nothing about the contentComponent has changed after 5 seconds, disconnect the observer
      setTimeout(() => {
        this.observer?.disconnect();
      }, 5000);

      // Start observing the target node for configured mutations
      this.observer.observe(contentComponent);
    }
  };

  /// ///////
  // Zoom
  /// ///////

  onWheelZoom = (event: WheelEvent): void => {
    const { disabled } = this.setup;
    if (disabled) return;

    this.syncModifierKeys(event);

    const isAllowed = isWheelAllowed(this, event);
    if (!isAllowed) return;

    handleWheelStart(this, event);
    handleWheelZoom(this, event);
    handleWheelStop(this, event);
  };

  /// ///////
  // Track Pad Panning
  /// ///////

  onWheelPanning = (event: WheelEvent): void => {
    const { onPanning } = this.props;
    const { trackPadPanning } = this.setup;
    const { lockAxisX, lockAxisY } = trackPadPanning;

    this.syncModifierKeys(event);

    const isAllowed = isWheelPanningAllowed(this, event);

    if (!isAllowed) return;

    event.preventDefault();
    event.stopPropagation();

    const { positionX, positionY } = this.state;
    const mouseX = positionX - event.deltaX;
    const mouseY = positionY - event.deltaY;
    const newPositionX = lockAxisX ? positionX : mouseX;
    const newPositionY = lockAxisY ? positionY : mouseY;

    const { sizeX, sizeY } = this.setup.autoAlignment;
    const paddingValueX = getPaddingValue(this, sizeX);
    const paddingValueY = getPaddingValue(this, sizeY);

    if (newPositionX === positionX && newPositionY === positionY) return;

    handleWheelPanningStart(this, event);
    handleNewPosition(
      this,
      newPositionX,
      newPositionY,
      paddingValueX,
      paddingValueY,
    );
    handleCallback(getContext(this), event, onPanning);
    handleWheelPanningStop(this, event);
  };

  /// ///////
  // Pan
  /// ///////

  onPanningStart = (event: MouseEvent): void => {
    const { disabled } = this.setup;
    const { onPanningStart } = this.props;
    if (disabled) return;

    this.syncModifierKeys(event);

    const isAllowed = isPanningStartAllowed(this, event);
    if (!isAllowed) return;

    const keysPressed = this.isPressingKeys(this.setup.panning.activationKeys);
    if (!keysPressed) return;

    if (event.button === 0 && !this.setup.panning.allowLeftClickPan) return;
    if (event.button === 1 && !this.setup.panning.allowMiddleClickPan) return;
    if (event.button === 2 && !this.setup.panning.allowRightClickPan) return;

    event.preventDefault();
    event.stopPropagation();

    handleCancelAnimation(this);
    handlePanningStart(this, event);
    handleCallback(getContext(this), event, onPanningStart);
  };

  onPanning = (event: MouseEvent): void => {
    const { disabled } = this.setup;
    const { onPanning } = this.props;

    if (disabled) return;

    this.syncModifierKeys(event);

    // Detect missed mouseup — e.g. when the mouse was released outside an
    // iframe boundary where the host frame swallows the mouseup event.
    if (this.isPanning && event.buttons === 0) {
      this.clearPanning(event);
      return;
    }

    const isAllowed = isPanningAllowed(this);
    if (!isAllowed) return;

    const keysPressed = this.isPressingKeys(this.setup.panning.activationKeys);
    if (!keysPressed) return;

    event.preventDefault();
    event.stopPropagation();

    handlePanning(this, event.clientX, event.clientY, DeviceType.MOUSE);
    handleCallback(getContext(this), event, onPanning);
  };

  onPanningStop = (event: MouseEvent | TouchEvent): void => {
    const { velocityDisabled } = this.setup.panning;
    const { onPanningStop } = this.props;

    if (this.isPanning) {
      handlePanningEnd(this, velocityDisabled);
      handleCallback(getContext(this), event, onPanningStop);
    }
  };

  /// ///////
  // Pinch
  /// ///////

  onPinchStart = (event: TouchEvent): void => {
    const { disabled } = this.setup;
    const { onPinchStart } = this.props;

    if (disabled) return;

    const isAllowed = isPinchStartAllowed(this, event);
    if (!isAllowed) return;

    handlePinchStart(this, event);
    handleCancelAnimation(this);
    handleCallback(getContext(this), event, onPinchStart);
  };

  onPinch = (event: TouchEvent): void => {
    const { disabled } = this.setup;
    const { onPinch } = this.props;

    if (disabled) return;

    const isAllowed = isPinchAllowed(this);
    if (!isAllowed) return;

    event.preventDefault();
    event.stopPropagation();

    handlePinchZoom(this, event);
    handleCallback(getContext(this), event, onPinch);
  };

  onPinchStop = (event: TouchEvent): void => {
    const { onPinchStop } = this.props;

    if (this.pinchStartScale) {
      handlePinchStop(this);
      handleCallback(getContext(this), event, onPinchStop);
    }
  };

  /// ///////
  // Touch
  /// ///////

  onTouchPanningStart = (event: TouchEvent): void => {
    const { disabled, doubleClick } = this.setup;
    const { onPanningStart } = this.props;

    if (disabled) return;

    const isDoubleTapAllowed = !doubleClick?.disabled;
    const isDoubleTap = this.lastTouch && +new Date() - this.lastTouch < 200;

    if (isDoubleTapAllowed && isDoubleTap && event.touches.length === 1) {
      this.onDoubleClick(event);
    } else {
      this.lastTouch = +new Date();

      handleCancelAnimation(this);

      const { touches } = event;

      const isPanningAction = touches.length === 1;
      const isPinchAction = touches.length === 2;

      const isAllowed = isPanningStartAllowed(this, event);
      if (isPanningAction) {
        if (!isAllowed) return;
        handleCancelAnimation(this);
        handlePanningStart(this, event);
        handleCallback(getContext(this), event, onPanningStart);
      }
      if (isPinchAction) {
        this.onPinchStart(event);
      }
    }
  };

  onTouchPanning = (event: TouchEvent): void => {
    const { disabled } = this.setup;
    const { onPanning } = this.props;

    if (this.isPanning && event.touches.length === 1) {
      if (disabled) return;

      const isAllowed = isPanningAllowed(this);
      if (!isAllowed) return;

      if (event.cancelable) {
        event.preventDefault();
      }
      event.stopPropagation();

      const touch = event.touches[0];
      handlePanning(this, touch.clientX, touch.clientY, DeviceType.TOUCH);
      handleCallback(getContext(this), event, onPanning);
    } else if (event.touches.length > 1) {
      this.onPinch(event);
    }
  };

  onTouchPanningStop = (event: TouchEvent): void => {
    this.onPanningStop(event);
    this.onPinchStop(event);
  };

  /// ///////
  // Double Click
  /// ///////

  onDoubleClick = (event: MouseEvent | TouchEvent): void => {
    const { disabled } = this.setup;
    if (disabled) return;

    const isAllowed = isDoubleClickAllowed(this, event);
    if (!isAllowed) return;

    handleDoubleClick(this, event);
  };

  /// ///////
  // Helpers
  /// ///////

  clearPanning = (event: MouseEvent): void => {
    if (this.isPanning) {
      this.onPanningStop(event);
    }
  };

  // When the window loses focus (e.g. user clicks outside an iframe),
  // keyup and mouseup events are swallowed by the parent frame. Clear all
  // tracked state to prevent stale activation keys or ghost panning.
  handleWindowBlur = (): void => {
    this.pressedKeys = {};
    if (this.isPanning) {
      this.isPanning = false;
      this.startCoords = null;
    }
  };

  // Iframe focus problem (e.g. Storybook):
  //
  // When the library runs inside an iframe, keyboard events (keydown/keyup)
  // only reach the iframe's window when it has focus. If the user navigates
  // via the host UI (e.g. Storybook sidebar) the iframe never receives
  // focus, so keydown never fires and activationKeys like Cmd/Ctrl are
  // invisible to pressedKeys.
  //
  // Mouse and wheel events, however, DO reach the iframe regardless of
  // focus — and they carry modifier flags (ctrlKey, metaKey, shiftKey,
  // altKey) that always reflect the real physical key state at event time.
  //
  // We sync those flags into pressedKeys on every interaction event so
  // that activationKeys checks work without requiring iframe focus.
  // Both pressed (true) AND released (false) states must be written;
  // writing only `true` would leave stale keys after release because
  // keyup never fires in an unfocused iframe.
  syncModifierKeys = (event: MouseEvent | WheelEvent | TouchEvent): void => {
    const { ctrlKey, metaKey, shiftKey, altKey } = event;

    if (typeof ctrlKey === "boolean") this.pressedKeys.Control = ctrlKey;
    if (typeof metaKey === "boolean") this.pressedKeys.Meta = metaKey;
    if (typeof shiftKey === "boolean") this.pressedKeys.Shift = shiftKey;
    if (typeof altKey === "boolean") this.pressedKeys.Alt = altKey;
  };

  setKeyPressed = (e: KeyboardEvent): void => {
    this.pressedKeys[e.key] = true;
  };

  setKeyUnPressed = (e: KeyboardEvent): void => {
    this.pressedKeys[e.key] = false;
  };

  isPressingKeys = (
    keys: string[] | ((keys: string[]) => boolean),
  ): boolean => {
    if (typeof keys === "function") {
      return keys(
        Object.entries(this.pressedKeys)
          .filter(([, pressed]) => pressed)
          .map(([key]) => key),
      );
    }
    if (!keys.length) {
      return true;
    }
    return Boolean(keys.every((key) => this.pressedKeys[key]));
  };

  setCenter = (): void => {
    if (this.wrapperComponent && this.contentComponent) {
      const targetState = getCenterPosition(
        this.state.scale,
        this.wrapperComponent,
        this.contentComponent,
      );
      this.setState(
        targetState.scale,
        targetState.positionX,
        targetState.positionY,
      );
    }
  };

  handleTransformStyles = (x: number, y: number, scale: number) => {
    if (this.props.customTransform) {
      return this.props.customTransform(x, y, scale);
    }
    return getTransformStyles(x, y, scale);
  };

  getContext = () => {
    return getContext(this);
  };

  applyTransformation = (): void => {
    if (!this.mounted || !this.contentComponent) return;
    const { scale, positionX, positionY } = this.state;
    const transform = this.handleTransformStyles(positionX, positionY, scale);

    // Detached mode do not apply transformation directly to content component
    if (!this.props.detached) {
      this.contentComponent.style.transform = transform;
    }

    this.onTransformCallbacks.forEach((callback) =>
      callback({
        scale,
        positionX,
        positionY,
        previousScale: this.state.previousScale,
        ref: getContext(this),
      }),
    );
  };

  setState = (scale: number, positionX: number, positionY: number): void => {
    const { onTransform } = this.props;

    if (
      !Number.isNaN(scale) &&
      !Number.isNaN(positionX) &&
      !Number.isNaN(positionY)
    ) {
      const safeScale = Math.max(scale, 1e-7);

      if (safeScale !== this.state.scale) {
        this.state.previousScale = this.state.scale;
        this.state.scale = safeScale;
      }

      this.state.positionX = positionX;
      this.state.positionY = positionY;

      this.applyTransformation();
      const ctx = getContext(this);
      this.onChangeCallbacks.forEach((callback) => callback(ctx));
      handleCallback(
        ctx,
        { scale: this.state.scale, positionX, positionY },
        onTransform,
      );
    } else {
      console.error("Detected NaN set state values");
    }
  };

  /**
   * Hooks
   */

  onTransform = (
    callback: (data: {
      scale: number;
      positionX: number;
      positionY: number;
      previousScale: number;
      ref: ReactZoomPanPinchRef;
    }) => void,
  ) => {
    if (!this.onTransformCallbacks.has(callback)) {
      this.onTransformCallbacks.add(callback);
    }
    return () => {
      this.onTransformCallbacks.delete(callback);
    };
  };

  onChange = (callback: (ref: ReactZoomPanPinchRef) => void) => {
    if (!this.onChangeCallbacks.has(callback)) {
      this.onChangeCallbacks.add(callback);
    }
    return () => {
      this.onChangeCallbacks.delete(callback);
    };
  };

  onInit = (callback: (ref: ReactZoomPanPinchRef) => void) => {
    if (!this.onInitCallbacks.has(callback)) {
      this.onInitCallbacks.add(callback);
    }
    return () => {
      this.onInitCallbacks.delete(callback);
    };
  };

  /**
   * Initialization
   */

  init = (
    wrapperComponent: HTMLDivElement,
    contentComponent: HTMLDivElement,
  ): void => {
    this.cleanupWindowEvents();
    this.wrapperComponent = wrapperComponent;
    this.contentComponent = contentComponent;
    handleCalculateBounds(this, this.state.scale);
    this.handleInitializeWrapperEvents(wrapperComponent);
    this.handleInitialize(contentComponent);
    this.initializeWindowEvents();
    this.isInitialized = true;
    const ctx = getContext(this);
    handleCallback(ctx, undefined, this.props.onInit);
    assignRef(this.props.ref, ctx);
  };
}
