/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.

SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import type React from "react";
import {
    type Suggestion,
    findSuggestionInText,
    getMappedSuggestion,
    processCommand,
    processEmojiReplacement,
    processMention,
    processSelectionChange,
    processTextReplacement,
} from "../../../../../../../src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion";

function createMockPlainTextSuggestionPattern(props: Partial<Suggestion> = {}): Suggestion {
    return {
        mappedSuggestion: { keyChar: "/", type: "command", text: "some text", ...props.mappedSuggestion },
        node: document.createTextNode(""),
        startOffset: 0,
        endOffset: 0,
        ...props,
    };
}

function createMockCustomSuggestionPattern(props: Partial<Suggestion> = {}): Suggestion {
    return {
        mappedSuggestion: { keyChar: "", type: "custom", text: "🙂", ...props.mappedSuggestion },
        node: document.createTextNode(":)"),
        startOffset: 0,
        endOffset: 2,
        ...props,
    };
}

describe("processCommand", () => {
    it("does not change parent hook state if suggestion is null", () => {
        // create a mockSuggestion using the text node above
        const mockSetSuggestion = jest.fn();
        const mockSetText = jest.fn();

        // call the function with a null suggestion
        processCommand("should not be seen", null, mockSetSuggestion, mockSetText);

        // check that the parent state setter has not been called
        expect(mockSetText).not.toHaveBeenCalled();
    });

    it("can change the parent hook state when required", () => {
        // create a div and append a text node to it with some initial text
        const editorDiv = document.createElement("div");
        const initialText = "text";
        const textNode = document.createTextNode(initialText);
        editorDiv.appendChild(textNode);

        // create a mockSuggestion using the text node above
        const mockSuggestion = createMockPlainTextSuggestionPattern({ node: textNode });
        const mockSetSuggestion = jest.fn();
        const mockSetText = jest.fn();
        const replacementText = "/replacement text";

        processCommand(replacementText, mockSuggestion, mockSetSuggestion, mockSetText);

        // check that the text has changed and includes a trailing space
        expect(mockSetText).toHaveBeenCalledWith(`${replacementText} `);
    });
});

describe("processEmojiReplacement", () => {
    it("does not change parent hook state if suggestion is null", () => {
        // create a mockSuggestion using the text node above
        const mockSetSuggestion = jest.fn();
        const mockSetText = jest.fn();

        // call the function with a null suggestion
        processEmojiReplacement(null, mockSetSuggestion, mockSetText);

        // check that the parent state setter has not been called
        expect(mockSetText).not.toHaveBeenCalled();
    });

    it("can change the parent hook state when required", () => {
        // create a div and append a text node to it with some initial text
        const editorDiv = document.createElement("div");
        const initialText = ":)";
        const textNode = document.createTextNode(initialText);
        editorDiv.appendChild(textNode);

        // create a mockSuggestion using the text node above
        const mockSuggestion = createMockCustomSuggestionPattern({ node: textNode });
        const mockSetSuggestion = jest.fn();
        const mockSetText = jest.fn();
        const replacementText = "🙂";

        processEmojiReplacement(mockSuggestion, mockSetSuggestion, mockSetText);

        // check that the text has changed and includes a trailing space
        expect(mockSetText).toHaveBeenCalledWith(replacementText);
    });
});

describe("processMention", () => {
    // TODO refactor and expand tests when mentions become <a> tags
    it("returns early when suggestion is null", () => {
        const mockSetSuggestion = jest.fn();
        const mockSetText = jest.fn();
        processMention("href", "displayName", new Map(), null, mockSetSuggestion, mockSetText);

        expect(mockSetSuggestion).not.toHaveBeenCalled();
        expect(mockSetText).not.toHaveBeenCalled();
    });

    it("can insert a mention into a text node", () => {
        // make a text node and an editor div, set the cursor inside the text node and then
        // append node to editor, then editor to document
        const textNode = document.createTextNode("@a");
        const mockEditor = document.createElement("div");
        mockEditor.appendChild(textNode);
        document.body.appendChild(mockEditor);
        document.getSelection()?.setBaseAndExtent(textNode, 1, textNode, 1);

        // call the util function
        const href = "href";
        const displayName = "displayName";
        const mockSetSuggestionData = jest.fn();
        const mockSetText = jest.fn();
        processMention(
            href,
            displayName,
            new Map([["style", "test"]]),
            { node: textNode, startOffset: 0, endOffset: 2 } as unknown as Suggestion,
            mockSetSuggestionData,
            mockSetText,
        );

        // check that the editor has a single child
        expect(mockEditor.children).toHaveLength(1);
        const linkElement = mockEditor.firstElementChild as HTMLElement;

        // and that the child is an <a> tag with the expected attributes and content
        expect(linkElement).toBeInstanceOf(HTMLAnchorElement);
        expect(linkElement).toHaveAttribute(href, href);
        expect(linkElement).toHaveAttribute("contenteditable", "false");
        expect(linkElement).toHaveAttribute("style", "test");
        expect(linkElement.textContent).toBe(displayName);

        expect(mockSetText).toHaveBeenCalledWith();
        expect(mockSetSuggestionData).toHaveBeenCalledWith(null);
    });
});

