import {
  NodeSelection,
  Selection,
  TextSelection,
  Transaction,
} from "prosemirror-state";
import { CellSelection } from "prosemirror-tables";

import { Block } from "../../../../blocks/defaultBlocks.js";
import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor";
import { BlockIdentifier } from "../../../../schema/index.js";
import {
  getBlockInfoAtNearest,
  getNodeId,
} from "../../../getBlockInfoFromPos.js";
import { getNodeById } from "../../../nodeUtil.js";
import { insertBlocks } from "../insertBlocks/insertBlocks.js";
import { removeAndInsertBlocks } from "../replaceBlocks/replaceBlocks.js";

type BlockSelectionData = (
  | {
      type: "text";
      headBlockId: string;
      anchorOffset: number;
      headOffset: number;
    }
  | {
      type: "node";
    }
  | {
      type: "cell";
      anchorCellOffset: number;
      headCellOffset: number;
    }
) & {
  anchorBlockId: string;
};

/**
 * `getBlockSelectionData` and `updateBlockSelectionFromData` are used to save
 * and restore the selection within a block, when the block is moved. This is
 * done by first saving the offsets of the anchor and head from the before
 * positions of their surrounding blocks, as well as the IDs of those blocks. We
 * can then recreate the selection by finding the blocks with those IDs, getting
 * their before positions, and adding the offsets to those positions.
 * @param editor The BlockNote editor instance to get the selection data from.
 */
function getBlockSelectionData(
  editor: BlockNoteEditor<any, any, any>,
): BlockSelectionData {
  return editor.transact((tr) => {
    const anchorBlockPosInfo = getBlockInfoAtNearest(tr, tr.selection.anchor);

    const anchorBlockId = getNodeId(anchorBlockPosInfo.bnBlock.node, tr.doc);

    if (tr.selection instanceof CellSelection) {
      return {
        type: "cell" as const,
        anchorBlockId,
        anchorCellOffset:
          tr.selection.$anchorCell.pos - anchorBlockPosInfo.bnBlock.beforePos,
        headCellOffset:
          tr.selection.$headCell.pos - anchorBlockPosInfo.bnBlock.beforePos,
      };
    } else if (tr.selection instanceof NodeSelection) {
      return {
        type: "node" as const,
        anchorBlockId,
      };
    } else {
      const headBlockPosInfo = getBlockInfoAtNearest(tr, tr.selection.head);

      return {
        type: "text" as const,
        anchorBlockId,
        headBlockId: getNodeId(headBlockPosInfo.bnBlock.node, tr.doc),
        anchorOffset:
          tr.selection.anchor - anchorBlockPosInfo.bnBlock.beforePos,
        headOffset: tr.selection.head - headBlockPosInfo.bnBlock.beforePos,
      };
    }
  });
}

/**
 * `getBlockSelectionData` and `updateBlockSelectionFromData` are used to save
 * and restore the selection within a block, when the block is moved. This is
 * done by first saving the offsets of the anchor and head from the before
 * positions of their surrounding blocks, as well as the IDs of those blocks. We
 * can then recreate the selection by finding the blocks with those IDs, getting
 * their before positions, and adding the offsets to those positions.
 * @param tr The transaction to update the selection in.
 * @param data The selection data to update the selection with (generated by
 * `getBlockSelectionData`).
 */
function updateBlockSelectionFromData(
  tr: Transaction,
  data: BlockSelectionData,
) {
  const anchorBlockPos = getNodeById(data.anchorBlockId, tr.doc)?.posBeforeNode;
  if (anchorBlockPos === undefined) {
    throw new Error(
      `Could not find block with ID ${data.anchorBlockId} to update selection`,
    );
  }

  let selection: Selection;
  if (data.type === "cell") {
    selection = CellSelection.create(
      tr.doc,
      anchorBlockPos + data.anchorCellOffset,
      anchorBlockPos + data.headCellOffset,
    );
  } else if (data.type === "node") {
    selection = NodeSelection.create(tr.doc, anchorBlockPos + 1);
  } else {
    const headBlockPos = getNodeById(data.headBlockId, tr.doc)?.posBeforeNode;
    if (headBlockPos === undefined) {
      throw new Error(
        `Could not find block with ID ${data.headBlockId} to update selection`,
      );
    }

    selection = TextSelection.create(
      tr.doc,
      anchorBlockPos + data.anchorOffset,
      headBlockPos + data.headOffset,
    );
  }

  tr.setSelection(selection);
}

