/**
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 */
import {$createListNode, $isListNode} from '@lexical/list';
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {eventFiles} from '@lexical/rich-text';
import {mergeRegister} from '@lexical/utils';
import {
  $getNearestNodeFromDOMNode,
  $getNodeByKey,
  $getRoot,
  COMMAND_PRIORITY_HIGH,
  COMMAND_PRIORITY_LOW,
  DRAGOVER_COMMAND,
  DROP_COMMAND,
  LexicalEditor,
  LexicalNode,
} from 'lexical';
import {
  DragEvent as ReactDragEvent,
  TouchEvent,
  useCallback,
  useEffect,
  useRef,
  useState,
} from 'react';
import {createPortal} from 'react-dom';
import {BlockIcon} from '@standardnotes/icons';

import {isHTMLElement} from '../../Utils/guard';
import {Point} from '../../Utils/point';
import {ContainsPointReturn, Rect} from '../../Utils/rect';

const DRAGGABLE_BLOCK_MENU_LEFT_SPACE = -2;
const TARGET_LINE_HALF_HEIGHT = 2;
const DRAGGABLE_BLOCK_MENU_CLASSNAME = 'draggable-block-menu';
const DRAG_DATA_FORMAT = 'application/x-lexical-drag-block';
const TEXT_BOX_HORIZONTAL_PADDING = 24;

const Downward = 1;
const Upward = -1;
const Indeterminate = 0;

let prevIndex = Infinity;

function getCurrentIndex(keysLength: number): number {
  if (keysLength === 0) {
    return Infinity;
  }
  if (prevIndex >= 0 && prevIndex < keysLength) {
    return prevIndex;
  }

  return Math.floor(keysLength / 2);
}

function getTopLevelNodeKeys(editor: LexicalEditor): string[] {
  return editor.getEditorState().read(() => $getRoot().getChildrenKeys());
}

function elementContainingEventLocation(
  anchorElem: HTMLElement,
  element: HTMLElement,
  eventLocation: Point,
): {contains: ContainsPointReturn; element: HTMLElement} {
  const anchorElementRect = anchorElem.getBoundingClientRect();

  const elementDomRect = Rect.fromDOM(element);
  const {marginTop, marginBottom} = window.getComputedStyle(element);

  const rect = elementDomRect.generateNewRect({
    bottom: elementDomRect.bottom + parseFloat(marginBottom),
    left: anchorElementRect.left,
    right: anchorElementRect.right,
    top: elementDomRect.top - parseFloat(marginTop),
  });

  const children = Array.from(element.children);

  const shouldRecurseIntoChildren = ['UL', 'OL', 'LI'].includes(
    element.tagName,
  );

  if (shouldRecurseIntoChildren) {
    for (const child of children) {
      const isLeaf = child.children.length === 0;
      if (isLeaf) {
        continue;
      }
      const childResult = elementContainingEventLocation(
        anchorElem,
        child as HTMLElement,
        eventLocation,
      );

      if (childResult.contains.result) {
        return childResult;
      }
    }
  }

  return {contains: rect.contains(eventLocation), element: element};
}

function getBlockElement(
  anchorElem: HTMLElement,
  editor: LexicalEditor,
  eventLocation: Point,
): HTMLElement | null {
  const topLevelNodeKeys = getTopLevelNodeKeys(editor);

  let blockElem: HTMLElement | null = null;

  editor.getEditorState().read(() => {
    let index = getCurrentIndex(topLevelNodeKeys.length);
    let direction = Indeterminate;

    while (index >= 0 && index < topLevelNodeKeys.length) {
      const key = topLevelNodeKeys[index];
      const elem = editor.getElementByKey(key);
      if (elem === null) {
        break;
      }
      const {contains, element} = elementContainingEventLocation(
        anchorElem,
        elem,
        eventLocation,
      );

      if (contains.result) {
        blockElem = element;
        prevIndex = index;
        break;
      }

      if (direction === Indeterminate) {
        if (contains.reason.isOnTopSide) {
          direction = Upward;
        } else if (contains.reason.isOnBottomSide) {
          direction = Downward;
        } else {
          // stop search block element
          direction = Infinity;
        }
      }

      index += direction;
    }
  });

  return blockElem;
}

function isOnMenu(element: HTMLElement): boolean {
  return !!element.closest(`.${DRAGGABLE_BLOCK_MENU_CLASSNAME}`);
}

function setMenuPosition(
  targetElem: HTMLElement | null,
  floatingElem: HTMLElement,
  anchorElem: HTMLElement,
) {
  if (!targetElem) {
    floatingElem.style.opacity = '0';
    return;
  }

  const targetRect = targetElem.getBoundingClientRect();
  const targetStyle = window.getComputedStyle(targetElem);
  const floatingElemRect = floatingElem.getBoundingClientRect();
  const anchorElementRect = anchorElem.getBoundingClientRect();

  const top =
    targetRect.top +
    (parseInt(targetStyle.lineHeight, 10) - floatingElemRect.height) / 2 -
    anchorElementRect.top;

  const left = DRAGGABLE_BLOCK_MENU_LEFT_SPACE;

  floatingElem.style.opacity = '1';
  floatingElem.style.transform = `translate(${left}px, ${top}px)`;
}

