/**
 * Modified version of  https://github.com/xiel/embla-carousel-wheel-gestures
 *
 * This fixes a bug where click events would not fire properly after scrolling
 * due to Embla assuming that they were related to dragging the carousel, which
 * was not a valid assumption with the way this plugin works.
 */

/**
 * MIT License
 * Copyright (c) 2020 Felix Leupold
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */
import {
  CreateOptionsType,
  CreatePluginType,
  EmblaCarouselType,
  OptionsHandlerType,
} from 'embla-carousel';
import WheelGestures, { WheelEventState } from 'wheel-gestures';

export type WheelGesturesPluginOptions = CreateOptionsType<{
  allowOverscroll: boolean;
  wheelDraggingClass: string;
  forceWheelAxis?: 'x' | 'y';
  target?: Element;
}>;

type WheelGesturesPluginType = CreatePluginType<
  { [key: string]: unknown },
  WheelGesturesPluginOptions
>;

const defaultOptions: WheelGesturesPluginOptions = {
  active: true,
  breakpoints: {},
  allowOverscroll: true,
  wheelDraggingClass: 'is-wheel-dragging',
  forceWheelAxis: undefined,
  target: undefined,
};

WheelGesturesPlugin.globalOptions = undefined as
  | WheelGesturesPluginType['options']
  | undefined;

const __DEV__ = process.env.NODE_ENV !== 'production';

