/**
 * Copyright (c) Facebook, Inc. and its affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 * @flow
 */

import type {
  ReactDOMResponderContext,
  ReactDOMResponderEvent,
  PointerType,
} from 'shared/ReactDOMTypes';
import type {ReactEventResponderListener} from 'shared/ReactTypes';

import React from 'react';
import {
  buttonsEnum,
  dispatchDiscreteEvent,
  dispatchUserBlockingEvent,
  getTouchById,
  hasModifierKey,
  hasPointerEvents,
} from './shared';

type TapProps = $ReadOnly<{|
  disabled?: boolean,
  maximumDistance?: number,
  preventDefault?: boolean,
  onAuxiliaryTap?: (e: TapEvent) => void,
  onTapCancel?: (e: TapEvent) => void,
  onTapChange?: boolean => void,
  onTapEnd?: (e: TapEvent) => void,
  onTapStart?: (e: TapEvent) => void,
  onTapUpdate?: (e: TapEvent) => void,
|}>;

type TapGestureState = {|
  altKey: boolean,
  ctrlKey: boolean,
  height: number,
  metaKey: boolean,
  pageX: number,
  pageY: number,
  pointerType: PointerType,
  pressure: number,
  screenX: number,
  screenY: number,
  shiftKey: boolean,
  tangentialPressure: number,
  target: null | Element,
  tiltX: number,
  tiltY: number,
  timeStamp: number,
  twist: number,
  width: number,
  x: number,
  y: number,
|};

type TapState = {|
  activePointerId: null | number,
  buttons: 0 | 1 | 4,
  gestureState: TapGestureState,
  ignoreEmulatedEvents: boolean,
  initialPosition: {|x: number, y: number|},
  isActive: boolean,
  isAuxiliaryActive: boolean,
  pointerType: PointerType,
  responderTarget: null | Element,
  rootEvents: null | Array<string>,
  shouldPreventDefault: boolean,
|};

type TapEventType =
  | 'tap:auxiliary'
  | 'tap:cancel'
  | 'tap:change'
  | 'tap:end'
  | 'tap:start'
  | 'tap:update';

type TapEvent = {|
  ...TapGestureState,
  defaultPrevented: boolean,
  type: TapEventType,
|};

/**
 * Native event dependencies
 */

const targetEventTypes = hasPointerEvents
  ? ['pointerdown']
  : ['mousedown', 'touchstart'];

const rootEventTypes = hasPointerEvents
  ? [
      'click_active',
      'contextmenu',
      'pointerup',
      'pointermove',
      'pointercancel',
      'scroll',
    ]
  : [
      'click_active',
      'contextmenu',
      'mouseup',
      'mousemove',
      'dragstart',
      'touchend',
      'touchmove',
      'touchcancel',
      'scroll',
    ];

/**
 * Responder and gesture state
 */

function createInitialState(): TapState {
  return {
    activePointerId: null,
    buttons: 0,
    ignoreEmulatedEvents: false,
    isActive: false,
    isAuxiliaryActive: false,
    initialPosition: {x: 0, y: 0},
    pointerType: '',
    responderTarget: null,
    rootEvents: null,
    shouldPreventDefault: true,
    gestureState: {
      altKey: false,
      ctrlKey: false,
      height: 1,
      metaKey: false,
      pageX: 0,
      pageY: 0,
      pointerType: '',
      pressure: 0,
      screenX: 0,
      screenY: 0,
      shiftKey: false,
      tangentialPressure: 0,
      target: null,
      tiltX: 0,
      tiltY: 0,
      timeStamp: 0,
      twist: 0,
      width: 1,
      x: 0,
      y: 0,
    },
  };
}

function createPointerEventGestureState(
  context: ReactDOMResponderContext,
  props: TapProps,
  state: TapState,
  event: ReactDOMResponderEvent,
): TapGestureState {
  const timeStamp = context.getTimeStamp();
  const nativeEvent = (event.nativeEvent: any);
  const {
    altKey,
    ctrlKey,
    height,
    metaKey,
    pageX,
    pageY,
    pointerType,
    pressure,
    screenX,
    screenY,
    shiftKey,
    tangentialPressure,
    tiltX,
    tiltY,
    twist,
    width,
    clientX,
    clientY,
  } = nativeEvent;

  return {
    altKey,
    ctrlKey,
    height,
    metaKey,
    pageX,
    pageY,
    pointerType,
    pressure,
    screenX,
    screenY,
    shiftKey,
    tangentialPressure,
    target: state.responderTarget,
    tiltX,
    tiltY,
    timeStamp,
    twist,
    width,
    x: clientX,
    y: clientY,
  };
}