function setDragImage(
  dataTransfer: DataTransfer,
  draggableBlockElem: HTMLElement,
) {
  const {transform} = draggableBlockElem.style;

  // Remove dragImage borders
  draggableBlockElem.style.transform = 'translateZ(0)';
  dataTransfer.setDragImage(draggableBlockElem, 0, 0);

  setTimeout(() => {
    draggableBlockElem.style.transform = transform;
  });
}

function setTargetLine(
  targetLineElem: HTMLElement,
  targetBlockElem: HTMLElement,
  mouseY: number,
  anchorElem: HTMLElement,
) {
  const targetStyle = window.getComputedStyle(targetBlockElem);
  const {top: targetBlockElemTop, height: targetBlockElemHeight} =
    targetBlockElem.getBoundingClientRect();
  const {top: anchorTop, width: anchorWidth} =
    anchorElem.getBoundingClientRect();

  let lineTop = targetBlockElemTop;
  // At the bottom of the target
  if (mouseY - targetBlockElemTop > targetBlockElemHeight / 2) {
    lineTop += targetBlockElemHeight + parseFloat(targetStyle.marginBottom);
  } else {
    lineTop -= parseFloat(targetStyle.marginTop);
  }

  const top = lineTop - anchorTop - TARGET_LINE_HALF_HEIGHT;
  const left = TEXT_BOX_HORIZONTAL_PADDING - DRAGGABLE_BLOCK_MENU_LEFT_SPACE;

  targetLineElem.style.transform = `translate(${left}px, ${top}px)`;
  targetLineElem.style.width = `${
    anchorWidth -
    (TEXT_BOX_HORIZONTAL_PADDING - DRAGGABLE_BLOCK_MENU_LEFT_SPACE) * 2
  }px`;
  targetLineElem.style.opacity = '.6';
}

function hideTargetLine(targetLineElem: HTMLElement | null) {
  if (targetLineElem) {
    targetLineElem.style.opacity = '0';
  }
}

