import {
  combineTransactionSteps,
  Extension,
  findChildrenInRange,
  getChangedRanges,
} from "@tiptap/core";
import { Fragment, Slice } from "prosemirror-model";
import { Plugin, PluginKey } from "prosemirror-state";
import { v4 } from "uuid";

/**
 * Code from Tiptap UniqueID extension (https://tiptap.dev/api/extensions/unique-id)
 * This extension is licensed under MIT (even though it's part of Tiptap pro).
 *
 * If you're a user of BlockNote, we still recommend to support their awesome work and become a sponsor!
 * https://tiptap.dev/pro
 */

/**
 * Removes duplicated values within an array.
 * Supports numbers, strings and objects.
 */
function removeDuplicates(array: any, by = JSON.stringify) {
  const seen: any = {};
  return array.filter((item: any) => {
    const key = by(item);
    return Object.prototype.hasOwnProperty.call(seen, key)
      ? false
      : (seen[key] = true);
  });
}

/**
 * Returns a list of duplicated items within an array.
 */
function findDuplicates(items: any) {
  const filtered = items.filter(
    (el: any, index: number) => items.indexOf(el) !== index,
  );
  const duplicates = removeDuplicates(filtered);
  return duplicates;
}

const UniqueID = Extension.create({
  name: "uniqueID",
  // we’ll set a very high priority to make sure this runs first
  // and is compatible with `appendTransaction` hooks of other extensions
  priority: 10000,
  addOptions() {
    return {
      attributeName: "id",
      types: [],
      setIdAttribute: false,
      generateID: () => {
        // Use mock ID if tests are running.
        if (typeof window !== "undefined" && (window as any).__TEST_OPTIONS) {
          const testOptions = (window as any).__TEST_OPTIONS;
          if (testOptions.mockID === undefined) {
            testOptions.mockID = 0;
          } else {
            testOptions.mockID++;
          }

          return testOptions.mockID.toString() as string;
        }

        return v4();
      },
      filterTransaction: null,
    };
  },
  addGlobalAttributes() {
    return [
      {
        types: this.options.types,
        attributes: {
          [this.options.attributeName]: {
            default: null,
            parseHTML: (element) =>
              element.getAttribute(`data-${this.options.attributeName}`),
            renderHTML: (attributes) => {
              const defaultIdAttributes = {
                [`data-${this.options.attributeName}`]:
                  attributes[this.options.attributeName],
              };
              if (this.options.setIdAttribute) {
                return {
                  ...defaultIdAttributes,
                  id: attributes[this.options.attributeName],
                };
              } else {
                return defaultIdAttributes;
              }
            },
          },
        },
      },
    ];
  },
  // check initial content for missing ids
  // onCreate() {
  //   // Don’t do this when the collaboration extension is active
  //   // because this may update the content, so Y.js tries to merge these changes.
  //   // This leads to empty block nodes.
  //   // See: https://github.com/ueberdosis/tiptap/issues/2400
  //   if (
  //     this.editor.extensionManager.extensions.find(
  //       (extension) => extension.name === "collaboration"
  //     )
  //   ) {
  //     return;
  //   }
  //   const { view, state } = this.editor;
  //   const { tr, doc } = state;
  //   const { types, attributeName, generateID } = this.options;
  //   const nodesWithoutId = findChildren(doc, (node) => {
  //     return (
  //       types.includes(node.type.name) && node.attrs[attributeName] === null
  //     );
  //   });
  //   nodesWithoutId.forEach(({ node, pos }) => {
  //     tr.setNodeMarkup(pos, undefined, {
  //       ...node.attrs,
  //       [attributeName]: generateID(),
  //     });
  //   });
  //   tr.setMeta("addToHistory", false);
  //   view.dispatch(tr);
  // },
  addProseMirrorPlugins() {
    let dragSourceElement: any = null;
    let transformPasted = false;
    return [
      new Plugin({
        key: new PluginKey("uniqueID"),
        appendTransaction: (transactions, oldState, newState) => {
          // console.log("appendTransaction");
          const docChanges =
            transactions.some((transaction) => transaction.docChanged) &&
            !oldState.doc.eq(newState.doc);
          const filterTransactions =
            this.options.filterTransaction &&
            transactions.some((tr) => {
              let _a, _b;
              return !((_b = (_a = this.options).filterTransaction) === null ||
              _b === void 0
                ? void 0
                : _b.call(_a, tr));
            });
          if (!docChanges || filterTransactions) {
            return;
          }
          const { tr } = newState;
          const { types, attributeName, generateID } = this.options;
          const transform = combineTransactionSteps(
            oldState.doc,
            transactions as any,
          );
          const { mapping } = transform;
          // get changed ranges based on the old state
          const changes = getChangedRanges(transform);

          changes.forEach(({ newRange }) => {
            const newNodes = findChildrenInRange(
              newState.doc,
              newRange,
              (node) => {
                return types.includes(node.type.name);
              },
            );
            const newIds = newNodes
              .map(({ node }) => node.attrs[attributeName])
              .filter((id) => id !== null);
            const duplicatedNewIds = findDuplicates(newIds);
            newNodes.forEach(({ node, pos }) => {
              let _a;
              // instead of checking `node.attrs[attributeName]` directly
              // we look at the current state of the node within `tr.doc`.
              // this helps to prevent adding new ids to the same node
              // if the node changed multiple times within one transaction
              const id =
                (_a = tr.doc.nodeAt(pos)) === null || _a === void 0
                  ? void 0
                  : _a.attrs[attributeName];
              if (id === null) {
                // edge case, when using collaboration, yjs will set the id to null in `_forceRerender`
                // when loading the editor
                // this checks for this case and keeps it at initialBlockId so there will be no change
                const initialDoc = oldState.doc.type.createAndFill()!.content;
                const wasInitial =
                  oldState.doc.content.findDiffStart(initialDoc) === null;

                if (wasInitial) {
                  // the old state was the "initial content"
                  const jsonNode = JSON.parse(
                    JSON.stringify(newState.doc.toJSON()),
                  );
                  jsonNode.content[0].content[0].attrs.id = "initialBlockId";
                  // would the new state with the fix also be the "initial content"?
                  if (
                    JSON.stringify(jsonNode.content) ===
                    JSON.stringify(initialDoc.toJSON())
                  ) {
                    // yes, apply the fix
                    tr.setNodeMarkup(pos, undefined, {
                      ...node.attrs,
                      [attributeName]: "initialBlockId",
                    });
                    return;
                  }
                }

                tr.setNodeMarkup(pos, undefined, {
                  ...node.attrs,
                  [attributeName]: generateID(),
                });
                return;
              }
              // check if the node doesn’t exist in the old state
              const { deleted } = mapping.invert().mapResult(pos);
              const newNode = deleted && duplicatedNewIds.includes(id);
              if (newNode) {
                tr.setNodeMarkup(pos, undefined, {
                  ...node.attrs,
                  [attributeName]: generateID(),
                });
              }
            });
          });
          if (!tr.steps.length) {
            return;
          }
          return tr;
        },
        // we register a global drag handler to track the current drag source element
        view(view) {
          const handleDragstart = (event: any) => {
            let _a;
            dragSourceElement = (
              (_a = view.dom.parentElement) === null || _a === void 0
                ? void 0
                : _a.contains(event.target)
            )
              ? view.dom.parentElement
              : null;
          };
          window.addEventListener("dragstart", handleDragstart);
          return {
            destroy() {
              window.removeEventListener("dragstart", handleDragstart);
            },
          };
        },
        props: {
          // `handleDOMEvents` is called before `transformPasted` so we can do
          // some checks before. However, `transformPasted` only runs when
          // editor content is pasted - not external content.
          handleDOMEvents: {
            // only create new ids for dropped content while holding `alt`
            // or content is dragged from another editor
            drop: (view, event: any) => {
              let _a;
              if (
                dragSourceElement !== view.dom.parentElement ||
                ((_a = event.dataTransfer) === null || _a === void 0
                  ? void 0
                  : _a.effectAllowed) === "copy"
              ) {
                transformPasted = true;
              } else {
                transformPasted = false;
              }

              dragSourceElement = null;

              return false;
            },
            // always create new ids on pasted content
            paste: () => {
              transformPasted = true;
              return false;
            },
          },
          // we’ll remove ids for every pasted node
          // so we can create a new one within `appendTransaction`
          transformPasted: (slice) => {
            if (!transformPasted) {
              return slice;
            }
            const { types, attributeName } = this.options;
            const removeId = (fragment: any) => {
              const list: any[] = [];
              fragment.forEach((node: any) => {
                // don’t touch text nodes
                if (node.isText) {
                  list.push(node);
                  return;
                }
                // check for any other child nodes
                if (!types.includes(node.type.name)) {
                  list.push(node.copy(removeId(node.content)));
                  return;
                }
                // remove id
                const nodeWithoutId = node.type.create(
                  {
                    ...node.attrs,
                    [attributeName]: null,
                  },
                  removeId(node.content),
                  node.marks,
                );
                list.push(nodeWithoutId);
              });
              return Fragment.from(list);
            };
            // reset check
            transformPasted = false;
            return new Slice(
              removeId(slice.content),
              slice.openStart,
              slice.openEnd,
            );
          },
        },
      }),
    ];
  },
});

export { UniqueID as default, UniqueID };
