// @flow
import { useRef } from 'react';
import invariant from 'tiny-invariant';
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 checkForNestedScrollContainers from './check-for-nested-scroll-container';
import { origin } from '../../state/position';
import getScroll from './get-scroll';
import type {
  DimensionMarshal,
  DroppableCallbacks,
  RecollectDroppableOptions,
} from '../../state/dimension-marshal/dimension-marshal-types';
import getEnv, { type Env } from './get-env';
import type {
  DroppableId,
  TypeId,
  DroppableDimension,
  DroppableDescriptor,
  Direction,
  ScrollOptions,
} from '../../types';
import getDimension from './get-dimension';
import AppContext, { type AppContextValue } from '../context/app-context';
import withoutPlaceholder from './without-placeholder';
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';

type Props = {|
  droppableId: DroppableId,
  type: TypeId,
  direction: Direction,
  isDropDisabled: boolean,
  isCombineEnabled: boolean,
  ignoreContainerClipping: boolean,
  getPlaceholderRef: () => ?HTMLElement,
  getDroppableRef: () => ?HTMLElement,
|};

type WhileDragging = {|
  ref: HTMLElement,
  descriptor: DroppableDescriptor,
  env: Env,
  scrollOptions: ScrollOptions,
|};

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

export default function useDroppableDimensionPublisher(args: Props) {
  const whileDraggingRef = useRef<?WhileDragging>(null);
  const appContext: AppContextValue = useRequiredContext(AppContext);
  const marshal: DimensionMarshal = appContext.marshal;
  const previousRef = usePreviousRef(args);
  const descriptor = useMemo<DroppableDescriptor>(() => {
    return {
      id: args.droppableId,
      type: args.type,
    };
  }, [args.droppableId, 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 = whileDraggingRef.current;
    if (!dragging || !dragging.env.closestScrollable) {
      return origin;
    }

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

  const updateScroll = useCallback(() => {
    const scroll: Position = getClosestScroll();
    memoizedUpdateScroll(scroll.x, scroll.y);
  }, [getClosestScroll, memoizedUpdateScroll]);

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

  const onClosestScroll = useCallback(() => {
    const dragging: ?WhileDragging = whileDraggingRef.current;
    const closest: ?Element = 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 = 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,
      });

      if (env.closestScrollable) {
        // bind scroll listener

        env.closestScrollable.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(env.closestScrollable);
        }
      }

      return dimension;
    },
    [descriptor, onClosestScroll, previousRef],
  );
  const recollect = useCallback(
    (options: RecollectDroppableOptions): DroppableDimension => {
      const dragging: ?WhileDragging = whileDraggingRef.current;
      const closest: ?Element = getClosestScrollableFromDrag(dragging);
      invariant(
        dragging && closest,
        'Can only recollect Droppable client for Droppables that have a scroll container',
      );

      const previous: Props = previousRef.current;

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

      if (!options.withoutPlaceholder) {
        return execute();
      }

      return withoutPlaceholder(previous.getPlaceholderRef(), execute);
    },
    [previousRef],
  );
  const dragStopped = useCallback(() => {
    const dragging: ?WhileDragging = whileDraggingRef.current;
    invariant(dragging, 'Cannot stop drag when no active drag');
    const closest: ?Element = getClosestScrollableFromDrag(dragging);

    // goodbye old friend
    whileDraggingRef.current = null;

    if (!closest) {
      return;
    }

    // unwatch scroll
    scheduleScrollUpdate.cancel();
    closest.removeEventListener(
      'scroll',
      onClosestScroll,
      getListenerOptions(dragging.scrollOptions),
    );
  }, [onClosestScroll, scheduleScrollUpdate]);

  const scroll = useCallback((change: Position) => {
    // arrange
    const dragging: ?WhileDragging = whileDraggingRef.current;
    invariant(dragging, 'Cannot scroll when there is no drag');
    const closest: ?Element = 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,
      recollect,
      dragStopped,
      scroll,
    };
  }, [dragStopped, getDimensionAndWatchScroll, recollect, scroll]);

  // Register with the marshal and let it know of:
  // - any descriptor changes
  // - when it unmounts
  useLayoutEffect(() => {
    publishedDescriptorRef.current = descriptor;
    marshal.registerDroppable(descriptor, callbacks);

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

      marshal.unregisterDroppable(descriptor);
    };
  }, [callbacks, descriptor, dragStopped, marshal]);

  // 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]);
}
