import { useCallback, useEffect, useRef } from 'react';
// import { Draggable } from 'react-smooth-dnd';
import classNames from 'classnames';
import Debug from 'debug';

import type { CSSProperties, FC, MutableRefObject, ReactNode } from 'react';
import type { ContainerOptions, SmoothDnD } from './smooth-dnd';

import { isTouchDevice } from '~/lib/device/utils';
import { vibrateDevice } from '~/src/lib/utils/vibrateDevice';
import { constants, smoothDnD } from './smooth-dnd';

const debug = Debug('songwhip/Sortable');
const DEFAULT_DRAGGABLE_CLASS_NAME = 'sortableItem';
const { wrapperClass } = constants;
const NOOP = () => {};

// PERF: avoids using top/left for positioning element being dragged (ghost)
smoothDnD.useTransformForGhost = true;

smoothDnD.wrapChild = false;

smoothDnD.maxScrollSpeed = 1500;

// not sure what this is for but copied from react-smooth-dnd
smoothDnD.dropHandler = () => {
  return (dropResult, onDrop) => {
    if (onDrop) {
      onDrop(dropResult);
    }
  };
};

export interface SortableContainerProps {
  /**
   * Css selector to test for enabling dragging. If not given item
   * can be grabbed from anywhere in its boundaries.
   */
  dragHandleSelector?: string;

  dragItemSelector?: string;

  /**
   * Css selector to prevent dragging. Can be useful when you have
   * form elements or selectable text somewhere inside your draggable
   * item. It has a precedence over dragHandleSelector.
   */
  nonDragAreaSelector?: string;

  animationDuration?: number;
  axis?: 'y' | 'x';
  style?: CSSProperties;
  tag?: 'div' | 'ul';

  children: ReactNode;

  nodeRef?: MutableRefObject<HTMLElement>;
  onDrop?: (params: { removedIndex: number; addedIndex: number }) => void;

  onBeforeDragStart?: (params: {
    containerEl: HTMLElement;
    itemEls: HTMLElement[];
    dragItemEl: HTMLElement;
    pointerX: number;
    pointerY: number;
  }) => void;

  onDragStart?: (params: {
    containerEl: HTMLElement;
    itemEls: HTMLElement[];
    dragItemEl: HTMLElement;
    pointerX: number;
    pointerY: number;
  }) => void;

  onDragEnd?: (params: {
    containerEl: HTMLElement;
    itemEls: HTMLElement[];
    dragItemEl: HTMLElement;
    pointerX: number;
    pointerY: number;
  }) => void;
}