function createFallbackGestureState(
  context: ReactDOMResponderContext,
  props: TapProps,
  state: TapState,
  event: ReactDOMResponderEvent,
): TapGestureState {
  const timeStamp = context.getTimeStamp();
  const nativeEvent = (event.nativeEvent: any);
  const eType = event.type;
  const {altKey, ctrlKey, metaKey, shiftKey} = nativeEvent;
  const isCancelType = eType === 'dragstart' || eType === 'touchcancel';
  const isEndType = eType === 'mouseup' || eType === 'touchend';
  const isTouchEvent = event.pointerType === 'touch';

  let pointerEvent = nativeEvent;
  if (!hasPointerEvents && isTouchEvent) {
    const touch = getTouchById(nativeEvent, state.activePointerId);
    if (touch != null) {
      pointerEvent = touch;
    }
  }

  const {
    pageX,
    pageY,
    // $FlowExpectedError: missing from 'Touch' typedef
    radiusX,
    // $FlowExpectedError: missing from 'Touch' typedef
    radiusY,
    // $FlowExpectedError: missing from 'Touch' typedef
    rotationAngle,
    screenX,
    screenY,
    clientX,
    clientY,
  } = pointerEvent;

  return {
    altKey,
    ctrlKey,
    height: !isCancelType && radiusY != null ? radiusY * 2 : 1,
    metaKey,
    pageX: isCancelType ? 0 : pageX,
    pageY: isCancelType ? 0 : pageY,
    pointerType: event.pointerType,
    pressure: isEndType || isCancelType ? 0 : isTouchEvent ? 1 : 0.5,
    screenX: isCancelType ? 0 : screenX,
    screenY: isCancelType ? 0 : screenY,
    shiftKey,
    tangentialPressure: 0,
    target: state.responderTarget,
    tiltX: 0,
    tiltY: 0,
    timeStamp,
    twist: rotationAngle != null ? rotationAngle : 0,
    width: !isCancelType && radiusX != null ? radiusX * 2 : 1,
    x: isCancelType ? 0 : clientX,
    y: isCancelType ? 0 : clientY,
  };
}

const createGestureState = hasPointerEvents
  ? createPointerEventGestureState
  : createFallbackGestureState;

/**
 * Managing root events
 */

function addRootEventTypes(
  rootEvents: Array<string>,
  context: ReactDOMResponderContext,
  state: TapState,
): void {
  if (!state.rootEvents) {
    state.rootEvents = rootEvents;
    context.addRootEventTypes(state.rootEvents);
  }
}

function removeRootEventTypes(
  context: ReactDOMResponderContext,
  state: TapState,
): void {
  if (state.rootEvents != null) {
    context.removeRootEventTypes(state.rootEvents);
    state.rootEvents = null;
  }
}

/**
 * Managing pointers
 */

function getHitTarget(
  event: ReactDOMResponderEvent,
  context: ReactDOMResponderContext,
  state: TapState,
): null | Element | Document {
  if (!hasPointerEvents && event.pointerType === 'touch') {
    const doc = context.getActiveDocument();
    const nativeEvent: any = event.nativeEvent;
    const touch = getTouchById(nativeEvent, state.activePointerId);
    if (touch != null) {
      return doc.elementFromPoint(touch.clientX, touch.clientY);
    } else {
      return null;
    }
  }
  return event.target;
}

function isActivePointer(
  event: ReactDOMResponderEvent,
  state: TapState,
): boolean {
  const nativeEvent: any = event.nativeEvent;
  const activePointerId = state.activePointerId;

  if (hasPointerEvents) {
    const eventPointerId = nativeEvent.pointerId;
    if (activePointerId != null && eventPointerId != null) {
      return (
        state.pointerType === event.pointerType &&
        activePointerId === eventPointerId
      );
    } else {
      return true;
    }
  } else {
    if (event.pointerType === 'touch') {
      const touch = getTouchById(nativeEvent, activePointerId);
      return touch != null;
    } else {
      // accept all events that don't have pointer ids
      return true;
    }
  }
}

function isAuxiliary(buttons: number, event: ReactDOMResponderEvent): boolean {
  const nativeEvent: any = event.nativeEvent;
  const isPrimaryPointer =
    buttons === buttonsEnum.primary || event.pointerType === 'touch';
  return (
    // middle-click
    buttons === buttonsEnum.auxiliary ||
    // open-in-new-tab
    (isPrimaryPointer && nativeEvent.metaKey) ||
    // open-in-new-window
    (isPrimaryPointer && nativeEvent.shiftKey)
  );
}

function shouldActivate(event: ReactDOMResponderEvent): boolean {
  const nativeEvent: any = event.nativeEvent;
  const isPrimaryPointer =
    nativeEvent.buttons === buttonsEnum.primary ||
    event.pointerType === 'touch';
  return isPrimaryPointer && !hasModifierKey(event);
}

/**
 * Communicating gesture state back to components
 */

