/*
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 React from "react";
import { fireEvent, render, screen } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import { Room } from "matrix-js-sdk/src/matrix";
import { type ReplacementEvent, type RoomMessageEventContent } from "matrix-js-sdk/src/types";

import EditMessageComposerWithMatrixClient, {
    createEditContent,
} from "../../../../../src/components/views/rooms/EditMessageComposer";
import EditorModel from "../../../../../src/editor/model";
import { createPartCreator } from "../../../editor/mock";
import {
    getMockClientWithEventEmitter,
    getRoomContext,
    mkEvent,
    mockClientMethodsUser,
    setupRoomWithEventsTimeline,
} from "../../../../test-utils";
import DocumentOffset from "../../../../../src/editor/offset";
import SettingsStore from "../../../../../src/settings/SettingsStore";
import EditorStateTransfer from "../../../../../src/utils/EditorStateTransfer";
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
import Autocompleter, { type IProviderCompletions } from "../../../../../src/autocomplete/Autocompleter";
import NotifProvider from "../../../../../src/autocomplete/NotifProvider";
import DMRoomMap from "../../../../../src/utils/DMRoomMap";
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx";
import type { RoomContextType } from "../../../../../src/contexts/RoomContext.ts";
import { SDKContext } from "../../../../../src/contexts/SDKContext.ts";
import { SDKContextClass } from "../../../../../src/contexts/SDKContextClass.ts";

describe("<EditMessageComposer/>", () => {
    const userId = "@alice:server.org";
    const roomId = "!room:server.org";
    const mockClient = getMockClientWithEventEmitter({
        ...mockClientMethodsUser(userId),
        getRoom: jest.fn(),
        sendMessage: jest.fn(),
    });
    const room = new Room(roomId, mockClient, userId);

    const editedEvent = mkEvent({
        type: "m.room.message",
        user: "@alice:test",
        room: "!abc:test",
        content: { body: "original message", msgtype: "m.text" },
        event: true,
    });

    const eventWithMentions = mkEvent({
        type: "m.room.message",
        user: userId,
        room: roomId,
        content: {
            "msgtype": "m.text",
            "body": "hey Bob and Charlie",
            "format": "org.matrix.custom.html",
            "formatted_body":
                'hey <a href="https://matrix.to/#/@bob:server.org">Bob</a> and <a href="https://matrix.to/#/@charlie:server.org">Charlie</a>',
            "m.mentions": {
                user_ids: ["@bob:server.org", "@charlie:server.org"],
            },
        },
        event: true,
    });

    // message composer emojipicker uses this
    // which would require more irrelevant mocking
    jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);

    const defaultRoomContext = getRoomContext(room, {});

    const getComponent = (editState: EditorStateTransfer, roomContext: RoomContextType = defaultRoomContext) =>
        render(<EditMessageComposerWithMatrixClient editState={editState} />, {
            wrapper: ({ children }) => (
                <SDKContext.Provider value={SDKContextClass.instance}>
                    <MatrixClientContext.Provider value={mockClient}>
                        <ScopedRoomContextProvider {...roomContext}>{children}</ScopedRoomContextProvider>
                    </MatrixClientContext.Provider>
                </SDKContext.Provider>
            ),
        });

    beforeEach(() => {
        mockClient.getRoom.mockReturnValue(room);
        mockClient.sendMessage.mockClear();

        userEvent.setup();

        DMRoomMap.makeShared(mockClient);

        jest.spyOn(Autocompleter.prototype, "getCompletions").mockResolvedValue([
            {
                completions: [
                    {
                        completion: "@dan:server.org",
                        completionId: "@dan:server.org",
                        type: "user",
                        suffix: " ",
                        component: <span>Dan</span>,
                    },
                ],
                command: {
                    command: ["@d"],
                },
                provider: new NotifProvider(room),
            } as unknown as IProviderCompletions,
        ]);
    });

    const editText = async (text: string, shouldClear?: boolean): Promise<void> => {
        const input = screen.getByRole("textbox");
        if (shouldClear) {
            await userEvent.clear(input);
        }
        await userEvent.type(input, text);
    };

    it("should edit a simple message", async () => {
        const editState = new EditorStateTransfer(editedEvent);
        getComponent(editState);
        await editText(" + edit");

        fireEvent.click(screen.getByText("Save"));

        const expectedBody = {
            ...editedEvent.getContent(),
            "body": "* original message + edit",
            "m.new_content": {
                "body": "original message + edit",
                "msgtype": "m.text",
                "m.mentions": {},
            },
            "m.relates_to": {
                event_id: editedEvent.getId(),
                rel_type: "m.replace",
            },
            "m.mentions": {},
        };
        expect(mockClient.sendMessage).toHaveBeenCalledWith(editedEvent.getRoomId()!, null, expectedBody);
    });

    it("should throw when room for message is not found", () => {
        mockClient.getRoom.mockReturnValue(null);
        const editState = new EditorStateTransfer(editedEvent);
        expect(() => getComponent(editState, { ...defaultRoomContext, room: undefined })).toThrow(
            "Cannot render without room",
        );
    });

    describe("createEditContent", () => {
        it("sends plaintext messages correctly", () => {
            const model = new EditorModel([], createPartCreator());
            const documentOffset = new DocumentOffset(11, true);
            model.update("hello world", "insertText", documentOffset);

            const content = createEditContent(model, editedEvent);

            expect(content).toEqual({
                "body": "* hello world",
                "msgtype": "m.text",
                "m.new_content": {
                    "body": "hello world",
                    "msgtype": "m.text",
                    "m.mentions": {},
                },
                "m.relates_to": {
                    event_id: editedEvent.getId(),
                    rel_type: "m.replace",
                },
                "m.mentions": {},
            });
        });

        it("sends markdown messages correctly", () => {
            const model = new EditorModel([], createPartCreator());
            const documentOffset = new DocumentOffset(13, true);
            model.update("hello *world*", "insertText", documentOffset);

            const content = createEditContent(model, editedEvent);

            expect(content).toEqual({
                "body": "* hello *world*",
                "msgtype": "m.text",
                "format": "org.matrix.custom.html",
                "formatted_body": "* hello <em>world</em>",
                "m.new_content": {
                    "body": "hello *world*",
                    "msgtype": "m.text",
                    "format": "org.matrix.custom.html",
                    "formatted_body": "hello <em>world</em>",
                    "m.mentions": {},
                },
                "m.relates_to": {
                    event_id: editedEvent.getId(),
                    rel_type: "m.replace",
                },
                "m.mentions": {},
            });
        });

        it("strips /me from messages and marks them as m.emote accordingly", () => {
            const model = new EditorModel([], createPartCreator());
            const documentOffset = new DocumentOffset(22, true);
            model.update("/me blinks __quickly__", "insertText", documentOffset);

            const content = createEditContent(model, editedEvent);

            expect(content).toEqual({
                "body": "* blinks __quickly__",
                "msgtype": "m.emote",
                "format": "org.matrix.custom.html",
                "formatted_body": "* blinks <strong>quickly</strong>",
                "m.new_content": {
                    "body": "blinks __quickly__",
                    "msgtype": "m.emote",
                    "format": "org.matrix.custom.html",
                    "formatted_body": "blinks <strong>quickly</strong>",
                    "m.mentions": {},
                },
                "m.relates_to": {
                    event_id: editedEvent.getId(),
                    rel_type: "m.replace",
                },
                "m.mentions": {},
            });
        });

        it("allows emoting with non-text parts", () => {
            const model = new EditorModel([], createPartCreator());
            const documentOffset = new DocumentOffset(16, true);
            model.update("/me ✨sparkles✨", "insertText", documentOffset);
            expect(model.parts.length).toEqual(4); // Emoji count as non-text

            const content = createEditContent(model, editedEvent);

            expect(content).toEqual({
                "body": "* ✨sparkles✨",
                "msgtype": "m.emote",
                "m.new_content": {
                    "body": "✨sparkles✨",
                    "msgtype": "m.emote",
                    "m.mentions": {},
                },
                "m.relates_to": {
                    event_id: editedEvent.getId(),
                    rel_type: "m.replace",
                },
                "m.mentions": {},
            });
        });

        it("allows sending double-slash escaped slash commands correctly", () => {
            const model = new EditorModel([], createPartCreator());
            const documentOffset = new DocumentOffset(32, true);

            model.update("//dev/null is my favourite place", "insertText", documentOffset);

            const content = createEditContent(model, editedEvent);

            // TODO Edits do not properly strip the double slash used to skip
            // command processing.
            expect(content).toEqual({
                "body": "* //dev/null is my favourite place",
                "msgtype": "m.text",
                "m.new_content": {
                    "body": "//dev/null is my favourite place",
                    "msgtype": "m.text",
                    "m.mentions": {},
                },
                "m.relates_to": {
                    event_id: editedEvent.getId(),
                    rel_type: "m.replace",
                },
                "m.mentions": {},
            });
        });
    });

    describe("when message is not a reply", () => {
        it("should attach an empty mentions object for a message with no mentions", async () => {
            const editState = new EditorStateTransfer(editedEvent);
            getComponent(editState);
            const editContent = " + edit";
            await editText(editContent);

            fireEvent.click(screen.getByText("Save"));

            const messageContent = mockClient.sendMessage.mock.calls[0][2] as RoomMessageEventContent &
                ReplacementEvent<RoomMessageEventContent>;

            // both content.mentions and new_content.mentions are empty
            expect(messageContent["m.mentions"]).toEqual({});
            expect(messageContent["m.new_content"]!["m.mentions"]).toEqual({});
        });

        it("should retain mentions in the original message that are not removed by the edit", async () => {
            const editState = new EditorStateTransfer(eventWithMentions);
            getComponent(editState);
            // Remove charlie from the message
            const editContent = "{backspace}{backspace}friends";
            await editText(editContent);

            fireEvent.click(screen.getByText("Save"));

            const messageContent = mockClient.sendMessage.mock.calls[0][2] as RoomMessageEventContent &
                ReplacementEvent<RoomMessageEventContent>;

            // no new mentions were added, so nothing in top level mentions
            expect(messageContent["m.mentions"]).toEqual({});
            // bob is still mentioned, charlie removed
            expect(messageContent["m.new_content"]!["m.mentions"]).toEqual({
                user_ids: ["@bob:server.org"],
            });
        });

        it("should remove mentions that are removed by the edit", async () => {
            const editState = new EditorStateTransfer(eventWithMentions);
            getComponent(editState);
            const editContent = "new message!";
            // clear the original message
            await editText(editContent, true);

            fireEvent.click(screen.getByText("Save"));

            const messageContent = mockClient.sendMessage.mock.calls[0][2] as RoomMessageEventContent &
                ReplacementEvent<RoomMessageEventContent>;

            // no new mentions were added, so nothing in top level mentions
            expect(messageContent["m.mentions"]).toEqual({});
            // bob is not longer mentioned in the edited message, so empty mentions in new_content
            expect(messageContent["m.new_content"]!["m.mentions"]).toEqual({});
        });

        it("should add mentions that were added in the edit", async () => {
            const editState = new EditorStateTransfer(editedEvent);
            getComponent(editState);
            const editContent = " and @d";
            await editText(editContent);

            // wait for autocompletion to render
            await screen.findByText("Dan");
            // submit autocomplete for mention
            await editText("{enter}");

            fireEvent.click(screen.getByText("Save"));

            const messageContent = mockClient.sendMessage.mock.calls[0][2] as RoomMessageEventContent &
                ReplacementEvent<RoomMessageEventContent>;

            // new mention in the edit
            expect(messageContent["m.mentions"]).toEqual({
                user_ids: ["@dan:server.org"],
            });
            expect(messageContent["m.new_content"]!["m.mentions"]).toEqual({
                user_ids: ["@dan:server.org"],
            });
        });

        it("should add and remove mentions from the edit", async () => {
            const editState = new EditorStateTransfer(eventWithMentions);
            getComponent(editState);
            // Remove charlie from the message
            await editText("{backspace}{backspace}");
            // and replace with @room
            await editText("@d");
            // wait for autocompletion to render
            await screen.findByText("Dan");
            // submit autocomplete for @dan mention
            await editText("{enter}");

            fireEvent.click(screen.getByText("Save"));

            const messageContent = mockClient.sendMessage.mock.calls[0][2] as RoomMessageEventContent &
                ReplacementEvent<RoomMessageEventContent>;

            // new mention in the edit
            expect(messageContent["m.mentions"]).toEqual({
                user_ids: ["@dan:server.org"],
            });
            // all mentions in the edited version of the event
            expect(messageContent["m.new_content"]!["m.mentions"]).toEqual({
                user_ids: ["@bob:server.org", "@dan:server.org"],
            });
        });
    });

    describe("when message is replying", () => {
        const originalEvent = mkEvent({
            type: "m.room.message",
            user: "@ernie:test",
            room: roomId,
            content: { body: "original message", msgtype: "m.text" },
            event: true,
        });

        const replyEvent = mkEvent({
            type: "m.room.message",
            user: "@bert:test",
            room: roomId,
            content: {
                "body": "reply with plain message",
                "msgtype": "m.text",
                "m.relates_to": {
                    "m.in_reply_to": {
                        event_id: originalEvent.getId(),
                    },
                },
                "m.mentions": {
                    user_ids: [originalEvent.getSender()!],
                },
            },
            event: true,
        });

        const replyWithMentions = mkEvent({
            type: "m.room.message",
            user: "@bert:test",
            room: roomId,
            content: {
                "body": 'reply that mentions <a href="https://matrix.to/#/@bob:server.org">Bob</a>',
                "msgtype": "m.text",
                "m.relates_to": {
                    "m.in_reply_to": {
                        event_id: originalEvent.getId(),
                    },
                },
                "m.mentions": {
                    user_ids: [
                        // sender of event we replied to
                        originalEvent.getSender()!,
                    ],
                },
            },
            event: true,
        });

        beforeEach(() => {
            setupRoomWithEventsTimeline(room, [originalEvent, replyEvent]);
        });

        it("should retain parent event sender in mentions when editing with plain text", async () => {
            const editState = new EditorStateTransfer(replyEvent);
            getComponent(editState);
            const editContent = " + edit";
            await editText(editContent);

            fireEvent.click(screen.getByText("Save"));

            const messageContent = mockClient.sendMessage.mock.calls[0][2] as RoomMessageEventContent &
                ReplacementEvent<RoomMessageEventContent>;

            // no new mentions from edit
            expect(messageContent["m.mentions"]).toEqual({});
            // edited reply still mentions the parent event sender
            expect(messageContent["m.new_content"]!["m.mentions"]).toEqual({
                user_ids: [originalEvent.getSender()],
            });
        });

        it("should retain parent event sender in mentions when adding a mention", async () => {
            const editState = new EditorStateTransfer(replyEvent);
            getComponent(editState);
            await editText(" and @d");
            // wait for autocompletion to render
            await screen.findByText("Dan");
            // submit autocomplete for @dan mention
            await editText("{enter}");

            fireEvent.click(screen.getByText("Save"));

            const messageContent = mockClient.sendMessage.mock.calls[0][2] as RoomMessageEventContent &
                ReplacementEvent<RoomMessageEventContent>;

            // new mention in edit
            expect(messageContent["m.mentions"]).toEqual({
                user_ids: ["@dan:server.org"],
            });
            // edited reply still mentions the parent event sender
            // plus new mention @dan
            expect(messageContent["m.new_content"]!["m.mentions"]).toEqual({
                user_ids: [originalEvent.getSender(), "@dan:server.org"],
            });
        });

        it("should retain parent event sender in mentions when removing all mentions from content", async () => {
            const editState = new EditorStateTransfer(replyWithMentions);
            getComponent(editState);
            // replace text to remove all mentions
            await editText("no mentions here", true);

            fireEvent.click(screen.getByText("Save"));

            const messageContent = mockClient.sendMessage.mock.calls[0][2] as RoomMessageEventContent &
                ReplacementEvent<RoomMessageEventContent>;

            // no mentions in edit
            expect(messageContent["m.mentions"]).toEqual({});
            // edited reply still mentions the parent event sender
            // existing @bob mention removed
            expect(messageContent["m.new_content"]!["m.mentions"]).toEqual({
                user_ids: [originalEvent.getSender()],
            });
        });

        it("should retain parent event sender in mentions when removing mention of said user", async () => {
            const replyThatMentionsParentEventSender = mkEvent({
                type: "m.room.message",
                user: "@bert:test",
                room: roomId,
                content: {
                    "body": `reply that mentions the sender of the message we replied to <a href="https://matrix.to/#/${originalEvent.getSender()!}">Ernie</a>`,
                    "msgtype": "m.text",
                    "m.relates_to": {
                        "m.in_reply_to": {
                            event_id: originalEvent.getId(),
                        },
                    },
                    "m.mentions": {
                        user_ids: [
                            // sender of event we replied to
                            originalEvent.getSender()!,
                        ],
                    },
                },
                event: true,
            });
            const editState = new EditorStateTransfer(replyThatMentionsParentEventSender);
            getComponent(editState);
            // replace text to remove all mentions
            await editText("no mentions here", true);

            fireEvent.click(screen.getByText("Save"));

            const messageContent = mockClient.sendMessage.mock.calls[0][2] as RoomMessageEventContent &
                ReplacementEvent<RoomMessageEventContent>;

            // no mentions in edit
            expect(messageContent["m.mentions"]).toEqual({});
            // edited reply still mentions the parent event sender
            expect(messageContent["m.new_content"]!["m.mentions"]).toEqual({
                user_ids: [originalEvent.getSender()],
            });
        });
    });
});
