// This component is based on RN's DrawerLayoutAndroid API
// It's cross-compatible with all platforms despite
// `DrawerLayoutAndroid` only being available on android

import type { ReactNode } from 'react';
import React, {
  useCallback,
  useEffect,
  useImperativeHandle,
  useMemo,
  useState,
} from 'react';
import type {
  LayoutChangeEvent,
  StatusBarAnimation,
  StyleProp,
  ViewStyle,
} from 'react-native';
import {
  I18nManager,
  Keyboard,
  Platform,
  StatusBar,
  StyleSheet,
} from 'react-native';
import type { SharedValue } from 'react-native-reanimated';
import Animated, {
  Extrapolation,
  interpolate,
  runOnJS,
  useAnimatedProps,
  useAnimatedStyle,
  useDerivedValue,
  useSharedValue,
  withSpring,
} from 'react-native-reanimated';

import type {
  ActiveCursor,
  HitSlop,
  UserSelect,
} from '../handlers/gestureHandlerCommon';
import { MouseButton } from '../handlers/gestureHandlerCommon';
import { GestureDetector } from '../v3/detectors';
import type { PanGestureActiveEvent } from '../v3/hooks/gestures';
import { usePanGesture, useTapGesture } from '../v3/hooks/gestures';
import type { WithSharedValue } from '../v3/types';

const DRAG_TOSS = 0.05;

export enum DrawerPosition {
  LEFT,
  RIGHT,
}

export enum DrawerState {
  IDLE,
  DRAGGING,
  SETTLING,
}

export enum DrawerType {
  FRONT,
  BACK,
  SLIDE,
}

export enum DrawerLockMode {
  UNLOCKED,
  LOCKED_CLOSED,
  LOCKED_OPEN,
}

export enum DrawerKeyboardDismissMode {
  NONE,
  ON_DRAG,
}

export type DrawerLayoutProps = {
  /**
   * This attribute is present in the native android implementation already and is one
   * of the required params. The gesture handler version of DrawerLayout makes it
   * possible for the function passed as `renderNavigationView` to take an
   * Animated value as a parameter that indicates the progress of drawer
   * opening/closing animation (progress value is 0 when closed and 1 when
   * opened). This can be used by the drawer component to animated its children
   * while the drawer is opening or closing.
   */
  renderNavigationView: (
    progressAnimatedValue: SharedValue<number>
  ) => ReactNode;

  /**
   * Determines the side from which the drawer will open.
   */
  drawerPosition?: DrawerPosition;

  /**
   * Width of the drawer.
   */
  drawerWidth?: number;

  /**
   * Background color of the drawer.
   */
  drawerBackgroundColor?: string;

  /**
   * Specifies the lock mode of the drawer.
   * Programatic opening/closing isn't affected by the lock mode. Defaults to `UNLOCKED`.
   * - `UNLOCKED` - the drawer will respond to gestures.
   * - `LOCKED_CLOSED` - the drawer will move freely until it settles in a closed position, then the gestures will be disabled.
   * - `LOCKED_OPEN` - the drawer will move freely until it settles in an opened position, then the gestures will be disabled.
   */
  drawerLockMode?: DrawerLockMode;

  /**
   * Determines if system keyboard should be closed upon dragging the drawer.
   */
  keyboardDismissMode?: DrawerKeyboardDismissMode;

  /**
   * Called when the drawer is closed.
   */
  onDrawerClose?: () => void;

  /**
   * Called when the drawer is opened.
   */
  onDrawerOpen?: () => void;

  /**
   * Called when the status of the drawer changes.
   */
  onDrawerStateChanged?: (
    newState: DrawerState,
    drawerWillShow: boolean
  ) => void;

  /**
   * Type of animation that will play when opening the drawer.
   */
  drawerType?: DrawerType;

  /**
   * Speed of animation that will play when letting go, or dismissing the drawer.
   * This will also be the default animation speed for programatic controlls.
   */
  animationSpeed?: number;

  /**
   * Defines how far from the edge of the content view the gesture should
   * activate.
   */
  edgeWidth?: number;

  /**
   * Minimal distance to swipe before the drawer starts moving.
   */
  minSwipeDistance?: number;

  /**
   * When set to true Drawer component will use
   * {@link https://reactnative.dev/docs/statusbar StatusBar} API to hide the OS
   * status bar whenever the drawer is pulled or when its in an "open" state.
   */
  hideStatusBar?: boolean;

  /**
   * @default 'slide'
   *
   * Can be used when hideStatusBar is set to true and will select the animation
   * used for hiding/showing the status bar. See
   * {@link https://reactnative.dev/docs/statusbar StatusBar} documentation for
   * more details
   */
  statusBarAnimation?: StatusBarAnimation;

  /**
   * @default 'rgba(0, 0, 0, 0.7)'
   *
   * Color of the background overlay.
   * Animated from `0%` to `100%` as the drawer opens.
   */
  overlayColor?: string;

  /**
   * Style wrapping the content.
   */
  contentContainerStyle?: StyleProp<ViewStyle>;

  /**
   * Style wrapping the drawer.
   */
  drawerContainerStyle?: StyleProp<ViewStyle>;

  onDrawerSlide?: (position: number) => void;

  // Implicit `children` prop has been removed in @types/react^18.0.
  /**
   * Elements that will be rendered inside the content view.
   */
  children?: ReactNode | ((openValue?: SharedValue<number>) => ReactNode);

  /**
   * @default 'none'
   * Sets whether the text inside both the drawer and the context window can be selected.
   * Values: 'none' | 'text' | 'auto'
   */
  userSelect?: UserSelect;

  /**
   * @default 'false if MouseButton.RIGHT is specified'
   * Allows to enable/disable context menu.
   */
  enableContextMenu?: boolean;
} & WithSharedValue<
  {
    /**
     * Enables two-finger gestures on supported devices, for example iPads with
     * trackpads. If not enabled the gesture will require click + drag, with
     * `enableTrackpadTwoFingerGesture` swiping with two fingers will also trigger
     * the gesture.
     */
    enableTrackpadTwoFingerGesture?: boolean;

    /**
     * @default 'auto'
     * Sets the displayed cursor pictogram when the drawer is being dragged.
     * Values: see CSS cursor values
     */
    activeCursor?: ActiveCursor;

    /**
     * @default 'MouseButton.LEFT'
     * Allows to choose which mouse button should underlying pan handler react to.
     */
    mouseButton?: MouseButton;
  },
  ActiveCursor | MouseButton