export function WheelGesturesPlugin(
  userOptions: WheelGesturesPluginType['options'] = {}
): WheelGesturesPluginType {
  let options: WheelGesturesPluginOptions;
  let cleanup = () => {};

  function init(embla: EmblaCarouselType, optionsHandler: OptionsHandlerType) {
    const { mergeOptions, optionsAtMedia } = optionsHandler;
    const optionsBase = mergeOptions(
      defaultOptions,
      WheelGesturesPlugin.globalOptions
    );
    const allOptions = mergeOptions(optionsBase, userOptions);
    options = optionsAtMedia(allOptions);

    const engine = embla.internalEngine();
    const targetNode =
      options.target ?? (embla.containerNode().parentNode as Element);
    const wheelAxis = options.forceWheelAxis ?? engine.options.axis;
    const wheelGestures = WheelGestures({
      preventWheelAction: wheelAxis,
      reverseSign: [true, true, false],
    });

    const unobserveTargetNode = wheelGestures.observe(targetNode);
    const offWheel = wheelGestures.on('wheel', handleWheel);

    let isStarted = false;
    let startEvent: MouseEvent;

    function wheelGestureStarted(state: WheelEventState) {
      // >>> BEGIN SUNO MODIFICATION! <<<
      // If we know we don't want to allow overscrollm don't send mouse events
      // when we're already at the carousel's limit. This prevents avoids UI
      // jank when trying to scroll beyond the constraints.
      if (!options.allowOverscroll) {
        // If the min/max limits are the same, there's nothing to scroll
        if (engine.limit.min >= engine.limit.max) {
          return;
        }
        // Otherwise, just don't allow scrolling beyond the max
        const targetPosition = engine.target.get();
        const reachedMax = targetPosition >= engine.limit.max;
        const reachedMin = targetPosition <= engine.limit.min;
        const {
          axisDelta: [deltaX, deltaY],
        } = state;
        const primaryAxisDelta = wheelAxis === 'x' ? deltaX : deltaY;
        if (reachedMax && primaryAxisDelta > 0) {
          return;
        } else if (reachedMin && primaryAxisDelta < 0) {
          return;
        }
      }
      // >>> END SUNO MODIFICATION! <<<
      try {
        startEvent = new MouseEvent('mousedown', state.event);
        dispatchEvent(startEvent);
      } catch (e) {
        // Legacy Browsers like IE 10 & 11 will throw when attempting to create the Event
        if (__DEV__) {
          console.warn(
            'Legacy browser requires events-polyfill (https://github.com/xiel/embla-carousel-wheel-gestures#legacy-browsers)'
          );
        }
        return cleanup();
      }

      isStarted = true;
      addNativeMouseEventListeners();

      if (options.wheelDraggingClass) {
        targetNode.classList.add(options.wheelDraggingClass);
      }
    }

    function wheelGestureEnded(state: WheelEventState) {
      isStarted = false;
      dispatchEvent(createRelativeMouseEvent('mouseup', state));
      removeNativeMouseEventListeners();

      // >>> BEGIN SUNO MODIFICATION! <<<
      // Embla's `mousemove` handler will eat the next click event, because
      // normally the `mouseup` event would be followed by an unwanted `click`.
      // However, since this plugin is creating fake mouse events, we won't get
      // a real native click event, so we need to trigger it ourselves to allow
      // allow carousel items to respond to real clicks after scrolling.
      // @TODO: The simulated `mouseup` can trick Embla into thinking it needs
      // to handle a "flick" of the carousel that looks a little weird.
      // It would be better to honor the scroll momentum as-is, but that may
      // require a different approach than this plugin is already using.
      if (Math.abs(state.axisMovement[0]) > engine.options.dragThreshold) {
        dispatchEvent(createRelativeMouseEvent('click', state));
      }
      // >>> END SUNO MODIFICATION! <<<

      if (options.wheelDraggingClass) {
        targetNode.classList.remove(options.wheelDraggingClass);
      }
    }

    function addNativeMouseEventListeners() {
      document.documentElement.addEventListener(
        'mousemove',
        preventNativeMouseHandler,
        true
      );
      document.documentElement.addEventListener(
        'mouseup',
        preventNativeMouseHandler,
        true
      );
      document.documentElement.addEventListener(
        'mousedown',
        preventNativeMouseHandler,
        true
      );
    }

    function removeNativeMouseEventListeners() {
      document.documentElement.removeEventListener(
        'mousemove',
        preventNativeMouseHandler,
        true
      );
      document.documentElement.removeEventListener(
        'mouseup',
        preventNativeMouseHandler,
        true
      );
      document.documentElement.removeEventListener(
        'mousedown',
        preventNativeMouseHandler,
        true
      );
    }

    function preventNativeMouseHandler(e: MouseEvent) {
      if (isStarted && e.isTrusted) {
        e.stopImmediatePropagation();
      }
    }

    function createRelativeMouseEvent(
      type: 'mousedown' | 'mousemove' | 'mouseup' | 'click',
      state: WheelEventState
    ) {
      let moveX, moveY;

      if (wheelAxis === engine.options.axis) {
        [moveX, moveY] = state.axisMovement;
      } else {
        // if emblas axis and the wheelAxis don't match, swap the axes to match the right embla events
        [moveY, moveX] = state.axisMovement;
      }

      // prevent skipping slides
      if (!engine.options.skipSnaps && !engine.options.dragFree) {
        const maxX = engine.containerRect.width;
        const maxY = engine.containerRect.height;

        moveX = moveX < 0 ? Math.max(moveX, -maxX) : Math.min(moveX, maxX);
        moveY = moveY < 0 ? Math.max(moveY, -maxY) : Math.min(moveY, maxY);
      }

      return new MouseEvent(type, {
        clientX: startEvent.clientX + moveX,
        clientY: startEvent.clientY + moveY,
        screenX: startEvent.screenX + moveX,
        screenY: startEvent.screenY + moveY,
        movementX: moveX,
        movementY: moveY,
        button: 0,
        bubbles: true,
        cancelable: true,
        composed: true,
      });
    }

    function dispatchEvent(event: UIEvent) {
      embla.containerNode().dispatchEvent(event);
    }

    function handleWheel(state: WheelEventState) {
      const {
        axisDelta: [deltaX, deltaY],
      } = state;
      const primaryAxisDelta = wheelAxis === 'x' ? deltaX : deltaY;
      const crossAxisDelta = wheelAxis === 'x' ? deltaY : deltaX;
      const isRelease =
        state.isMomentum && state.previous && !state.previous.isMomentum;
      const isEndingOrRelease =
        (state.isEnding && !state.isMomentum) || isRelease;
      const primaryAxisDeltaIsDominant =
        Math.abs(primaryAxisDelta) > Math.abs(crossAxisDelta);

      if (primaryAxisDeltaIsDominant && !isStarted && !state.isMomentum) {
        wheelGestureStarted(state);
      }

      if (!isStarted) return;
      if (isEndingOrRelease) {
        wheelGestureEnded(state);
      } else {
        dispatchEvent(createRelativeMouseEvent('mousemove', state));
      }
    }

    cleanup = () => {
      unobserveTargetNode();
      offWheel();
      removeNativeMouseEventListeners();
    };
  }

  const self: WheelGesturesPluginType = {
    name: 'wheelGestures',
    options: userOptions,
    init,
    destroy: () => cleanup(),
  };
  return self;
}