describe("processSelectionChange", () => {
    function createMockEditorRef(element: HTMLDivElement | null = null): React.RefObject<HTMLDivElement> {
        return { current: element } as React.RefObject<HTMLDivElement>;
    }

    function appendEditorWithTextNodeContaining(initialText = ""): [HTMLDivElement, Node] {
        // create the elements/nodes
        const mockEditor = document.createElement("div");
        const textNode = document.createTextNode(initialText);

        // append text node to the editor, editor to the document body
        mockEditor.appendChild(textNode);
        document.body.appendChild(mockEditor);

        return [mockEditor, textNode];
    }

    const mockSetSuggestion = jest.fn();
    beforeEach(() => {
        mockSetSuggestion.mockClear();
    });

    it("returns early if current editorRef is null", () => {
        const mockEditorRef = createMockEditorRef(null);
        // we monitor for the call to document.createNodeIterator to indicate an early return
        const nodeIteratorSpy = jest.spyOn(document, "createNodeIterator");

        processSelectionChange(mockEditorRef, jest.fn());
        expect(nodeIteratorSpy).not.toHaveBeenCalled();

        // tidy up to avoid potential impacts on other tests
        nodeIteratorSpy.mockRestore();
    });

    it("calls setSuggestion with null if selection is not a cursor", () => {
        const [mockEditor, textNode] = appendEditorWithTextNodeContaining("content");
        const mockEditorRef = createMockEditorRef(mockEditor);

        // create a selection in the text node that has different start and end locations ie it
        // is not a cursor
        document.getSelection()?.setBaseAndExtent(textNode, 0, textNode, 4);

        // process the selection and check that we do not attempt to set the suggestion
        processSelectionChange(mockEditorRef, mockSetSuggestion);
        expect(mockSetSuggestion).toHaveBeenCalledWith(null);
    });

    it("calls setSuggestion with null if selection cursor is not inside a text node", () => {
        const [mockEditor] = appendEditorWithTextNodeContaining("content");
        const mockEditorRef = createMockEditorRef(mockEditor);

        // create a selection that points at the editor element, not the text node it contains
        document.getSelection()?.setBaseAndExtent(mockEditor, 0, mockEditor, 0);

        // process the selection and check that we do not attempt to set the suggestion
        processSelectionChange(mockEditorRef, mockSetSuggestion);
        expect(mockSetSuggestion).toHaveBeenCalledWith(null);
    });

    it("calls setSuggestion with null if we have an existing suggestion but no command match", () => {
        const [mockEditor, textNode] = appendEditorWithTextNodeContaining("content");
        const mockEditorRef = createMockEditorRef(mockEditor);

        // create a selection in the text node that has identical start and end locations, ie it is a cursor
        document.getSelection()?.setBaseAndExtent(textNode, 0, textNode, 0);

        // the call to process the selection will have an existing suggestion in state due to the second
        // argument being non-null, expect that we clear this suggestion now that the text is not a command
        processSelectionChange(mockEditorRef, mockSetSuggestion);
        expect(mockSetSuggestion).toHaveBeenCalledWith(null);
    });

    it("calls setSuggestion with the expected arguments when text node is valid command", () => {
        const commandText = "/potentialCommand";
        const [mockEditor, textNode] = appendEditorWithTextNodeContaining(commandText);
        const mockEditorRef = createMockEditorRef(mockEditor);

        // create a selection in the text node that has identical start and end locations, ie it is a cursor
        document.getSelection()?.setBaseAndExtent(textNode, 3, textNode, 3);

        // process the change and check the suggestion that is set looks as we expect it to
        processSelectionChange(mockEditorRef, mockSetSuggestion);
        expect(mockSetSuggestion).toHaveBeenCalledWith({
            mappedSuggestion: {
                keyChar: "/",
                type: "command",
                text: "potentialCommand",
            },
            node: textNode,
            startOffset: 0,
            endOffset: commandText.length,
        });
    });

    it("does not treat a command outside the first text node to be a suggestion", () => {
        const [mockEditor] = appendEditorWithTextNodeContaining("some text in first node");
        const [, commandTextNode] = appendEditorWithTextNodeContaining("/potentialCommand");

        const mockEditorRef = createMockEditorRef(mockEditor);

        // create a selection in the text node that has identical start and end locations, ie it is a cursor
        document.getSelection()?.setBaseAndExtent(commandTextNode, 3, commandTextNode, 3);

        // process the change and check the suggestion that is set looks as we expect it to
        processSelectionChange(mockEditorRef, mockSetSuggestion);
        expect(mockSetSuggestion).toHaveBeenCalledWith(null);
    });
});