export const SortableContainer: FC<SortableContainerProps> = ({
  onDrop = NOOP,
  onDragStart = NOOP,
  onDragEnd = NOOP,
  onBeforeDragStart = NOOP,
  dragHandleSelector = `.${DEFAULT_DRAGGABLE_CLASS_NAME}`,
  dragItemSelector = `.${DEFAULT_DRAGGABLE_CLASS_NAME}`,
  children,
  animationDuration = 200,
  nonDragAreaSelector,
  axis = 'y',
  tag = 'div',
  style,
  nodeRef,
}) => {
  const rootElRef = useRef<HTMLDivElement>(null);
  const smoothDndRef = useRef<SmoothDnD>(null);
  const isMountedRef = useRef(false);

  const innerRef = nodeRef || rootElRef;

  // Rhe dragBeginDelay is a window where smooth-dnd will wait N ms before
  // initiating the drag. If the pointer is moved > 5px in that time window then
  // the drag is cancelled. On desktop we don't need this window as the pointer
  // isn't used for scrolling. Instead on desktop when the mouse is down as soon
  // as the pointer is moved > 1px dragging begins.
  const dragBeginDelay = isTouchDevice() ? 250 : 0;

  const dragDataRef = useRef<{
    containerEl: HTMLElement;
    handleEl: HTMLElement;
    dragItemEl: HTMLElement;
    itemEls: HTMLElement[];
    pointerX: number;
    pointerY: number;
  }>(null);

  const getSmoothDndOptions = (): ContainerOptions => {
    const isHorizontal = axis === 'x';

    return {
      getGhostParent: () => document.body,
      orientation: isHorizontal ? 'horizontal' : 'vertical',
      lockAxis: axis,
      behaviour: 'contain',

      dragHandleSelector,
      shouldAnimateDrop: () => false,
      animationDuration,
      nonDragAreaSelector,
      dragBeginDelay,

      onDrop: (params) => {
        debug('on drop', params);

        const containerEl = innerRef.current!;
        containerEl.classList.remove('is-dragging');

        if (onDrop && params) {
          onDrop({
            addedIndex: params.addedIndex!,
            removedIndex: params.removedIndex!,
          });
        }
      },

      onDragStart: ({ isSource }) => {
        // Seems to be called for all container instances on the page,
        // perhaps to facilitate cross-container drag-drop behaviour.
        // Here we're ignoring when this container isn't the 'source'.
        if (!isSource) return;

        debug('on drag start');

        const dragData = dragDataRef.current;
        if (!dragData) return;

        // const containerEl = rootElRef.current!;
        document.body.classList.add('is-dragging');

        onDragStart(dragData);
        vibrateDevice();
      },

      onDragEnd: ({ isSource }) => {
        // Seems to be called for all container instances on the page,
        // perhaps to facilitate cross-container drag-drop behaviour.
        // Here we're ignoring when this container isn't the 'source'.
        if (!isSource) return;

        const dragData = dragDataRef.current;
        if (!dragData) return;

        document.body.classList.remove('is-dragging');

        setTimeout(() => {
          debug('on drag end');
          onDragEnd(dragData);
          vibrateDevice();
        });
      },
    };
  };

  useEffect(() => {
    // don't run on first mount, only when deps change thereafter
    if (isMountedRef.current) {
      if (smoothDndRef.current) {
        debug('update options', isMountedRef.current);
        smoothDndRef.current.setOptions(getSmoothDndOptions());
      }
    }

    isMountedRef.current = true;
  }, [onDrop]);

  const updateDragData = (event: MouseEvent | TouchEvent) => {
    const containerEl = innerRef.current!;

    // checks to see if event was on a drag handle
    const handleEl = (event.target as HTMLElement)?.closest<HTMLElement>(
      dragHandleSelector
    );

    const pointerX =
      (event as MouseEvent).pageX || (event as TouchEvent).touches?.[0]?.pageX;

    const pointerY =
      (event as MouseEvent).pageY || (event as TouchEvent).touches?.[0]?.pageY;

    const dragItemEl = handleEl?.closest<HTMLElement>(dragItemSelector);

    const itemEls = Array.from(
      containerEl.querySelectorAll<HTMLElement>(dragItemSelector)
    );

    if (!handleEl || !dragItemEl) {
      return;
    }

    // store in a ref so we can use them in smooth-dnd callbacks
    return (dragDataRef.current = {
      containerEl,
      pointerX,
      pointerY,
      handleEl,
      itemEls,
      dragItemEl,
    });
  };

  const Tag = tag;

  return (
    <Tag
      className="sortable"
      style={style}
      ref={useCallback((el) => {
        debug('ref change', el);

        innerRef.current = el;

        if (smoothDndRef.current) {
          debug('teardown smooth-dnd');
          smoothDndRef.current.dispose();
          smoothDndRef.current = null;
        }

        if (el) {
          debug('setup smooth-dnd');
          smoothDndRef.current = smoothDnD(el, getSmoothDndOptions());
          el.smoothDnd = smoothDndRef.current;
        }
      }, [])}
      onMouseDown={useCallback(
        (event) => {
          const dragData = updateDragData(event);
          if (!dragData) return;

          const onMouseMove = (event: MouseEvent) => {
            const deltaY = dragData.pointerY - event.pageY;
            const deltaX = dragData.pointerX - event.pageX;
            const willDrag = Math.abs(deltaY) > 1 || Math.abs(deltaX) > 1;

            if (willDrag) {
              debug('before drag start');
              teardown();

              onBeforeDragStart({
                ...dragDataRef.current!,
              });
            }
          };

          const teardown = () => {
            removeEventListener('mouseup', teardown);
            removeEventListener('mousemove', onMouseMove, true);
          };

          addEventListener('mousemove', onMouseMove, true);
          addEventListener('mouseup', teardown);
        },
        [onBeforeDragStart]
      )}
      onTouchStart={useCallback((event) => {
        const dragData = updateDragData(event);
        if (!dragData) return;

        const onTouchMove = (event: TouchEvent) => {
          const { pageX, pageY } = event.touches[0];
          const deltaX = dragData.pointerX - pageX;
          const deltaY = dragData.pointerY - pageY;
          const isScrollGesture = Math.abs(deltaX) > 5 || Math.abs(deltaY) > 5;

          if (isScrollGesture) {
            debug('scroll detected: cancelling drag handle detection');
            teardown();
          }
        };

        const onTouchEnd = () => {
          teardown();
        };

        const dragBeginTimeout = setTimeout(() => {
          debug('drag handle down timeout reached');

          // the timeout was reached meaning we didn't detect
          // a scroll gesture in the delay window so
          // this gesture will become a drag
          teardown();

          // touch devices seem to enter drag mode as soon as the timeout
          // is reached they don't wait for a 1px move like mouse does
          onBeforeDragStart(dragData);
        }, dragBeginDelay);

        const teardown = () => {
          clearTimeout(dragBeginTimeout);
          removeEventListener('touchmove', onTouchMove, true);
          removeEventListener('touchend', onTouchEnd);
        };

        addEventListener('touchmove', onTouchMove, true);
        addEventListener('touchend', onTouchEnd);
      }, [])}
    >
      {children}
      <style jsx>{`
        :global(body.is-dragging *) {
          cursor: grabbing !important;
        }
      `}</style>
    </Tag>
  );
};

export const SortableItem = ({
  style,
  tag = 'div',
  className,
  ...props
}: {
  tag?: 'div' | 'li';
  style?: CSSProperties;
  children: ReactNode;
  className?: string;
}) => {
  const Tag = tag;

  return (
    <Tag
      {...props}
      // prevent :focus outline/glow being clipped
      style={{ overflow: 'visible', ...style }}
      className={classNames(
        DEFAULT_DRAGGABLE_CLASS_NAME,
        className,
        wrapperClass
      )}
    />
  );
};
