import * as React from "react";
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
import { DragAndDropContextProps, DraggingPosition, TreeItem, TreeItemIndex } from "../types";
import { useTreeEnvironment } from "./ControlledTreeEnvironment";
import { useOnDragEnterTreeItemHandler, useOnDragLeaveTreeHandler } from "./useOnDragOverTreeHandler";
import { useCanDropAt } from "./useCanDropAt";
import { useGetViableDragPositions } from "./useGetViableDragPositions";
import { useSideEffect } from "../useSideEffect";
import { buildMapForTrees } from "../utils";
import { useCallSoon } from "../useCallSoon";
import { computeItemHeight } from "./layoutUtils";
import { useStableHandler } from "../use-stable-handler";

const DragAndDropContext = React.createContext<DragAndDropContextProps>(null as any);
export const useDragAndDrop = () => React.useContext(DragAndDropContext);

let currentDragOverEl: Element | null = null;

// TODO tidy up
export const DragAndDropProvider: React.FC<React.PropsWithChildren> = ({ children }) => {
  const environment = useTreeEnvironment();
  const [isProgrammaticallyDragging, setIsProgrammaticallyDragging] = useState(false);
  const [itemHeight, setItemHeight] = useState(39);
  const [viableDragPositions, setViableDragPositions] = useState<{
    [treeId: string]: DraggingPosition[];
  }>({});
  const [programmaticDragIndex, setProgrammaticDragIndex] = useState(0);
  const [draggingItems, setDraggingItems] = useState<TreeItem[]>();
  const draggingPositionRef = useRef<DraggingPosition>();
  const getViableDragPositions = useGetViableDragPositions();
  const callSoon = useCallSoon();
  const dragCodeRef = useRef("_nodrag");
  const dragCountRef = useRef(0);

  const setDragCode = useCallback((value: string): boolean => {
    const hasChanged = dragCodeRef.current !== value;
    dragCodeRef.current = value;
    return hasChanged;
  }, []);

  const setDraggingPosition = useCallback((value: DraggingPosition | undefined, target?: HTMLElement) => {
    draggingPositionRef.current = value;
    const itemId = value?.targetItem;
    if (!itemId && currentDragOverEl) {
      currentDragOverEl.classList.remove("rct-tree-item-li-dragging-over");
      currentDragOverEl = null;
    }
    if (itemId && target) {
      if (currentDragOverEl) {
        currentDragOverEl.classList.remove("rct-tree-item-li-dragging-over");
      }
      if (target.dataset.rctItemId === itemId) {
        target.classList.add("rct-tree-item-li-dragging-over");
        currentDragOverEl = target;
      } else {
        const el = target.closest(`li[data-rct-item-id="${itemId}"]`);
        if (el) {
          el.classList.add("rct-tree-item-li-dragging-over");
          currentDragOverEl = el;
        }
      }
    }
  }, []);

  const incrementDragCount = useCallback(
    (decrement?: boolean) => {
      const newValue = dragCountRef.current + (decrement ? -1 : 1);
      dragCountRef.current = newValue;
      if (newValue === 0) {
        setDraggingPosition(undefined);
        dragCodeRef.current = "outside";
      }
    },
    [setDraggingPosition]
  );

  const resetProgrammaticDragIndexForCurrentTree = useCallback(
    (draggingItems: TreeItem[] | undefined) => {
      if (
        environment.activeTreeId &&
        environment.viewState[environment.activeTreeId]?.focusedItem &&
        environment.linearItems &&
        draggingItems
      ) {
        const focusItem = environment.viewState[environment.activeTreeId]!.focusedItem;
        const treeDragPositions = getViableDragPositions(environment.activeTreeId, draggingItems);
        const newPos = treeDragPositions.findIndex((pos) => {
          if (pos.targetType === "item") {
            return pos.targetItem === focusItem;
          }
          return false;
        });

        if (newPos) {
          setProgrammaticDragIndex(Math.min(newPos + 1, treeDragPositions.length - 1));
        } else {
          setProgrammaticDragIndex(0);
        }
      } else {
        setProgrammaticDragIndex(0);
      }
    },
    [environment.activeTreeId, environment.linearItems, environment.viewState, getViableDragPositions]
  );

  const resetState = useCallback(() => {
    dragCountRef.current = 0;
    dragCodeRef.current = "_nodrag";
    setDraggingPosition(undefined);
    setIsProgrammaticallyDragging(false);
    setViableDragPositions({});
    setProgrammaticDragIndex(0);
    setDraggingItems(undefined);
  }, [setDraggingPosition]);

  useSideEffect(
    () => {
      if (
        environment.activeTreeId &&
        environment.linearItems[environment.activeTreeId] &&
        viableDragPositions[environment.activeTreeId]
      ) {
        resetProgrammaticDragIndexForCurrentTree(draggingItems);
      }
    },
    [
      draggingItems,
      environment.activeTreeId,
      environment.linearItems,
      resetProgrammaticDragIndexForCurrentTree,
      viableDragPositions,
    ],
    [environment.activeTreeId]
  );

  useSideEffect(
    () => {
      if (isProgrammaticallyDragging && environment.activeTreeId) {
        setDraggingPosition(viableDragPositions[environment.activeTreeId][programmaticDragIndex]);
      }
    },
    [programmaticDragIndex, environment.activeTreeId, isProgrammaticallyDragging, viableDragPositions],
    [programmaticDragIndex, environment.activeTreeId]
  );

  const canDropAt = useCanDropAt();

  const performDrag = (draggingPosition: DraggingPosition, target?: HTMLElement) => {
    if (draggingItems && !canDropAt(draggingPosition, draggingItems)) {
      return;
    }

    setDraggingPosition(draggingPosition, target);
    environment.setActiveTree(draggingPosition.treeId);

    if (draggingItems && environment.activeTreeId !== draggingPosition.treeId) {
      // TODO maybe do only if draggingItems are different to selectedItems
      environment.onSelectItems?.(
        draggingItems.map((item) => item.index),
        draggingPosition.treeId
      );
    }
  };

  const onDragEnterTreeItemHandler = useOnDragEnterTreeItemHandler(
    setDragCode,
    draggingItems,
    itemHeight,
    setDraggingPosition,
    performDrag,
    environment.activeTreeId,
    incrementDragCount
  );

  const onDragLeaveTreeHandler = useOnDragLeaveTreeHandler(incrementDragCount);

  const onDropHandler = useStableHandler(() => {
    if (draggingItems && draggingPositionRef.current && environment.onDrop) {
      environment.onDrop(draggingItems, draggingPositionRef.current);
      const treeId = draggingPositionRef.current.treeId;

      callSoon(() => {
        environment.onFocusItem?.(draggingItems[0], treeId);
        resetState();
      });
    }
  });

  const onStartDraggingItems = useCallback(
    (items, treeId) => {
      dragCountRef.current = 0;
      const treeViableDragPositions = buildMapForTrees(environment.treeIds, (treeId) =>
        getViableDragPositions(treeId, items)
      );

      // TODO what if trees have different heights and drag target changes?
      const height = computeItemHeight(treeId);
      setItemHeight(height);
      setDraggingItems(items);
      setViableDragPositions(treeViableDragPositions);

      if (environment.activeTreeId) {
        resetProgrammaticDragIndexForCurrentTree(items);
      }
    },
    [environment.activeTreeId, environment.treeIds, getViableDragPositions, resetProgrammaticDragIndexForCurrentTree]
  );

  const startProgrammaticDrag = useCallback(() => {
    if (!environment.canDragAndDrop) {
      return;
    }

    if (environment.activeTreeId) {
      const draggingItems =
        environment.viewState[environment.activeTreeId]?.selectedItems ??
        ([environment.viewState[environment.activeTreeId]?.focusedItem] as TreeItemIndex[]);

      if (draggingItems.length === 0 || draggingItems[0] === undefined) {
        return;
      }

      const resolvedDraggingItems = draggingItems.map((id) => environment.items[id]);

      if (environment.canDrag && !environment.canDrag(resolvedDraggingItems)) {
        return;
      }

      onStartDraggingItems(resolvedDraggingItems, environment.activeTreeId);
      setTimeout(() => {
        setIsProgrammaticallyDragging(true);
        // Needs to be done after onStartDraggingItems was called, so that viableDragPositions is populated
      });
    }
  }, [onStartDraggingItems, environment]);

  const abortProgrammaticDrag = useCallback(() => {
    resetState();
  }, [resetState]);

  const completeProgrammaticDrag = useCallback(() => {
    onDropHandler();
    resetState();
  }, [onDropHandler, resetState]);

  const programmaticDragUp = useCallback(() => {
    setProgrammaticDragIndex((oldIndex) => Math.max(0, oldIndex - 1));
  }, []);

  const programmaticDragDown = useCallback(() => {
    if (environment.activeTreeId) {
      setProgrammaticDragIndex((oldIndex) =>
        Math.min(viableDragPositions[environment.activeTreeId!].length, oldIndex + 1)
      );
    }
  }, [environment.activeTreeId, viableDragPositions]);

  const dnd = useMemo<DragAndDropContextProps>(
    () => ({
      onStartDraggingItems,
      startProgrammaticDrag,
      abortProgrammaticDrag,
      completeProgrammaticDrag,
      programmaticDragUp,
      programmaticDragDown,
      draggingItems,
      itemHeight,
      isProgrammaticallyDragging,
      onDragEnterTreeItemHandler,
      onDragLeaveTreeHandler,
      viableDragPositions,
    }),
    [
      onStartDraggingItems,
      startProgrammaticDrag,
      abortProgrammaticDrag,
      completeProgrammaticDrag,
      programmaticDragUp,
      programmaticDragDown,
      draggingItems,
      itemHeight,
      isProgrammaticallyDragging,
      onDragEnterTreeItemHandler,
      onDragLeaveTreeHandler,
      viableDragPositions,
    ]
  );

  useEffect(() => {
    window.addEventListener("dragend", resetState);
    window.addEventListener("drop", onDropHandler);
    return () => {
      window.removeEventListener("dragend", resetState);
      window.removeEventListener("drop", onDropHandler);
    };
  }, [onDropHandler, resetState]);

  return <DragAndDropContext.Provider value={dnd}>{children}</DragAndDropContext.Provider>;
};