describe("findSuggestionInText", () => {
    const command = "/someCommand";
    const userMention = "@userMention";
    const roomMention = "#roomMention";

    const mentionTestCases = [userMention, roomMention];
    const allTestCases = [command, userMention, roomMention];

    it("returns null if content does not contain any mention or command characters", () => {
        expect(findSuggestionInText("hello", 1, true)).toBeNull();
    });

    it("returns null if content contains a command but is not the first text node", () => {
        expect(findSuggestionInText(command, 1, false)).toBeNull();
    });

    it("returns null if the offset is outside the content length", () => {
        expect(findSuggestionInText("hi", 30, true)).toBeNull();
        expect(findSuggestionInText("hi", -10, true)).toBeNull();
    });

    it.each(allTestCases)("returns an object when the whole input is special case: %s", (text) => {
        const expected = {
            mappedSuggestion: getMappedSuggestion(text),
            startOffset: 0,
            endOffset: text.length,
        };
        // test for cursor immediately before and after special character, before end, at end
        expect(findSuggestionInText(text, 0, true)).toEqual(expected);
        expect(findSuggestionInText(text, 1, true)).toEqual(expected);
        expect(findSuggestionInText(text, text.length - 2, true)).toEqual(expected);
        expect(findSuggestionInText(text, text.length, true)).toEqual(expected);
    });

    it("returns null when a command is followed by other text", () => {
        const followingText = " followed by something";

        // check for cursor inside and outside the command
        expect(findSuggestionInText(command + followingText, command.length - 2, true)).toBeNull();
        expect(findSuggestionInText(command + followingText, command.length + 2, true)).toBeNull();
    });

    it.each(mentionTestCases)("returns an object when a %s is followed by other text", (mention) => {
        const followingText = " followed by something else";
        expect(findSuggestionInText(mention + followingText, mention.length - 2, true)).toEqual({
            mappedSuggestion: getMappedSuggestion(mention),
            startOffset: 0,
            endOffset: mention.length,
        });
    });

    it("returns null if there is a command surrounded by text", () => {
        const precedingText = "text before the command ";
        const followingText = " text after the command";
        expect(
            findSuggestionInText(precedingText + command + followingText, precedingText.length + 4, true),
        ).toBeNull();
    });

    it.each(mentionTestCases)("returns an object if %s is surrounded by text", (mention) => {
        const precedingText = "I want to mention ";
        const followingText = " in my message";

        const textInput = precedingText + mention + followingText;
        const expected = {
            mappedSuggestion: getMappedSuggestion(mention),
            startOffset: precedingText.length,
            endOffset: precedingText.length + mention.length,
        };

        // when the cursor is immediately before the special character
        expect(findSuggestionInText(textInput, precedingText.length, true)).toEqual(expected);
        // when the cursor is inside the mention
        expect(findSuggestionInText(textInput, precedingText.length + 3, true)).toEqual(expected);
        // when the cursor is right at the end of the mention
        expect(findSuggestionInText(textInput, precedingText.length + mention.length, true)).toEqual(expected);
    });

    it("returns null for text content with an email address", () => {
        const emailInput = "send to user@test.com";
        expect(findSuggestionInText(emailInput, 15, true)).toBeNull();
    });

    it("returns null for double slashed command", () => {
        const doubleSlashCommand = "//not a command";
        expect(findSuggestionInText(doubleSlashCommand, 4, true)).toBeNull();
    });

    it("returns null for slash separated text", () => {
        const slashSeparatedInput = "please to this/that/the other";
        expect(findSuggestionInText(slashSeparatedInput, 21, true)).toBeNull();
    });

    it("returns an object for a mention that contains punctuation", () => {
        const mentionWithPunctuation = "@userX14#5a_-";
        const precedingText = "mention ";
        const mentionInput = precedingText + mentionWithPunctuation;
        expect(findSuggestionInText(mentionInput, 12, true)).toEqual({
            mappedSuggestion: getMappedSuggestion(mentionWithPunctuation),
            startOffset: precedingText.length,
            endOffset: precedingText.length + mentionWithPunctuation.length,
        });
    });

    it("returns null when user inputs any whitespace after the special character", () => {
        const mentionWithSpaceAfter = "@ somebody";
        expect(findSuggestionInText(mentionWithSpaceAfter, 2, true)).toBeNull();
    });

    it("returns an object for an emoji suggestion", () => {
        const emoiticon = ":)";
        const precedingText = "hello ";
        const mentionInput = precedingText + emoiticon;
        expect(findSuggestionInText(mentionInput, precedingText.length, true, true)).toEqual({
            mappedSuggestion: getMappedSuggestion(emoiticon, true),
            startOffset: precedingText.length,
            endOffset: precedingText.length + emoiticon.length,
        });
    });
});

