// @flow
import type { Position } from 'css-box-model';
import { useRef } from 'react';
import invariant from 'tiny-invariant';
import { useMemo, useCallback } from 'use-memo-one';
import type { EventBinding } from '../util/event-types';
import createEventMarshal, {
  type EventMarshal,
} from '../util/create-event-marshal';
import type { Callbacks } from '../drag-handle-types';
import { bindEvents, unbindEvents } from '../util/bind-events';
import createScheduler from '../util/create-scheduler';
import * as keyCodes from '../../key-codes';
import supportedPageVisibilityEventName from '../util/supported-page-visibility-event-name';
import createPostDragEventPreventer, {
  type EventPreventer,
} from '../util/create-post-drag-event-preventer';
import isSloppyClickThresholdExceeded from '../util/is-sloppy-click-threshold-exceeded';
import preventStandardKeyEvents from '../util/prevent-standard-key-events';

export type Args = {|
  callbacks: Callbacks,
  onCaptureStart: (abort: Function) => void,
  onCaptureEnd: () => void,
  getDraggableRef: () => ?HTMLElement,
  getWindow: () => HTMLElement,
  canStartCapturing: (event: Event) => boolean,
  getShouldRespectForcePress: () => boolean,
|};

export type OnMouseDown = (event: MouseEvent) => void;

// Custom event format for force press inputs
// https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button
const primaryButton: number = 0;
const noop = () => {};

// shared management of mousedown without needing to call preventDefault()
const mouseDownMarshal: EventMarshal = createEventMarshal();