function dispatchStart(
  context: ReactDOMResponderContext,
  props: TapProps,
  state: TapState,
): void {
  const type = 'tap:start';
  const onTapStart = props.onTapStart;
  if (onTapStart != null) {
    const payload = context.objectAssign({}, state.gestureState, {type});
    dispatchDiscreteEvent(context, payload, onTapStart);
  }
  dispatchChange(context, props, state);
}

function dispatchChange(
  context: ReactDOMResponderContext,
  props: TapProps,
  state: TapState,
): void {
  const onTapChange = props.onTapChange;
  if (onTapChange != null) {
    const payload = state.isActive;
    dispatchDiscreteEvent(context, payload, onTapChange);
  }
}

function dispatchUpdate(
  context: ReactDOMResponderContext,
  props: TapProps,
  state: TapState,
) {
  const type = 'tap:update';
  const onTapUpdate = props.onTapUpdate;
  if (onTapUpdate != null) {
    const payload = context.objectAssign({}, state.gestureState, {type});
    dispatchUserBlockingEvent(context, payload, onTapUpdate);
  }
}

function dispatchEnd(
  context: ReactDOMResponderContext,
  props: TapProps,
  state: TapState,
): void {
  const type = 'tap:end';
  const onTapEnd = props.onTapEnd;
  dispatchChange(context, props, state);
  if (onTapEnd != null) {
    const defaultPrevented = state.shouldPreventDefault === true;
    const payload = context.objectAssign({}, state.gestureState, {
      defaultPrevented,
      type,
    });
    dispatchDiscreteEvent(context, payload, onTapEnd);
  }
}

function dispatchCancel(
  context: ReactDOMResponderContext,
  props: TapProps,
  state: TapState,
): void {
  const type = 'tap:cancel';
  const onTapCancel = props.onTapCancel;
  dispatchChange(context, props, state);
  if (onTapCancel != null) {
    const payload = context.objectAssign({}, state.gestureState, {type});
    dispatchDiscreteEvent(context, payload, onTapCancel);
  }
}

function dispatchAuxiliaryTap(
  context: ReactDOMResponderContext,
  props: TapProps,
  state: TapState,
): void {
  const type = 'tap:auxiliary';
  const onAuxiliaryTap = props.onAuxiliaryTap;
  if (onAuxiliaryTap != null) {
    const payload = context.objectAssign({}, state.gestureState, {
      defaultPrevented: false,
      type,
    });
    dispatchDiscreteEvent(context, payload, onAuxiliaryTap);
  }
}

/**
 * Responder implementation
 */

