import { Node } from "prosemirror-model";
import { Plugin, PluginKey } from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
import {
  createExtension,
  createStore,
  ExtensionOptions,
} from "../editor/BlockNoteExtension.js";
import { ShowSelectionExtension } from "../extensions/ShowSelection/ShowSelection.js";
import { normalizeToUserStore, UserStoreOrResolver } from "../user/index.js";
import { CustomBlockNoteSchema } from "../schema/schema.js";
import { CommentMark } from "./mark.js";
import type { ThreadStore } from "./threadstore/ThreadStore.js";
import type { CommentBody, ThreadData } from "./types.js";

const PLUGIN_KEY = new PluginKey("blocknote-comments");

type CommentsPluginState = {
  /**
   * Decorations to be rendered, specifically to indicate the selected thread
   */
  decorations: DecorationSet;
};

/**
 * Calculate the thread positions from the current document state
 */
function getUpdatedThreadPositions(doc: Node, markType: string) {
  const threadPositions = new Map<string, { from: number; to: number }>();

  // find all thread marks and store their position + create decoration for selected thread
  doc.descendants((node, pos) => {
    node.marks.forEach((mark) => {
      if (mark.type.name === markType) {
        const thisThreadId = (mark.attrs as { threadId: string | undefined })
          .threadId;
        if (!thisThreadId) {
          return;
        }
        const from = pos;
        const to = from + node.nodeSize;

        // FloatingThreads component uses "to" as the position, so always store the largest "to" found
        // AnchoredThreads component uses "from" as the position, so always store the smallest "from" found
        const currentPosition = threadPositions.get(thisThreadId) ?? {
          from: Infinity,
          to: 0,
        };
        threadPositions.set(thisThreadId, {
          from: Math.min(from, currentPosition.from),
          to: Math.max(to, currentPosition.to),
        });
      }
    });
  });
  return threadPositions;
}

