import { Keyboard, Mouse } from '@vev/utils';
import { at, clamp, difference, keyBy, last, sum, uniq } from 'lodash';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { BoxProps, SilkeBox } from '../silke-box';
import { SilkeNestedSortableItem } from './silke-nested-sortable-item';
import styles from './silke-nested-sortable-list.scss';
import {
  generatedComputedStates,
  getIndentLevel,
  getMouseHorizontalIndentTarget,
  getParents,
  hasState,
  isParentOfSelf,
  Item,
  ItemState,
  RenderFunction,
  SilkeSortableIconComponent,
} from './silke-nested-sortable-utils';

type SilkeNestedSortableListProps<T> = {
  /** Items in the root to start from, fallback is to use items with no parents */
  rootItems?: string[];
  items: Item<T>[];
  selected?: string[];
  collapsed?: (item: Item<T>) => boolean;
  classNameItem?: string | ((item: Item<T>) => string);
  children: RenderFunction<T>;
  replaceIcon?: SilkeSortableIconComponent<T>;
  onSort?: (updateList: Item<T>[], parentKey: string | undefined, childIndex: number) => void;
  canHaveChildren?: (item: Item<T>) => boolean;
  canDrag?: (selected: string[]) => boolean;
  canSort?: (
    parentKey: string | undefined,
    childIndex: number,
    parents: string[],
    selected: string[],
  ) => boolean | number;
  /** Triggered when collapsed arrow is pressed,gives the updated collapse list */
  onCollapse?: (item: Item<T>, doCollapse: boolean) => void;
  onItemClick?: (e: MouseEvent, item: Item<T>) => void;
  onItemDoubleClick?: (e: React.MouseEvent, item: Item<T>) => void;
  onItemMouseEnter?: (e: React.MouseEvent, item: Item<T>) => void;
  onItemMouseLeave?: (e: React.MouseEvent, item: Item<T>) => void;
  onItemContextMenu?: (e: React.MouseEvent, item: Item<T>) => void;
} & Omit<BoxProps, 'children'>;