// Replaces top-level `column` blocks with their children, as a `column` is not
// a valid block outside a `columnList`. Other blocks are returned as-is.
function flattenColumns(
  blocks: Block<any, any, any>[],
): Block<any, any, any>[] {
  return blocks.flatMap((block) =>
    block.type === "column" ? block.children : [block],
  );
}

/**
 * Removes the given blocks from the editor, then inserts them before/after a
 * reference block.
 * @param editor The BlockNote editor instance to move the blocks in.
 * @param blocks The blocks to move.
 * @param referenceBlock The reference block to insert the blocks before/after.
 * @param placement Whether to insert the blocks before or after the reference
 * block.
 */
export function moveBlocks(
  editor: BlockNoteEditor<any, any, any>,
  blocks: Block<any, any, any>[],
  referenceBlock: BlockIdentifier,
  placement: "before" | "after",
) {
  editor.transact((tr) => {
    // Don't fix columns/columnLists in the removal step. Since a move is a
    // rearrangement rather than a deletion, columns that it empties out are
    // deliberately left as-is instead of being collapsed - this keeps moves
    // free of side effects (and reversible by moving back), and matches
    // dragging a block out of a column, which doesn't collapse it either.
    // Fixing them mid-move also broke the following case:
    // <column>
    //  <paragraph></paragraph>
    //  <paragraph>Paragraph</paragraph>
    // </column>
    // When the non-empty block is moved up, the column is seen as empty and
    // collapsed in the removal step, so the following insertion fails.
    removeAndInsertBlocks(tr, blocks, [], { fixColumns: false });
    insertBlocks<any, any, any>(
      tr,
      flattenColumns(blocks),
      referenceBlock,
      placement,
    );
  });
}

/**
 * Removes the selected blocks from the editor, then inserts them before/after a
 * reference block. Also updates the selection to match the original selection
 * using `getBlockSelectionData` and `updateBlockSelectionFromData`.
 * @param editor The BlockNote editor instance to move the blocks in.
 * @param referenceBlock The reference block to insert the selected blocks
 * before/after.
 * @param placement Whether to insert the selected blocks before or after the
 * reference block.
 */
export function moveSelectedBlocksAndSelection(
  editor: BlockNoteEditor<any, any, any>,
  referenceBlock: BlockIdentifier,
  placement: "before" | "after",
) {
  // We want this to be a single step in the undo history
  editor.transact((tr) => {
    const blocks = editor.getSelection()?.blocks || [
      editor.getTextCursorPosition().block,
    ];
    const selectionData = getBlockSelectionData(editor);

    moveBlocks(editor, blocks, referenceBlock, placement);

    updateBlockSelectionFromData(tr, selectionData);
  });
}

// Checks if a block is in a valid place after being moved. This check is
// primitive at the moment and only returns false if the block's parent is a
// `columnList` block. This is because regular blocks cannot be direct children
// of `columnList` blocks.
function checkPlacementIsValid(parentBlock?: Block<any, any, any>): boolean {
  return !parentBlock || parentBlock.type !== "columnList";
}

// Gets the placement for moving a block up. This has 3 cases:
// 1. If the block has a previous sibling without children, the placement is
// before it.
// 2. If the block has a previous sibling with children, the placement is after
// the last child.
// 3. If the block has no previous sibling, but is nested, the placement is
// before its parent.
// If the placement is invalid, the function is called recursively until a valid
// placement is found. Returns undefined if no valid placement is found, meaning
// the block is already at the top of the document.
function getMoveUpPlacement(
  editor: BlockNoteEditor<any, any, any>,
  prevBlock?: Block<any, any, any>,
  parentBlock?: Block<any, any, any>,
):
  | { referenceBlock: BlockIdentifier; placement: "before" | "after" }
  | undefined {
  let referenceBlock: Block<any, any, any> | undefined;
  let placement: "before" | "after" | undefined;

  if (!prevBlock) {
    if (parentBlock) {
      referenceBlock = parentBlock;
      placement = "before";
    }
  } else if (prevBlock.children.length > 0) {
    referenceBlock = prevBlock.children[prevBlock.children.length - 1];
    placement = "after";
  } else {
    referenceBlock = prevBlock;
    placement = "before";
  }

  // Case when the block is already at the top of the document.
  if (!referenceBlock || !placement) {
    return undefined;
  }

  const referenceBlockParent = editor.getParentBlock(referenceBlock);
  if (!checkPlacementIsValid(referenceBlockParent)) {
    return getMoveUpPlacement(
      editor,
      placement === "after"
        ? referenceBlock
        : editor.getPrevBlock(referenceBlock),
      referenceBlockParent,
    );
  }

  return { referenceBlock, placement };
}

