import { useRef } from 'react';
import type { Position } from 'css-box-model';
import rafSchedule from 'raf-schd';
import { useMemo, useCallback } from 'use-memo-one';
import memoizeOne from 'memoize-one';
import { invariant } from '../../invariant';
import checkForNestedScrollContainers from './check-for-nested-scroll-container';
import * as dataAttr from '../data-attributes';
import { origin } from '../../state/position';
import getScroll from './get-scroll';
import type {
  DroppableEntry,
  DroppableCallbacks,
} from '../../state/registry/registry-types';
import getEnv from './get-env';
import type { Env } from './get-env';
import type {
  Id,
  DroppableId,
  TypeId,
  DroppableDimension,
  DroppableDescriptor,
  Direction,
  ScrollOptions,
  DroppableMode,
} from '../../types';
import getDimension from './get-dimension';
import AppContext from '../context/app-context';
import type { AppContextValue } from '../context/app-context';
import { warning } from '../../dev-warning';
import getListenerOptions from './get-listener-options';
import useRequiredContext from '../use-required-context';
import usePreviousRef from '../use-previous-ref';
import useLayoutEffect from '../use-isomorphic-layout-effect';
import useUniqueId from '../use-unique-id';

interface Props {
  droppableId: DroppableId;
  type: TypeId;
  mode: DroppableMode;
  direction: Direction;
  isDropDisabled: boolean;
  isCombineEnabled: boolean;
  ignoreContainerClipping: boolean;
  getDroppableRef: () => HTMLElement | null;
}

interface WhileDragging {
  ref: HTMLElement;
  descriptor: DroppableDescriptor;
  env: Env;
  scrollOptions: ScrollOptions;
}

const getClosestScrollableFromDrag = (
  dragging?: WhileDragging | null,
): HTMLElement | null => (dragging && dragging.env.closestScrollable) || null;