describe("processTextReplacement", () => {
    it("does not change parent hook state if suggestionData is null", () => {
        const mockSetSuggestionData = jest.fn();
        const mockSetText = jest.fn();
        const replacementText = "replacement";

        // call the function with null suggestionData
        processTextReplacement(replacementText, null, mockSetSuggestionData, mockSetText);

        // check that the parent state setters have not been called
        expect(mockSetText).not.toHaveBeenCalled();
        expect(mockSetSuggestionData).not.toHaveBeenCalled();
    });

    it("does not change parent hook state if existingContent is null", () => {
        const mockSetSuggestionData = jest.fn();
        const mockSetText = jest.fn();
        const replacementText = "replacement";

        // create a mock node with null textContent
        const mockNode = {
            textContent: null,
        } as unknown as Text;

        const mockSuggestion: Suggestion = {
            mappedSuggestion: { keyChar: ":", type: "emoji", text: ":)" },
            node: mockNode,
            startOffset: 0,
            endOffset: 2,
        };

        // call the function with a node that has null textContent
        processTextReplacement(replacementText, mockSuggestion, mockSetSuggestionData, mockSetText);

        // check that the parent state setters have not been called
        expect(mockSetText).not.toHaveBeenCalled();
        expect(mockSetSuggestionData).not.toHaveBeenCalled();
    });

    it("can replace text content when both suggestionData and existingContent are valid", () => {
        const mockSetSuggestionData = jest.fn();
        const mockSetText = jest.fn();
        const replacementText = "🙂";
        const initialText = "Hello :) world";

        // create a div and append a text node to it
        const editorDiv = document.createElement("div");
        const textNode = document.createTextNode(initialText);
        editorDiv.appendChild(textNode);
        document.body.appendChild(editorDiv);

        const mockSuggestion: Suggestion = {
            mappedSuggestion: { keyChar: ":", type: "emoji", text: ":)" },
            node: textNode,
            startOffset: 6, // position of ":)"
            endOffset: 8, // end of ":)"
        };

        // mock document.getSelection
        const mockSelection = {
            setBaseAndExtent: jest.fn(),
        };
        jest.spyOn(document, "getSelection").mockReturnValue(mockSelection as any);

        // call the function
        processTextReplacement(replacementText, mockSuggestion, mockSetSuggestionData, mockSetText);

        // check that the text content was updated correctly
        expect(textNode.textContent).toBe("Hello 🙂 world");

        // check that setText was called with the new content
        expect(mockSetText).toHaveBeenCalledWith("Hello 🙂 world");

        // check that suggestionData was cleared
        expect(mockSetSuggestionData).toHaveBeenCalledWith(null);

        // check that the cursor was positioned at the end
        expect(mockSelection.setBaseAndExtent).toHaveBeenCalledWith(
            textNode,
            "Hello 🙂 world".length,
            textNode,
            "Hello 🙂 world".length,
        );

        // clean up
        document.body.removeChild(editorDiv);
    });
});

describe("getMappedSuggestion", () => {
    it("returns null when the first character is not / # @", () => {
        expect(getMappedSuggestion("Zzz")).toBe(null);
    });

    it("returns the expected mapped suggestion when first character is # or @", () => {
        expect(getMappedSuggestion("@user-mention")).toEqual({
            type: "mention",
            keyChar: "@",
            text: "user-mention",
        });
        expect(getMappedSuggestion("#room-mention")).toEqual({
            type: "mention",
            keyChar: "#",
            text: "room-mention",
        });
    });

    it("returns the expected mapped suggestion when first character is /", () => {
        expect(getMappedSuggestion("/command")).toEqual({
            type: "command",
            keyChar: "/",
            text: "command",
        });
    });

    it("returns the expected mapped suggestion when the text is a plain text emoiticon", () => {
        expect(getMappedSuggestion(":)", true)).toEqual({
            type: "custom",
            keyChar: "",
            text: "🙂",
        });
    });
});