// Gets the placement for moving a block down. This has 3 cases:
// 1. If the block has a next sibling without children, the placement is  after
// it.
// 2. If the block has a next sibling with children, the placement is before the
// first child.
// 3. If the block has no next sibling, but is nested, the placement is
// after its parent.
// If the placement is invalid, the function is called recursively until a valid
// placement is found. Returns undefined if no valid placement is found, meaning
// the block is already at the bottom of the document.
function getMoveDownPlacement(
  editor: BlockNoteEditor<any, any, any>,
  nextBlock?: Block<any, any, any>,
  parentBlock?: Block<any, any, any>,
):
  | { referenceBlock: BlockIdentifier; placement: "before" | "after" }
  | undefined {
  let referenceBlock: Block<any, any, any> | undefined;
  let placement: "before" | "after" | undefined;

  if (!nextBlock) {
    if (parentBlock) {
      referenceBlock = parentBlock;
      placement = "after";
    }
  } else if (nextBlock.children.length > 0) {
    referenceBlock = nextBlock.children[0];
    placement = "before";
  } else {
    referenceBlock = nextBlock;
    placement = "after";
  }

  // Case when the block is already at the bottom of the document.
  if (!referenceBlock || !placement) {
    return undefined;
  }

  const referenceBlockParent = editor.getParentBlock(referenceBlock);
  if (!checkPlacementIsValid(referenceBlockParent)) {
    return getMoveDownPlacement(
      editor,
      placement === "before"
        ? referenceBlock
        : editor.getNextBlock(referenceBlock),
      referenceBlockParent,
    );
  }

  return { referenceBlock, placement };
}

export function moveBlocksUp(
  editor: BlockNoteEditor<any, any, any>,
  blockIdentifier?: BlockIdentifier,
) {
  editor.transact(() => {
    let sourceBlock: Block<any, any, any> | undefined;
    if (blockIdentifier) {
      sourceBlock = editor.getBlock(blockIdentifier);
      if (!sourceBlock) {
        return;
      }
    } else {
      const selection = editor.getSelection();
      sourceBlock =
        selection?.blocks[0] || editor.getTextCursorPosition().block;
    }

    const moveUpPlacement = getMoveUpPlacement(
      editor,
      editor.getPrevBlock(sourceBlock),
      editor.getParentBlock(sourceBlock),
    );

    if (!moveUpPlacement) {
      return;
    }

    if (blockIdentifier) {
      moveBlocks(
        editor,
        [sourceBlock],
        moveUpPlacement.referenceBlock,
        moveUpPlacement.placement,
      );
    } else {
      moveSelectedBlocksAndSelection(
        editor,
        moveUpPlacement.referenceBlock,
        moveUpPlacement.placement,
      );
    }
  });
}

export function moveBlocksDown(
  editor: BlockNoteEditor<any, any, any>,
  blockIdentifier?: BlockIdentifier,
) {
  editor.transact(() => {
    let sourceBlock: Block<any, any, any> | undefined;
    if (blockIdentifier) {
      sourceBlock = editor.getBlock(blockIdentifier);
      if (!sourceBlock) {
        return;
      }
    } else {
      const selection = editor.getSelection();
      sourceBlock =
        selection?.blocks[selection?.blocks.length - 1] ||
        editor.getTextCursorPosition().block;
    }

    const moveDownPlacement = getMoveDownPlacement(
      editor,
      editor.getNextBlock(sourceBlock),
      editor.getParentBlock(sourceBlock),
    );

    if (!moveDownPlacement) {
      return;
    }

    if (blockIdentifier) {
      moveBlocks(
        editor,
        [sourceBlock],
        moveDownPlacement.referenceBlock,
        moveDownPlacement.placement,
      );
    } else {
      moveSelectedBlocksAndSelection(
        editor,
        moveDownPlacement.referenceBlock,
        moveDownPlacement.placement,
      );
    }
  });
}