>;

export type DrawerMovementOption = {
  initialVelocity?: number;
  animationSpeed?: number;
};

export interface DrawerLayoutMethods {
  openDrawer: (options?: DrawerMovementOption) => void;
  closeDrawer: (options?: DrawerMovementOption) => void;
}

const defaultProps = {
  drawerWidth: 200,
  drawerPosition: DrawerPosition.LEFT,
  drawerType: DrawerType.FRONT,
  edgeWidth: 20,
  minSwipeDistance: 3,
  overlayColor: 'rgba(0, 0, 0, 0.7)',
  drawerLockMode: DrawerLockMode.UNLOCKED,
  enableTrackpadTwoFingerGesture: false,
  activeCursor: 'auto' as ActiveCursor,
  mouseButton: MouseButton.LEFT,
  statusBarAnimation: 'slide' as StatusBarAnimation,
};

// StatusBar.setHidden and Keyboard.dismiss cannot be directly referenced in worklets.
const setStatusBarHidden = StatusBar.setHidden;
const dismissKeyboard = Keyboard.dismiss;

const DrawerLayout = function DrawerLayout(
  props: DrawerLayoutProps & {
    ref?: React.Ref<DrawerLayoutMethods>;
  }
) {
  const [containerWidth, setContainerWidth] = useState(0);
  const [drawerState, setDrawerState] = useState<DrawerState>(DrawerState.IDLE);
  const [drawerOpened, setDrawerOpened] = useState(false);

  const {
    drawerPosition = defaultProps.drawerPosition,
    drawerWidth = defaultProps.drawerWidth,
    drawerType = defaultProps.drawerType,
    drawerBackgroundColor,
    drawerContainerStyle,
    contentContainerStyle,
    minSwipeDistance = defaultProps.minSwipeDistance,
    edgeWidth = defaultProps.edgeWidth,
    drawerLockMode = defaultProps.drawerLockMode,
    overlayColor = defaultProps.overlayColor,
    enableTrackpadTwoFingerGesture = defaultProps.enableTrackpadTwoFingerGesture,
    activeCursor = defaultProps.activeCursor,
    mouseButton = defaultProps.mouseButton,
    statusBarAnimation = defaultProps.statusBarAnimation,
    hideStatusBar,
    keyboardDismissMode,
    userSelect,
    enableContextMenu,
    renderNavigationView,
    onDrawerSlide,
    onDrawerClose,
    onDrawerOpen,
    onDrawerStateChanged,
    animationSpeed: animationSpeedProp,
  } = props;

  const isFromLeft = drawerPosition === DrawerPosition.LEFT;

  const sideCorrection = isFromLeft ? 1 : -1;

  // While closing the drawer when user starts gesture in the greyed out part of the window,
  // we want the drawer to follow only once the finger reaches the edge of the drawer.
  // See the diagram for reference. * = starting finger position, < = current finger position
  // 1) +---------------+ 2) +---------------+ 3) +---------------+ 4) +---------------+
  //    |XXXXXXXX|......|    |XXXXXXXX|......|    |XXXXXXXX|......|    |XXXXX|.........|
  //    |XXXXXXXX|......|    |XXXXXXXX|......|    |XXXXXXXX|......|    |XXXXX|.........|
  //    |XXXXXXXX|..<*..|    |XXXXXXXX|.<-*..|    |XXXXXXXX|<--*..|    |XXXXX|<-----*..|
  //    |XXXXXXXX|......|    |XXXXXXXX|......|    |XXXXXXXX|......|    |XXXXX|.........|
  //    |XXXXXXXX|......|    |XXXXXXXX|......|    |XXXXXXXX|......|    |XXXXX|.........|
  //    +---------------+    +---------------+    +---------------+    +---------------+

  const openValue = useSharedValue<number>(0);

  useDerivedValue(() => {
    onDrawerSlide && runOnJS(onDrawerSlide)(openValue.value);
  }, []);

  const isDrawerOpen = useSharedValue(false);

  const handleContainerLayout = ({ nativeEvent }: LayoutChangeEvent) => {
    setContainerWidth(nativeEvent.layout.width);
  };

  const emitStateChanged = useCallback(
    (newState: DrawerState, drawerWillShow: boolean) => {
      'worklet';
      onDrawerStateChanged &&
        runOnJS(onDrawerStateChanged)?.(newState, drawerWillShow);
    },
    [onDrawerStateChanged]
  );

  const drawerAnimatedProps = useAnimatedProps(() => ({
    accessibilityViewIsModal: isDrawerOpen.value,
  }));

  const overlayAnimatedProps = useAnimatedProps(() => ({
    pointerEvents: isDrawerOpen.value ? ('auto' as const) : ('none' as const),
  }));

  // While the drawer is hidden, it's hitSlop overflows onto the main view by edgeWidth
  // This way it can be swiped open even when it's hidden
  const [edgeHitSlop, setEdgeHitSlop] = useState<HitSlop>(
    isFromLeft ? { left: 0, width: edgeWidth } : { right: 0, width: edgeWidth }
  );

  // gestureOrientation is 1 if the gesture is expected to move from left to right and -1 otherwise
  const gestureOrientation = useMemo(
    () => sideCorrection * (drawerOpened ? -1 : 1),
    [sideCorrection, drawerOpened]
  );

  useEffect(() => {
    setEdgeHitSlop(
      isFromLeft
        ? { left: 0, width: edgeWidth }
        : { right: 0, width: edgeWidth }
    );
  }, [isFromLeft, edgeWidth]);

  const animateDrawer = useCallback(
    (toValue: number, initialVelocity: number, animationSpeed?: number) => {
      'worklet';
      const willShow = toValue !== 0;
      isDrawerOpen.value = willShow;

      emitStateChanged(DrawerState.SETTLING, willShow);
      runOnJS(setDrawerState)(DrawerState.SETTLING);

      if (hideStatusBar) {
        runOnJS(setStatusBarHidden)(willShow, statusBarAnimation);
      }

      const normalizedToValue = interpolate(
        toValue,
        [0, drawerWidth],
        [0, 1],
        Extrapolation.CLAMP
      );

      const normalizedInitialVelocity = interpolate(
        initialVelocity,
        [0, drawerWidth],
        [0, 1],
        Extrapolation.CLAMP
      );

      openValue.value = withSpring(
        normalizedToValue,
        {
          overshootClamping: true,
          velocity: normalizedInitialVelocity,
          mass: animationSpeed
            ? 1 / animationSpeed
            : 1 / (animationSpeedProp ?? 1),
          damping: 40,
          stiffness: 500,
        },
        (finished) => {
          if (finished) {
            emitStateChanged(DrawerState.IDLE, willShow);
            runOnJS(setDrawerOpened)(willShow);
            runOnJS(setDrawerState)(DrawerState.IDLE);
            if (willShow) {
              onDrawerOpen && runOnJS(onDrawerOpen)?.();
            } else {
              onDrawerClose && runOnJS(onDrawerClose)?.();
            }
          }
        }
      );
    },
    [
      openValue,
      emitStateChanged,
      isDrawerOpen,
      hideStatusBar,
      onDrawerClose,
      onDrawerOpen,
      drawerWidth,
      statusBarAnimation,
    ]
  );

  const handleRelease = useCallback(
    (event: PanGestureActiveEvent) => {
      'worklet';
      let { translationX: dragX, velocityX, x: touchX } = event;

      if (drawerPosition !== DrawerPosition.LEFT) {
        // See description in _updateAnimatedEvent about why events are flipped
        // for right-side drawer
        dragX = -dragX;
        touchX = containerWidth - touchX;
        velocityX = -velocityX;
      }

      const gestureStartX = touchX - dragX;
      let dragOffsetBasedOnStart = 0;

      if (drawerType === DrawerType.FRONT) {
        dragOffsetBasedOnStart =
          gestureStartX > drawerWidth ? gestureStartX - drawerWidth : 0;
      }

      const startOffsetX =
        dragX + dragOffsetBasedOnStart + (isDrawerOpen.value ? drawerWidth : 0);

      const projOffsetX = startOffsetX + DRAG_TOSS * velocityX;

      const shouldOpen = projOffsetX > drawerWidth / 2;

      if (shouldOpen) {
        animateDrawer(drawerWidth, velocityX);
      } else {
        animateDrawer(0, velocityX);
      }
    },
    [
      animateDrawer,
      containerWidth,
      drawerPosition,
      drawerType,
      drawerWidth,
      isDrawerOpen,
    ]
  );

  const openDrawer = useCallback(
    (options: DrawerMovementOption = {}) => {
      'worklet';
      animateDrawer(
        drawerWidth,
        options.initialVelocity ?? 0,
        options.animationSpeed
      );
    },
    [animateDrawer, drawerWidth]
  );

  const closeDrawer = useCallback(
    (options: DrawerMovementOption = {}) => {
      'worklet';
      animateDrawer(0, options.initialVelocity ?? 0, options.animationSpeed);
    },
    [animateDrawer]
  );

  const overlayDismissGesture = useTapGesture({
    enabled: drawerOpened,
    maxDistance: 25,
    onDeactivate: () => {
      'worklet';
      if (isDrawerOpen.value && drawerLockMode !== DrawerLockMode.LOCKED_OPEN) {
        closeDrawer();
      }
    },
  });

  const overlayAnimatedStyle = useAnimatedStyle(() => ({
    opacity: openValue.value,
    backgroundColor: overlayColor,
  }));

  const fillHitSlop = useMemo(
    () => (isFromLeft ? { left: drawerWidth } : { right: drawerWidth }),
    [drawerWidth, isFromLeft]
  );

  const panGesture = usePanGesture({
    activeCursor: activeCursor,
    mouseButton: mouseButton,
    hitSlop: drawerOpened ? fillHitSlop : edgeHitSlop,
    activeOffsetX: gestureOrientation * minSwipeDistance,
    failOffsetY: [-15, 15],
    simultaneousWith: overlayDismissGesture,
    enableTrackpadTwoFingerGesture: enableTrackpadTwoFingerGesture,
    enabled:
      drawerState !== DrawerState.SETTLING &&
      (drawerOpened
        ? drawerLockMode !== DrawerLockMode.LOCKED_OPEN
        : drawerLockMode !== DrawerLockMode.LOCKED_CLOSED),
    onActivate: () => {
      'worklet';
      emitStateChanged(DrawerState.DRAGGING, false);
      runOnJS(setDrawerState)(DrawerState.DRAGGING);
      if (keyboardDismissMode === DrawerKeyboardDismissMode.ON_DRAG) {
        runOnJS(dismissKeyboard)();
      }
      if (hideStatusBar) {
        runOnJS(setStatusBarHidden)(true, statusBarAnimation);
      }
    },
    onUpdate: (event) => {
      'worklet';
      const startedOutsideTranslation = isFromLeft
        ? interpolate(
            event.x,
            [0, drawerWidth, drawerWidth + 1],
            [0, drawerWidth, drawerWidth]
          )
        : interpolate(
            event.x - containerWidth,
            [-drawerWidth - 1, -drawerWidth, 0],
            [drawerWidth, drawerWidth, 0]
          );

      const startedInsideTranslation =
        sideCorrection *
        (event.translationX +
          (drawerOpened ? drawerWidth * -gestureOrientation : 0));

      const adjustedTranslation = Math.max(
        drawerOpened ? startedOutsideTranslation : 0,
        startedInsideTranslation
      );

      openValue.value = interpolate(
        adjustedTranslation,
        [-drawerWidth, 0, drawerWidth],
        [1, 0, 1],
        Extrapolation.CLAMP
      );
    },
    onDeactivate: handleRelease,
  });

  // When using RTL, row and row-reverse flex directions are flipped.
  const reverseContentDirection = I18nManager.isRTL ? isFromLeft : !isFromLeft;

  const dynamicDrawerStyles = {
    backgroundColor: drawerBackgroundColor,
    width: drawerWidth,
  };

  const containerStyles = useAnimatedStyle(() => {
    if (drawerType === DrawerType.FRONT) {
      return {};
    }

    return {
      transform: [
        {
          translateX: interpolate(
            openValue.value,
            [0, 1],
            [0, drawerWidth * sideCorrection],
            Extrapolation.CLAMP
          ),
        },
      ],
    };
  });

  const drawerAnimatedStyle = useAnimatedStyle(() => {
    const closedDrawerOffset = drawerWidth * -sideCorrection;
    const isBack = drawerType === DrawerType.BACK;
    const isIdle = drawerState === DrawerState.IDLE;

    if (isBack) {
      return {
        transform: [{ translateX: 0 }],
        flexDirection: reverseContentDirection ? 'row-reverse' : 'row',
      };
    }

    let translateX = 0;

    if (isIdle) {
      translateX = drawerOpened ? 0 : closedDrawerOffset;
    } else {
      translateX = interpolate(
        openValue.value,
        [0, 1],
        [closedDrawerOffset, 0],
        Extrapolation.CLAMP
      );
    }

    return {
      transform: [{ translateX }],
      flexDirection: reverseContentDirection ? 'row-reverse' : 'row',
    };
  });

  const containerAnimatedProps = useAnimatedProps(() => ({
    importantForAccessibility:
      Platform.OS === 'android'
        ? isDrawerOpen.value
          ? ('no-hide-descendants' as const)
          : ('yes' as const)
        : undefined,
  }));

  const children =
    typeof props.children === 'function'
      ? props.children(openValue) // renderer function
      : props.children;

  useImperativeHandle(
    props.ref,
    () => ({
      openDrawer,
      closeDrawer,
    }),
    [openDrawer, closeDrawer]
  );

  return (
    <GestureDetector
      gesture={panGesture}
      userSelect={userSelect}
      enableContextMenu={enableContextMenu}>
      <Animated.View style={styles.main} onLayout={handleContainerLayout}>
        <GestureDetector
          gesture={overlayDismissGesture}
          userSelect={userSelect}>
          <Animated.View
            style={[
              drawerType === DrawerType.FRONT
                ? styles.containerOnBack
                : styles.containerInFront,
              containerStyles,
              contentContainerStyle,
            ]}
            animatedProps={containerAnimatedProps}>
            {children}
            <Animated.View
              animatedProps={overlayAnimatedProps}
              style={[styles.overlay, overlayAnimatedStyle]}
            />
          </Animated.View>
        </GestureDetector>
        <Animated.View
          pointerEvents="box-none"
          animatedProps={drawerAnimatedProps}
          style={[
            styles.drawerContainer,
            drawerAnimatedStyle,
            drawerContainerStyle,
          ]}>
          <Animated.View style={dynamicDrawerStyles}>
            {renderNavigationView(openValue)}
          </Animated.View>
        </Animated.View>
      </Animated.View>
    </GestureDetector>
  );
};

export default DrawerLayout;

const styles = StyleSheet.create({
  drawerContainer: {
    ...StyleSheet.absoluteFill,
    zIndex: 1001,
    flexDirection: 'row',
  },
  containerInFront: {
    ...StyleSheet.absoluteFill,
    zIndex: 1002,
  },
  containerOnBack: {
    ...StyleSheet.absoluteFill,
  },
  main: {
    flex: 1,
    zIndex: 0,
    overflow: 'hidden',
  },
  overlay: {
    ...StyleSheet.absoluteFill,
    zIndex: 1000,
  },
});