function useDraggableBlockMenu(
  editor: LexicalEditor,
  anchorElem: HTMLElement,
  isEditable: boolean,
): JSX.Element {
  const scrollerElem = anchorElem.parentElement;

  const menuRef = useRef<HTMLDivElement>(null);
  const targetLineRef = useRef<HTMLDivElement>(null);
  const [draggableBlockElem, setDraggableBlockElem] =
    useState<HTMLElement | null>(null);
  const dragDataRef = useRef<string | null>(null);

  useEffect(() => {
    function onMouseMove(event: MouseEvent) {
      const target = event.target;
      if (!isHTMLElement(target)) {
        setDraggableBlockElem(null);
        return;
      }

      if (isOnMenu(target)) {
        return;
      }

      const _draggableBlockElem = getBlockElement(
        anchorElem,
        editor,
        new Point(event.clientX, event.clientY),
      );

      setDraggableBlockElem(_draggableBlockElem);
    }

    function onMouseLeave() {
      setDraggableBlockElem(null);
    }

    scrollerElem?.addEventListener('mousemove', onMouseMove);
    scrollerElem?.addEventListener('mouseleave', onMouseLeave);

    return () => {
      scrollerElem?.removeEventListener('mousemove', onMouseMove);
      scrollerElem?.removeEventListener('mouseleave', onMouseLeave);
    };
  }, [scrollerElem, anchorElem, editor]);

  useEffect(() => {
    if (menuRef.current) {
      setMenuPosition(draggableBlockElem, menuRef.current, anchorElem);
    }
  }, [anchorElem, draggableBlockElem]);

  const insertDraggedNode = useCallback(
    (
      draggedNode: LexicalNode,
      targetNode: LexicalNode,
      targetBlockElem: HTMLElement,
      pageY: number,
    ) => {
      let nodeToInsert = draggedNode;
      const targetParent = targetNode.getParent();
      const sourceParent = draggedNode.getParent();

      if ($isListNode(sourceParent) && !$isListNode(targetParent)) {
        const newList = $createListNode(sourceParent.getListType());
        newList.append(draggedNode);
        nodeToInsert = newList;
      }

      const {top, height} = targetBlockElem.getBoundingClientRect();
      const shouldInsertAfter = pageY - top > height / 2;
      if (shouldInsertAfter) {
        targetNode.insertAfter(nodeToInsert);
      } else {
        targetNode.insertBefore(nodeToInsert);
      }
    },
    [],
  );

  useEffect(() => {
    function onDragover(event: DragEvent): boolean {
      const [isFileTransfer] = eventFiles(event);
      if (isFileTransfer) {
        return false;
      }
      const {pageY, target} = event;
      if (!isHTMLElement(target)) {
        return false;
      }
      const targetBlockElem = getBlockElement(
        anchorElem,
        editor,
        new Point(event.pageX, pageY),
      );
      const targetLineElem = targetLineRef.current;
      if (targetBlockElem === null || targetLineElem === null) {
        return false;
      }
      setTargetLine(targetLineElem, targetBlockElem, pageY, anchorElem);
      // Prevent default event to be able to trigger onDrop events
      event.preventDefault();
      return true;
    }

    function onDrop(event: DragEvent): boolean {
      const [isFileTransfer] = eventFiles(event);
      if (isFileTransfer) {
        return false;
      }

      const {target, dataTransfer, pageY} = event;
      if (!isHTMLElement(target)) {
        return false;
      }

      const dragData = dataTransfer?.getData(DRAG_DATA_FORMAT) || '';
      const draggedNode = $getNodeByKey(dragData);
      if (!draggedNode) {
        return false;
      }

      const targetBlockElem = getBlockElement(
        anchorElem,
        editor,
        new Point(event.pageX, pageY),
      );
      if (!targetBlockElem) {
        return false;
      }

      const targetNode = $getNearestNodeFromDOMNode(targetBlockElem);
      if (!targetNode) {
        return false;
      }
      if (targetNode === draggedNode) {
        return true;
      }

      insertDraggedNode(draggedNode, targetNode, targetBlockElem, event.pageY);

      setDraggableBlockElem(null);

      return true;
    }

    return mergeRegister(
      editor.registerCommand(
        DRAGOVER_COMMAND,
        (event) => {
          return onDragover(event);
        },
        COMMAND_PRIORITY_LOW,
      ),
      editor.registerCommand(
        DROP_COMMAND,
        (event) => {
          return onDrop(event);
        },
        COMMAND_PRIORITY_HIGH,
      ),
    );
  }, [anchorElem, editor, insertDraggedNode]);

  function onDragStart(event: ReactDragEvent<HTMLDivElement>): void {
    const dataTransfer = event.dataTransfer;
    if (!dataTransfer || !draggableBlockElem) {
      return;
    }
    setDragImage(dataTransfer, draggableBlockElem);
    let nodeKey = '';
    editor.update(() => {
      const node = $getNearestNodeFromDOMNode(draggableBlockElem);
      if (node) {
        nodeKey = node.getKey();
      }
    });
    dataTransfer.setData(DRAG_DATA_FORMAT, nodeKey);
  }

  function onDragEnd(): void {
    hideTargetLine(targetLineRef.current);
  }

  function onTouchStart(): void {
    if (!draggableBlockElem) {
      return;
    }
    editor.update(() => {
      const node = $getNearestNodeFromDOMNode(draggableBlockElem);
      if (!node) {
        return;
      }
      const nodeKey = node.getKey();
      dragDataRef.current = nodeKey;
    });
  }

  function onTouchMove(event: TouchEvent) {
    const {pageX, pageY} = event.targetTouches[0];
    const rootElement = editor.getRootElement();
    if (rootElement) {
      const {top, bottom} = rootElement.getBoundingClientRect();
      const scrollOffset = 20;
      if (pageY - top < scrollOffset) {
        rootElement.scrollTop -= scrollOffset;
      } else if (bottom - pageY < scrollOffset) {
        rootElement.scrollTop += scrollOffset;
      }
    }
    const targetBlockElem = getBlockElement(
      anchorElem,
      editor,
      new Point(pageX, pageY),
    );
    const targetLineElem = targetLineRef.current;
    if (targetBlockElem === null || targetLineElem === null) {
      return;
    }
    setTargetLine(targetLineElem, targetBlockElem, pageY, anchorElem);
  }

  function onTouchEnd(event: TouchEvent): void {
    hideTargetLine(targetLineRef.current);

    editor.update(() => {
      const {pageX, pageY} = event.changedTouches[0];

      const dragData = dragDataRef.current || '';
      const draggedNode = $getNodeByKey(dragData);
      if (!draggedNode) {
        return;
      }

      const targetBlockElem = getBlockElement(
        anchorElem,
        editor,
        new Point(pageX, pageY),
      );
      if (!targetBlockElem) {
        return;
      }
      const targetNode = $getNearestNodeFromDOMNode(targetBlockElem);

      if (!targetNode) {
        return;
      }
      if (targetNode === draggedNode) {
        return;
      }

      insertDraggedNode(draggedNode, targetNode, targetBlockElem, pageY);
    });

    setDraggableBlockElem(null);
  }

  return createPortal(
    <>
      <div
        className="icon draggable-block-menu"
        ref={menuRef}
        draggable={true}
        onDragStart={onDragStart}
        onDragEnd={onDragEnd}
        onTouchStart={onTouchStart}
        onTouchMove={onTouchMove}
        onTouchEnd={onTouchEnd}>
        <div className={isEditable ? 'icon' : ''}>
          <BlockIcon className="text-text pointer-events-none" />
        </div>
      </div>
      <div className="draggable-block-target-line" ref={targetLineRef} />
    </>,
    anchorElem,
  );
}

export default function DraggableBlockPlugin({
  anchorElem = document.body,
}: {
  anchorElem?: HTMLElement;
}): JSX.Element {
  const [editor] = useLexicalComposerContext();
  return useDraggableBlockMenu(editor, anchorElem, editor._editable);
}