export const CommentsExtension = createExtension(
  ({
    editor,
    options: {
      schema: commentEditorSchema,
      threadStore,
      resolveUsers,
      confirmBeforeDiscard = true,
    },
  }: ExtensionOptions<{
    /**
     * The thread store implementation to use for storing and retrieving comment threads
     */
    threadStore: ThreadStore;
    /**
     * Resolve user information (names, avatars) for comment authors.
     *
     * Either a resolver function (called with the ids of users that are not yet
     * cached, returning their information) or a pre-built user store (see
     * `createUserStore`). Pass the same store to the collaboration options so a
     * single de-duped user cache is shared across comments and collaboration.
     *
     * See [Comments](https://www.blocknotejs.org/docs/features/collaboration/comments) for more info.
     */
    resolveUsers: UserStoreOrResolver;
    /**
     * A schema to use for the comment editor (which allows you to customize the blocks and styles that are available in the comment editor)
     */
    schema?: CustomBlockNoteSchema<any, any, any>;
    /**
     * Whether to ask the user for confirmation before discarding unsaved text
     * in a comment composer (a new comment, a reply, or an in-progress edit)
     * when it's dismissed (e.g. by clicking outside or pressing Escape).
     *
     * @default true
     */
    confirmBeforeDiscard?: boolean;
  }>) => {
    if (!resolveUsers) {
      throw new Error(
        "resolveUsers is required to be defined when using comments",
      );
    }
    if (!threadStore) {
      throw new Error(
        "threadStore is required to be defined when using comments",
      );
    }
    // Resolve users through this store, exposed on the extension instance so the
    // comments UI can read from it directly. Accepts a resolver callback or a
    // shared store (see the option docs above).
    const userStore = normalizeToUserStore(resolveUsers);
    const markType = CommentMark.name;

    const store = createStore(
      {
        pendingComment: false,
        selectedThreadId: undefined as string | undefined,
        threadPositions: new Map<string, { from: number; to: number }>(),
      },
      {
        onUpdate() {
          // If the selected thread id changed, we need to update the decorations
          if (
            store.state.selectedThreadId !== store.prevState.selectedThreadId
          ) {
            // So, we issue a transaction to update the decorations
            editor.transact((tr) => tr.setMeta(PLUGIN_KEY, true));
          }
        },
      },
    );

    const updateMarksFromThreads = (threads: Map<string, ThreadData>) => {
      editor.transact((tr) => {
        tr.doc.descendants((node, pos) => {
          node.marks.forEach((mark) => {
            if (mark.type.name === markType) {
              const markTypeInstance = mark.type;
              const markThreadId = mark.attrs.threadId as string;
              const thread = threads.get(markThreadId);
              const isOrphan = !!(
                !thread ||
                thread.resolved ||
                thread.deletedAt
              );

              if (isOrphan !== mark.attrs.orphan) {
                const trimmedFrom = Math.max(pos, 0);
                const trimmedTo = Math.min(
                  pos + node.nodeSize,
                  tr.doc.content.size - 1,
                  tr.doc.content.size - 1,
                );
                tr.removeMark(trimmedFrom, trimmedTo, mark);
                tr.addMark(
                  trimmedFrom,
                  trimmedTo,
                  markTypeInstance.create({
                    ...mark.attrs,
                    orphan: isOrphan,
                  }),
                );

                if (isOrphan && store.state.selectedThreadId === markThreadId) {
                  // unselect
                  store.setState((prev) => ({
                    ...prev,
                    selectedThreadId: undefined,
                  }));
                }
              }
            }
          });
        });
      });
    };

    return {
      key: "comments",
      store,
      userStore,
      runsBefore: ["link"],
      tiptapExtensions: [CommentMark],
      prosemirrorPlugins: [
        new Plugin<CommentsPluginState>({
          key: PLUGIN_KEY,
          state: {
            init() {
              return {
                decorations: DecorationSet.empty,
              };
            },
            apply(tr, state) {
              const action = tr.getMeta(PLUGIN_KEY);

              if (!tr.docChanged && !action) {
                return state;
              }

              // only update threadPositions if the doc changed
              const newThreadPositions = tr.docChanged
                ? getUpdatedThreadPositions(tr.doc, markType)
                : store.state.threadPositions;

              if (
                newThreadPositions.size > 0 ||
                store.state.threadPositions.size > 0
              ) {
                // small optimization; don't emit event if threadPositions before / after were both empty
                store.setState((prev) => ({
                  ...prev,
                  threadPositions: newThreadPositions,
                }));
              }

              // update decorations if doc or selected thread changed
              const decorations = [] as any[];

              if (store.state.selectedThreadId) {
                const selectedThreadPosition = newThreadPositions.get(
                  store.state.selectedThreadId,
                );

                if (selectedThreadPosition) {
                  decorations.push(
                    Decoration.inline(
                      selectedThreadPosition.from,
                      selectedThreadPosition.to,
                      {
                        class: "bn-thread-mark-selected",
                      },
                    ),
                  );
                }
              }

              return {
                decorations: DecorationSet.create(tr.doc, decorations),
              };
            },
          },
          props: {
            decorations(state) {
              return (
                PLUGIN_KEY.getState(state)?.decorations ?? DecorationSet.empty
              );
            },
            handleClick: (view, pos, event) => {
              if (event.button !== 0) {
                return false;
              }

              const node = view.state.doc.nodeAt(pos);

              if (!node) {
                // unselect
                store.setState((prev) => ({
                  ...prev,
                  selectedThreadId: undefined,
                }));
                return false;
              }

              const commentMark = node.marks.find(
                (mark) =>
                  mark.type.name === markType && mark.attrs.orphan !== true,
              );

              if (!commentMark) {
                // Clicked outside any comment thread. Deselect if needed but
                // don't consume the event so other handlers (e.g. link
                // navigation) can process it.
                if (store.state.selectedThreadId !== undefined) {
                  store.setState((prev) => ({
                    ...prev,
                    selectedThreadId: undefined,
                  }));
                }
                return false;
              }

              const threadId = commentMark.attrs.threadId as string;

              // If the clicked thread is already selected, do nothing and let
              // other handlers process the event (e.g. navigating a link).
              if (threadId === store.state.selectedThreadId) {
                return false;
              }

              store.setState((prev) => ({
                ...prev,
                selectedThreadId: threadId,
              }));

              return true;
            },
          },
        }),
      ],
      threadStore: threadStore,
      mount() {
        const unsubscribe = threadStore.subscribe(updateMarksFromThreads);
        updateMarksFromThreads(threadStore.getThreads());

        const unsubscribeOnSelectionChange = editor.onSelectionChange(() => {
          if (store.state.pendingComment) {
            store.setState((prev) => ({
              ...prev,
              pendingComment: false,
            }));
          }
        });

        return () => {
          unsubscribe();
          unsubscribeOnSelectionChange();
        };
      },
      selectThread(threadId: string | undefined, scrollToThread = true) {
        if (store.state.selectedThreadId === threadId) {
          return;
        }
        store.setState((prev) => ({
          ...prev,
          pendingComment: false,
          selectedThreadId: threadId,
        }));

        if (threadId && scrollToThread) {
          const selectedThreadPosition =
            store.state.threadPositions.get(threadId);
          if (!selectedThreadPosition) {
            return;
          }
          (
            editor.prosemirrorView?.domAtPos(selectedThreadPosition.from)
              .node as Element | undefined
          )?.scrollIntoView({
            behavior: "smooth",
            block: "center",
          });
        }
      },
      startPendingComment() {
        store.setState((prev) => ({
          ...prev,
          selectedThreadId: undefined,
          pendingComment: true,
        }));
        // Use `editor.domElement` as `editor.focus()` doesn't do anything if
        // the editor is non-editable. Editor needs to be focused as
        // `showSelection` will otherwise trigger a selection update which
        // triggers `stopPendingComment`.
        editor.domElement?.focus();
        editor
          .getExtension(ShowSelectionExtension)
          ?.showSelection(true, "comments");
      },
      stopPendingComment() {
        store.setState((prev) => ({
          ...prev,
          selectedThreadId: undefined,
          pendingComment: false,
        }));
        editor
          .getExtension(ShowSelectionExtension)
          ?.showSelection(false, "comments");
      },
      async createThread(options: {
        initialComment: { body: CommentBody; metadata?: any };
        metadata?: any;
      }) {
        const thread = await threadStore.createThread(options);
        if (threadStore.addThreadToDocument) {
          await threadStore.addThreadToDocument({
            threadId: thread.id,
            selection: editor.transact((tr) => tr.selection),
            editor,
          });
        } else {
          (editor as any)._tiptapEditor.commands.setMark(markType, {
            orphan: false,
            threadId: thread.id,
          });
        }
      },
      commentEditorSchema,
      confirmBeforeDiscard,
    } as const;
  },
);