export function SilkeNestedSortableList<T>({
  rootItems,
  items,
  selected,
  collapsed,
  children,
  className,
  classNameItem,
  replaceIcon,
  onSort,
  canHaveChildren,
  canSort,
  canDrag,
  onCollapse,
  onItemClick,
  onItemDoubleClick,
  onItemMouseEnter,
  onItemMouseLeave,
  onItemContextMenu,
  ...rest
}: SilkeNestedSortableListProps<T>) {
  const ref = useRef<HTMLDivElement>(null);
  const [pendingClickItem, setPendingClickItem] = useState<Item<T>>();
  const [startDrag, setStartDrag] = useState(false);
  const [dropState, setDropState] = useState<{
    index: number;
    state: number;
    childIndex: number;
    top: number;
  }>();

  let cl = styles.list;
  if (className) cl += ' ' + className;
  if (dropState) cl += ' ' + styles.dragging;

  const [computedStates, parentMap, itemMap] = useMemo(() => {
    const itemMap = keyBy(items, 'key');
    const parentMap: { [key: string]: string } = {};
    for (const item of items) {
      if (item?.children) {
        for (const childKey of item.children) parentMap[childKey] = item.key;
      }
    }

    const rootItemKeys =
      rootItems || items.filter((item) => !(item.key in parentMap)).map((item) => item.key);

    const computedStates = generatedComputedStates(
      itemMap,
      rootItemKeys,
      selected,
      collapsed,
      canHaveChildren,
    );

    return [computedStates, parentMap, itemMap];
  }, [items, selected, collapsed, rootItems, canHaveChildren]);

  const handleCollapse = useCallback(
    (item: Item<T>) => {
      onCollapse?.(item, !collapsed?.(item));
    },
    [collapsed, onCollapse],
  );
  const handleItemMouseDown = useCallback(
    (e: React.MouseEvent<HTMLDivElement>, item: Item<T>) => {
      if (e.button === 2 && onItemContextMenu) return onItemContextMenu(e, item);
      if (e.button !== 0) return;

      const shouldDragBeDone = onSort && (!canDrag || canDrag(selected || []));
      // If already selected we have to wait to see if the user wants to drag or not, will be handled in mouse up of drag
      // If not selected we can handle the mouse event immediately
      if (shouldDragBeDone && selected?.includes(item.key)) {
        setPendingClickItem(item);
      } else {
        setPendingClickItem(undefined);
        onItemClick?.(e.nativeEvent, item);
      }
      if (shouldDragBeDone) setStartDrag(true);
    },
    [onItemClick, onSort, canDrag, onItemContextMenu, selected],
  );

  useEffect(() => {
    if (selected?.length) {
      // Timing out for a while to not make the list jumpy when user clicks fast around
      const timeout = setTimeout(() => {
        const el = ref.current?.querySelector('.' + styles.selected) as HTMLElement | undefined;
        if (el) (el as any).scrollIntoViewIfNeeded?.();
      }, 500);

      return () => clearTimeout(timeout);
    }
  }, [selected?.join(',')]);

  useEffect(() => {
    const listEl = ref.current;
    if (!startDrag || !listEl) return;
    const itemElements = listEl.querySelectorAll<HTMLElement>('.' + styles.item);
    const offsets: [offsetTop: number, height: number][] = [];
    itemElements?.forEach((el) => {
      offsets.push([el.offsetTop, el.offsetHeight]);
    });

    Keyboard.onKey('Escape', () => {
      setStartDrag(false);
      setDropState(undefined);
    });

    let dragState: 'pending' | 'dragging' = 'pending';
    let dropState: {
      index: number;
      state: number;
      parentKey: string;
      childIndex: number;
      top: number;
    };
    const handleMouseMove = (e: MouseEvent) => {
      if (!selected || !selected.length) return;
      if (dragState === 'pending') {
        if (Mouse.getDownDist() > 10) dragState = 'dragging';
      } else {
        const { top } = listEl.getBoundingClientRect();
        const cursorOffsetY = e.pageY - top;
        let index = -1;

        if (cursorOffsetY > offsets[0][0]) {
          for (index = 0; index < offsets.length; index++) {
            const [top, height] = offsets[index];
            if (cursorOffsetY <= top + height) break;
          }
        }

        let placementState: number = ItemState.placeBefore;
        let dropLineTopPos = 0;
        if (index === -1) {
          placementState = ItemState.placeBefore;
          index = 0;
        } else if (index === computedStates.length) {
          placementState = ItemState.placeAfter;
          index = computedStates.length - 1;
          // Place dropline after last element in list
          dropLineTopPos = sum(last(offsets) || []);
        } else {
          const [targetState, targetItem] = computedStates[index];
          const allowChildren = canHaveChildren
            ? canHaveChildren(targetItem)
            : Boolean(targetItem?.children);
          const offset = offsets[clamp(index, 0, computedStates.length - 1)];
          const isTargetSelected = hasState(targetState, ItemState.selected);
          const isTargetCollapsed =
            hasState(targetState, ItemState.collapsed) || !targetItem?.children?.length;

          const insideBottomPos = offset[0] + offset[1] + (isTargetCollapsed ? -10 : 0);
          dropLineTopPos = offset[0];

          if (
            allowChildren &&
            !isTargetSelected &&
            cursorOffsetY > offset[0] + 10 &&
            cursorOffsetY < insideBottomPos
          ) {
            placementState = ItemState.placeInside;
          } else if (cursorOffsetY > offset[0] + offset[1] / 2) {
            placementState = ItemState.placeAfter;
            dropLineTopPos += offset[1];
          }
        }

        let [, targetItem] = computedStates[index];

        let parentKey: string;
        let childIndex = 0;

        if (placementState === ItemState.placeInside) {
          parentKey = targetItem.key;
        } else {
          if (placementState === ItemState.placeAfter) {
            const mouseIndentLevels = Math.ceil(e.pageX - Mouse.pageDownX) / 24;
            targetItem = getMouseHorizontalIndentTarget(
              targetItem,
              mouseIndentLevels,
              itemMap,
              parentMap,
            );
          }

          parentKey = parentMap[targetItem.key];
          const parent = itemMap[parentKey];
          childIndex = parent?.children?.indexOf(targetItem.key) || 0;
          if (placementState === ItemState.placeAfter) childIndex++;
        }

        const parents = [parentKey, ...getParents(parentKey, parentMap)].filter(Boolean);
        const allowSort = !canSort || canSort(parentKey, childIndex, parents, selected);
        const selectionParentOfSelf = selected.some((key) =>
          isParentOfSelf(parentMap[targetItem.key], key, parentMap),
        );

        if (!allowSort || selectionParentOfSelf) placementState |= ItemState.placementInvalid;

        const indent = parents.length;

        dropState = {
          index,
          state: placementState | indent,
          childIndex,
          parentKey,
          top: dropLineTopPos,
        };
        setDropState(dropState);
      }
    };

    const handleMouseUp = (e: MouseEvent) => {
      setStartDrag(false);
      setDropState(undefined);
      if (dragState === 'pending') {
        if (pendingClickItem && onItemClick) onItemClick(e, pendingClickItem);
        return;
      }
      if (dropState && selected && !hasState(dropState.state, ItemState.placementInvalid)) {
        const { parentKey, childIndex, index, state } = dropState;
        const newItems = items.slice();
        let currentChildIndex = childIndex;
        for (let i = 0; i < newItems.length; i++) {
          const item = newItems[i];
          const { children } = item;
          if (children) {
            const newChildren = difference(children, selected);
            // if child difference then some children were removed so need to update item
            if (newChildren.length !== children.length) {
              newItems[i] = { ...item, children: newChildren };
            }
          }

          if (item.key === parentKey) {
            const newChildren = (newItems[i].children || []).slice();

            // Have to offset index if child existed in the children array above the position we're inserting at
            currentChildIndex -= selected.reduce((offset, key) => {
              const index = children ? children.indexOf(key) : -1;
              return index !== -1 && index < currentChildIndex ? offset + 1 : offset;
            }, 0);

            newChildren.splice(currentChildIndex, 0, ...uniq(selected));
            newItems[i] = { ...newItems[i], children: newChildren };
          }
        }
        // If no parent we notify about the list change by just sorting the main list
        if (!parentKey) {
          let atIndex = index;
          if (hasState(state, ItemState.placeAfter) && atIndex > 0) atIndex++;
          const selectedItems = selected.map((key) => {
            const index = newItems.findIndex((item) => item.key === key);
            const [item] = newItems.splice(index, 1);
            if (index < atIndex) atIndex--;
            return item;
          });
          newItems.splice(atIndex, 0, ...selectedItems);
        }

        onSort?.(newItems, parentKey, childIndex);
      }
    };
    Mouse.on('mousemove', handleMouseMove);
    Mouse.on('mouseup', handleMouseUp);
    return () => {
      Mouse.off('mousemove', handleMouseMove);
      Mouse.off('mouseup', handleMouseUp);
    };
  }, [startDrag, selected, computedStates, onItemClick, canHaveChildren, canSort, parentMap]);

  const isClassNameItemAFunction = typeof classNameItem === 'function';
  return (
    <SilkeBox data-dragging={Boolean(dropState)} className={cl} column {...(rest as any)}>
      <div ref={ref} style={{ position: 'relative' }}>
        {startDrag && dropState && (
          <DropPoint state={dropState?.state || 0} top={dropState?.top || 0} />
        )}
        {computedStates.map(([state, item], index) => (
          <SilkeNestedSortableItem
            key={index + item.key}
            item={item}
            replaceIcon={replaceIcon}
            className={isClassNameItemAFunction ? classNameItem(item) : classNameItem}
            state={
              state |
              (startDrag && index === dropState?.index
                ? dropState.state & (ItemState.placeInside | ItemState.placementInvalid)
                : 0)
            }
            index={index}
            onMouseDown={handleItemMouseDown}
            onDoubleClick={onItemDoubleClick}
            onMouseEnter={onItemMouseEnter}
            onMouseLeave={onItemMouseLeave}
            onCollapseToggle={onCollapse && handleCollapse}
          >
            {item.render || children}
          </SilkeNestedSortableItem>
        ))}
      </div>
    </SilkeBox>
  );
}

function DropPoint({ state, top }: { state: number; top: number }) {
  // Handled by outlining the element
  if (hasState(state, ItemState.placeInside)) return null;

  const indent = getIndentLevel(state);
  let cl = styles.dropPoint;
  if (hasState(state, ItemState.placementInvalid)) cl += ' ' + styles.placementInvalid;
  if (indent) cl += ' ' + styles[`indent${indent}`];

  return (
    <div
      className={cl}
      style={{
        top,
      }}
    />
  );
}