const responderImpl = {
  targetEventTypes,
  getInitialState(): TapState {
    return createInitialState();
  },
  onEvent(
    event: ReactDOMResponderEvent,
    context: ReactDOMResponderContext,
    props: TapProps,
    state: TapState,
  ): void {
    if (props.disabled) {
      removeRootEventTypes(context, state);
      if (state.isActive) {
        state.isActive = false;
        dispatchCancel(context, props, state);
      }
      return;
    }

    const nativeEvent: any = event.nativeEvent;
    const eventTarget: Element = nativeEvent.target;
    const eventType = event.type;

    switch (eventType) {
      // START
      case 'pointerdown':
      case 'mousedown':
      case 'touchstart': {
        if (eventType === 'mousedown' && state.ignoreEmulatedEvents) {
          return;
        }

        if (!state.isActive) {
          if (hasPointerEvents) {
            const pointerId = nativeEvent.pointerId;
            state.activePointerId = pointerId;
            // Make mouse and touch pointers consistent.
            // Flow bug: https://github.com/facebook/flow/issues/8055
            // $FlowExpectedError
            eventTarget.releasePointerCapture(pointerId);
          } else {
            if (eventType === 'touchstart') {
              const targetTouches = nativeEvent.targetTouches;
              if (targetTouches.length === 1) {
                state.activePointerId = targetTouches[0].identifier;
              } else {
                return;
              }
            }
          }

          const activate = shouldActivate(event);
          const activateAuxiliary = isAuxiliary(nativeEvent.buttons, event);

          if (activate || activateAuxiliary) {
            state.buttons = nativeEvent.buttons;
            state.pointerType = event.pointerType;
            state.responderTarget = context.getResponderNode();
            addRootEventTypes(rootEventTypes, context, state);
            if (!hasPointerEvents) {
              if (eventType === 'touchstart') {
                state.ignoreEmulatedEvents = true;
              }
            }
          }

          if (activateAuxiliary) {
            state.isAuxiliaryActive = true;
          } else if (activate) {
            const gestureState = createGestureState(
              context,
              props,
              state,
              event,
            );
            state.isActive = true;
            state.shouldPreventDefault = props.preventDefault !== false;
            state.gestureState = gestureState;
            state.initialPosition.x = gestureState.x;
            state.initialPosition.y = gestureState.y;
            dispatchStart(context, props, state);
          }
        } else if (
          !isActivePointer(event, state) ||
          (eventType === 'touchstart' && nativeEvent.targetTouches.length > 1)
        ) {
          // Cancel the gesture if a second pointer becomes active on the target.
          state.isActive = false;
          dispatchCancel(context, props, state);
        }
        break;
      }
    }
  },
  onRootEvent(
    event: ReactDOMResponderEvent,
    context: ReactDOMResponderContext,
    props: TapProps,
    state: TapState,
  ): void {
    const nativeEvent: any = event.nativeEvent;
    const eventType = event.type;
    const hitTarget = getHitTarget(event, context, state);

    switch (eventType) {
      // UPDATE
      case 'pointermove':
      case 'mousemove':
      case 'touchmove': {
        if (!hasPointerEvents) {
          if (eventType === 'mousemove' && state.ignoreEmulatedEvents) {
            return;
          }
        }

        if (state.isActive && isActivePointer(event, state)) {
          state.gestureState = createGestureState(context, props, state, event);
          let shouldUpdate = true;

          if (!context.isTargetWithinResponder(hitTarget)) {
            shouldUpdate = false;
          } else if (
            props.maximumDistance != null &&
            props.maximumDistance >= 10
          ) {
            const maxDistance = props.maximumDistance;
            const initialPosition = state.initialPosition;
            const currentPosition = state.gestureState;
            const moveX = initialPosition.x - currentPosition.x;
            const moveY = initialPosition.y - currentPosition.y;
            const moveDistance = Math.sqrt(moveX * moveX + moveY * moveY);
            if (moveDistance > maxDistance) {
              shouldUpdate = false;
            }
          }

          if (shouldUpdate) {
            dispatchUpdate(context, props, state);
          } else {
            state.isActive = false;
            dispatchCancel(context, props, state);
          }
        }
        break;
      }

      // END
      case 'pointerup':
      case 'mouseup':
      case 'touchend': {
        if (state.isActive && isActivePointer(event, state)) {
          state.gestureState = createGestureState(context, props, state, event);
          state.isActive = false;
          if (isAuxiliary(state.buttons, event)) {
            dispatchCancel(context, props, state);
            dispatchAuxiliaryTap(context, props, state);
            // Remove the root events here as no 'click' event is dispatched
            removeRootEventTypes(context, state);
          } else if (
            !context.isTargetWithinResponder(hitTarget) ||
            hasModifierKey(event)
          ) {
            dispatchCancel(context, props, state);
          } else {
            dispatchEnd(context, props, state);
          }
        } else if (
          state.isAuxiliaryActive &&
          isAuxiliary(state.buttons, event)
        ) {
          state.isAuxiliaryActive = false;
          state.gestureState = createGestureState(context, props, state, event);
          dispatchAuxiliaryTap(context, props, state);
          // Remove the root events here as no 'click' event is dispatched
          removeRootEventTypes(context, state);
        }

        if (!hasPointerEvents) {
          if (eventType === 'mouseup') {
            state.ignoreEmulatedEvents = false;
          }
        }
        break;
      }

      // CANCEL
      case 'contextmenu':
      case 'pointercancel':
      case 'touchcancel':
      case 'dragstart': {
        if (state.isActive && isActivePointer(event, state)) {
          state.gestureState = createGestureState(context, props, state, event);
          state.isActive = false;
          dispatchCancel(context, props, state);
          removeRootEventTypes(context, state);
        }
        break;
      }

      // CANCEL
      case 'scroll': {
        if (
          state.isActive &&
          state.responderTarget != null &&
          // We ignore incoming scroll events when using mouse events
          state.pointerType !== 'mouse' &&
          // If the scroll target is the document or if the pointer target
          // is within the 'scroll' target, then cancel the gesture
          context.isTargetWithinNode(state.responderTarget, nativeEvent.target)
        ) {
          state.gestureState = createGestureState(context, props, state, event);
          state.isActive = false;
          dispatchCancel(context, props, state);
          removeRootEventTypes(context, state);
        }
        break;
      }

      case 'click': {
        if (state.shouldPreventDefault) {
          nativeEvent.preventDefault();
        }
        removeRootEventTypes(context, state);
        break;
      }
    }
  },
  onUnmount(
    context: ReactDOMResponderContext,
    props: TapProps,
    state: TapState,
  ): void {
    removeRootEventTypes(context, state);
    if (state.isActive) {
      state.isActive = false;
      dispatchCancel(context, props, state);
    }
  },
};

export const TapResponder = React.unstable_createResponder(
  'Tap',
  responderImpl,
);

export function useTap(props: TapProps): ReactEventResponderListener<any, any> {
  return React.unstable_useResponder(TapResponder, props);
}