export default function useDroppablePublisher(args: Props) {
  const whileDraggingRef = useRef<WhileDragging | null>(null);
  const appContext: AppContextValue = useRequiredContext(AppContext);
  const uniqueId: Id = useUniqueId('droppable');
  const { registry, marshal } = appContext;
  const previousRef = usePreviousRef(args);

  const descriptor = useMemo<DroppableDescriptor>(
    () => ({
      id: args.droppableId,
      type: args.type,
      mode: args.mode,
    }),
    [args.droppableId, args.mode, args.type],
  );
  const publishedDescriptorRef = useRef<DroppableDescriptor>(descriptor);

  const memoizedUpdateScroll = useMemo(
    () =>
      memoizeOne((x: number, y: number) => {
        invariant(
          whileDraggingRef.current,
          'Can only update scroll when dragging',
        );
        const scroll: Position = { x, y };
        marshal.updateDroppableScroll(descriptor.id, scroll);
      }),
    [descriptor.id, marshal],
  );

  const getClosestScroll = useCallback((): Position => {
    const dragging: WhileDragging | null = whileDraggingRef.current;
    if (!dragging || !dragging.env.closestScrollable) {
      return origin;
    }

    return getScroll(dragging.env.closestScrollable);
  }, []);

  const updateScroll = useCallback(() => {
    // reading scroll value when called so value will be the latest
    const scroll: Position = getClosestScroll();
    memoizedUpdateScroll(scroll.x, scroll.y);
  }, [getClosestScroll, memoizedUpdateScroll]);

  const scheduleScrollUpdate = useMemo(
    () => rafSchedule(updateScroll),
    [updateScroll],
  );

  const onClosestScroll = useCallback(() => {
    const dragging: WhileDragging | null = whileDraggingRef.current;
    const closest: Element | null = getClosestScrollableFromDrag(dragging);

    invariant(
      dragging && closest,
      'Could not find scroll options while scrolling',
    );
    const options: ScrollOptions = dragging.scrollOptions;
    if (options.shouldPublishImmediately) {
      updateScroll();
      return;
    }
    scheduleScrollUpdate();
  }, [scheduleScrollUpdate, updateScroll]);

  const getDimensionAndWatchScroll = useCallback(
    (windowScroll: Position, options: ScrollOptions) => {
      invariant(
        !whileDraggingRef.current,
        'Cannot collect a droppable while a drag is occurring',
      );
      const previous: Props = previousRef.current;
      const ref: HTMLElement | null = previous.getDroppableRef();
      invariant(ref, 'Cannot collect without a droppable ref');
      const env: Env = getEnv(ref);

      const dragging: WhileDragging = {
        ref,
        descriptor,
        env,
        scrollOptions: options,
      };
      // side effect
      whileDraggingRef.current = dragging;

      const dimension: DroppableDimension = getDimension({
        ref,
        descriptor,
        env,
        windowScroll,
        direction: previous.direction,
        isDropDisabled: previous.isDropDisabled,
        isCombineEnabled: previous.isCombineEnabled,
        shouldClipSubject: !previous.ignoreContainerClipping,
      });

      const scrollable: Element | null = env.closestScrollable;

      if (scrollable) {
        scrollable.setAttribute(
          dataAttr.scrollContainer.contextId,
          appContext.contextId,
        );

        // bind scroll listener
        scrollable.addEventListener(
          'scroll',
          onClosestScroll,
          getListenerOptions(dragging.scrollOptions),
        );
        // print a debug warning if using an unsupported nested scroll container setup
        if (process.env.NODE_ENV !== 'production') {
          checkForNestedScrollContainers(scrollable);
        }
      }

      return dimension;
    },
    [appContext.contextId, descriptor, onClosestScroll, previousRef],
  );

  const getScrollWhileDragging = useCallback((): Position => {
    const dragging: WhileDragging | null = whileDraggingRef.current;
    const closest: Element | null = getClosestScrollableFromDrag(dragging);
    invariant(
      dragging && closest,
      'Can only recollect Droppable client for Droppables that have a scroll container',
    );

    return getScroll(closest);
  }, []);

  const dragStopped = useCallback(() => {
    const dragging: WhileDragging | null = whileDraggingRef.current;
    invariant(dragging, 'Cannot stop drag when no active drag');
    const closest = getClosestScrollableFromDrag(dragging);

    // goodbye old friend
    whileDraggingRef.current = null;

    if (!closest) {
      return;
    }

    // unwatch scroll
    scheduleScrollUpdate.cancel();
    closest.removeAttribute(dataAttr.scrollContainer.contextId);
    closest.removeEventListener(
      'scroll',
      onClosestScroll,
      // See: https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/removeEventListener
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      getListenerOptions(dragging.scrollOptions) as any,
    );
  }, [onClosestScroll, scheduleScrollUpdate]);

  const scroll = useCallback((change: Position) => {
    // arrange
    const dragging: WhileDragging | null = whileDraggingRef.current;
    invariant(dragging, 'Cannot scroll when there is no drag');
    const closest: Element | null = getClosestScrollableFromDrag(dragging);
    invariant(closest, 'Cannot scroll a droppable with no closest scrollable');

    // act
    closest.scrollTop += change.y;
    closest.scrollLeft += change.x;
  }, []);

  const callbacks: DroppableCallbacks = useMemo(() => {
    return {
      getDimensionAndWatchScroll,
      getScrollWhileDragging,
      dragStopped,
      scroll,
    };
  }, [dragStopped, getDimensionAndWatchScroll, getScrollWhileDragging, scroll]);

  const entry: DroppableEntry = useMemo(
    () => ({
      uniqueId,
      descriptor,
      callbacks,
    }),
    [callbacks, descriptor, uniqueId],
  );

  // Register with the marshal and let it know of:
  // - any descriptor changes
  // - when it unmounts
  useLayoutEffect(() => {
    publishedDescriptorRef.current = entry.descriptor;
    registry.droppable.register(entry);

    return () => {
      if (whileDraggingRef.current) {
        warning(
          'Unsupported: changing the droppableId or type of a Droppable during a drag',
        );
        dragStopped();
      }

      registry.droppable.unregister(entry);
    };
  }, [callbacks, descriptor, dragStopped, entry, marshal, registry.droppable]);

  // update is enabled with the marshal
  // only need to update when there is a drag
  useLayoutEffect(() => {
    if (!whileDraggingRef.current) {
      return;
    }
    marshal.updateDroppableIsEnabled(
      publishedDescriptorRef.current.id,
      !args.isDropDisabled,
    );
  }, [args.isDropDisabled, marshal]);

  // update is combine enabled with the marshal
  // only need to update when there is a drag
  useLayoutEffect(() => {
    if (!whileDraggingRef.current) {
      return;
    }
    marshal.updateDroppableIsCombineEnabled(
      publishedDescriptorRef.current.id,
      args.isCombineEnabled,
    );
  }, [args.isCombineEnabled, marshal]);
}