export default function useMouseSensor(args: Args): OnMouseDown {
  const {
    canStartCapturing,
    getWindow,
    callbacks,
    // Currently always respecting force press due to safari bug (see below)
    // getShouldRespectForcePress,
    onCaptureStart,
    onCaptureEnd,
  } = args;
  const pendingRef = useRef<?Position>(null);
  const isDraggingRef = useRef<boolean>(false);
  const unbindWindowEventsRef = useRef<() => void>(noop);
  const getIsCapturing = useCallback(
    () => Boolean(pendingRef.current || isDraggingRef.current),
    [],
  );

  const schedule = useMemo(() => {
    invariant(
      !getIsCapturing(),
      'Should not recreate scheduler while capturing',
    );
    return createScheduler(callbacks);
  }, [callbacks, getIsCapturing]);

  const postDragEventPreventer: EventPreventer = useMemo(
    () => createPostDragEventPreventer(getWindow),
    [getWindow],
  );

  const stop = useCallback(() => {
    if (!getIsCapturing()) {
      return;
    }

    schedule.cancel();
    unbindWindowEventsRef.current();

    const shouldBlockClick: boolean = isDraggingRef.current;

    mouseDownMarshal.reset();
    if (shouldBlockClick) {
      postDragEventPreventer.preventNext();
    }
    // resetting refs
    pendingRef.current = null;
    isDraggingRef.current = false;

    // releasing the capture
    onCaptureEnd();
  }, [getIsCapturing, onCaptureEnd, postDragEventPreventer, schedule]);

  const cancel = useCallback(() => {
    const wasDragging: boolean = isDraggingRef.current;
    stop();

    if (wasDragging) {
      callbacks.onCancel();
    }
  }, [callbacks, stop]);

  const startDragging = useCallback(() => {
    invariant(!isDraggingRef.current, 'Cannot start a drag while dragging');
    const pending: ?Position = pendingRef.current;
    invariant(pending, 'Cannot start a drag without a pending drag');

    pendingRef.current = null;
    isDraggingRef.current = true;

    callbacks.onLift({
      clientSelection: pending,
      movementMode: 'FLUID',
    });
  }, [callbacks]);

  const windowBindings: EventBinding[] = useMemo(() => {
    invariant(
      !getIsCapturing(),
      'Should not recreate window bindings while capturing',
    );

    const bindings: EventBinding[] = [
      {
        eventName: 'mousemove',
        fn: (event: MouseEvent) => {
          const { button, clientX, clientY } = event;
          if (button !== primaryButton) {
            return;
          }

          const point: Position = {
            x: clientX,
            y: clientY,
          };

          // Already dragging
          if (isDraggingRef.current) {
            // preventing default as we are using this event
            event.preventDefault();
            schedule.move(point);
            return;
          }

          // There should be a pending drag at this point
          const pending: ?Position = pendingRef.current;

          if (!pending) {
            // this should be an impossible state
            // we cannot use kill directly as it checks if there is a pending drag
            stop();
            invariant(
              false,
              'Expected there to be an active or pending drag when window mousemove event is received',
            );
          }

          // threshold not yet exceeded
          if (!isSloppyClickThresholdExceeded(pending, point)) {
            return;
          }

          // preventing default as we are using this event
          event.preventDefault();
          startDragging();
        },
      },
      {
        eventName: 'mouseup',
        fn: (event: MouseEvent) => {
          const wasDragging: boolean = isDraggingRef.current;
          stop();

          if (wasDragging) {
            // preventing default as we are using this event
            event.preventDefault();
            callbacks.onDrop();
          }
        },
      },
      {
        eventName: 'mousedown',
        fn: (event: MouseEvent) => {
          // this can happen during a drag when the user clicks a button
          // other than the primary mouse button
          if (isDraggingRef.current) {
            event.preventDefault();
          }

          cancel();
        },
      },
      {
        eventName: 'keydown',
        fn: (event: KeyboardEvent) => {
          // Abort if any keystrokes while a drag is pending
          if (pendingRef.current) {
            stop();
            return;
          }

          // cancelling a drag
          if (event.keyCode === keyCodes.escape) {
            event.preventDefault();
            cancel();
            return;
          }

          preventStandardKeyEvents(event);
        },
      },
      {
        eventName: 'resize',
        fn: cancel,
      },
      {
        eventName: 'scroll',
        // ## Passive: true
        // Eventual consistency is fine because we use position: fixed on the item
        // ## Capture: false
        // Scroll events on elements do not bubble, but they go through the capture phase
        // https://twitter.com/alexandereardon/status/985994224867819520
        // Using capture: false here as we want to avoid intercepting droppable scroll requests
        // TODO: can result in awkward drop position
        options: { passive: true, capture: false },
        fn: (event: UIEvent) => {
          // IE11 fix:
          // Scrollable events still bubble up and are caught by this handler in ie11.
          // We can ignore this event
          if (event.currentTarget !== getWindow()) {
            return;
          }

          // stop a pending drag
          if (pendingRef.current) {
            stop();
            return;
          }
          // getCallbacks().onWindowScroll();
          schedule.windowScrollMove();
        },
      },
      // Need to opt out of dragging if the user is a force press
      // Only for safari which has decided to introduce its own custom way of doing things
      // https://developer.apple.com/library/content/documentation/AppleApplications/Conceptual/SafariJSProgTopics/RespondingtoForceTouchEventsfromJavaScript.html
      {
        eventName: 'webkitmouseforcedown',
        fn: () => {
          // In order to opt out of force press correctly we need to call
          // event.preventDefault() on webkitmouseforcewillbegin
          // We have no way of doing this in this branch so we are always respecting force touches
          // There is a correct fix in the `virtual` branch
          cancel();
        },
      },
      // Cancel on page visibility change
      {
        eventName: supportedPageVisibilityEventName,
        fn: cancel,
      },
    ];
    return bindings;
  }, [
    getIsCapturing,
    cancel,
    startDragging,
    schedule,
    stop,
    callbacks,
    getWindow,
  ]);

  const bindWindowEvents = useCallback(() => {
    const win: HTMLElement = getWindow();
    const options = { capture: true };

    // setting up our unbind before we bind
    unbindWindowEventsRef.current = () =>
      unbindEvents(win, windowBindings, options);

    bindEvents(win, windowBindings, options);
  }, [getWindow, windowBindings]);

  const startPendingDrag = useCallback(
    (point: Position) => {
      invariant(!pendingRef.current, 'Expected there to be no pending drag');
      pendingRef.current = point;
      onCaptureStart(stop);
      bindWindowEvents();
    },
    [bindWindowEvents, onCaptureStart, stop],
  );

  const onMouseDown = useCallback(
    (event: MouseEvent) => {
      if (mouseDownMarshal.isHandled()) {
        return;
      }

      invariant(
        !getIsCapturing(),
        'Should not be able to perform a mouse down while a drag or pending drag is occurring',
      );

      // We do not need to prevent the event on a dropping draggable as
      // the mouse down event will not fire due to pointer-events: none
      // https://codesandbox.io/s/oxo0o775rz
      if (!canStartCapturing(event)) {
        return;
      }

      // only starting a drag if dragging with the primary mouse button
      if (event.button !== primaryButton) {
        return;
      }

      // Do not start a drag if any modifier key is pressed
      if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
        return;
      }

      // Registering that this event has been handled.
      // This is to prevent parent draggables using this event
      // to start also.
      // Ideally we would not use preventDefault() as we are not sure
      // if this mouse down is part of a drag interaction
      // Unfortunately we do to prevent the element obtaining focus (see below).
      mouseDownMarshal.handle();

      // Unfortunately we do need to prevent the drag handle from getting focus on mousedown.
      // This goes against our policy on not blocking events before a drag has started.
      // See [How we use dom events](/docs/guides/how-we-use-dom-events.md).
      event.preventDefault();

      const point: Position = {
        x: event.clientX,
        y: event.clientY,
      };

      startPendingDrag(point);
    },
    [canStartCapturing, getIsCapturing, startPendingDrag],
  );

  return onMouseDown;
}
