/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2022 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, { type ComponentProps } from "react";
import { type MatrixClient, type MatrixEvent, PushRuleKind, type Room } from "matrix-js-sdk/src/matrix";
import { mocked, type MockedObject } from "jest-mock-vitest-adapter";
import { act, render, waitFor } from "jest-matrix-react";
import { PushProcessor } from "matrix-js-sdk/src/pushprocessor";
import { setMissingEntryGenerator } from "@element-hq/web-shared-components";

import {
    getMockClientWithEventEmitter,
    mkEvent,
    mkMessage,
    mkStubRoom,
    mockClientPushProcessor,
} from "../../../../test-utils";
import DMRoomMap from "../../../../../src/utils/DMRoomMap";
import { TextualBodyFactory as TextualBody } from "../../../../../src/components/views/messages/TextualBodyFactory";
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
import RoomContext from "../../../../../src/contexts/RoomContext";
import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks";
import { type MediaEventHelper } from "../../../../../src/utils/MediaEventHelper";
import { getRoomContext } from "../../../../test-utils/room";

jest.mock("../../../../../src/hooks/useMediaVisible", () => ({
    __esModule: true,
    useMediaVisible: () => [true, jest.fn()],
}));

const room1Id = "!room1:example.com";
const room2Id = "!room2:example.com";
const room2Name = "Room 2";

interface MkRoomTextMessageOpts {
    roomId?: string;
}

const mkRoomTextMessage = (body: string, mkRoomTextMessageOpts?: MkRoomTextMessageOpts): MatrixEvent => {
    return mkMessage({
        msg: body,
        room: mkRoomTextMessageOpts?.roomId ?? room1Id,
        user: "sender",
        event: true,
    });
};

const mkFormattedMessage = (body: string, formattedBody: string): MatrixEvent => {
    return mkMessage({
        msg: body,
        formattedMsg: formattedBody,
        format: "org.matrix.custom.html",
        room: room1Id,
        user: "sender",
        event: true,
    });
};

describe("<TextualBody />", () => {
    afterEach(() => {
        jest.spyOn(global.Math, "random").mockRestore();
    });

    let defaultRoom: Room;
    let otherRoom: Room;
    let defaultMatrixClient: MockedObject<MatrixClient>;

    const defaultEvent = mkEvent({
        type: "m.room.message",
        room: room1Id,
        user: "sender",
        content: {
            body: "winks",
            msgtype: "m.emote",
        },
        event: true,
    });

    const defaultProps: ComponentProps<typeof TextualBody> = {
        mxEvent: defaultEvent,
        highlights: [] as string[],
        highlightLink: "",
        onMessageAllowed: jest.fn(),
        mediaEventHelper: {} as MediaEventHelper,
    };

    beforeEach(() => {
        defaultMatrixClient = getMockClientWithEventEmitter({
            getRoom: (roomId: string | undefined) => {
                if (roomId === room1Id) return defaultRoom;
                if (roomId === room2Id) return otherRoom;
                return null;
            },
            getRooms: () => [defaultRoom, otherRoom],
            getAccountData: (): MatrixEvent | undefined => undefined,
            isGuest: () => false,
            mxcUrlToHttp: (s: string) => s,
            getUserId: () => "@user:example.com",
            fetchRoomEvent: () => {
                throw new Error("MockClient event not found");
            },
        });
        // @ts-expect-error
        defaultMatrixClient.pushProcessor = new PushProcessor(defaultMatrixClient);

        defaultRoom = mkStubRoom(room1Id, "test room", defaultMatrixClient);
        otherRoom = mkStubRoom(room2Id, room2Name, defaultMatrixClient);

        mocked(defaultRoom).findEventById.mockImplementation((eventId: string) => {
            if (eventId === defaultEvent.getId()) return defaultEvent;
            return undefined;
        });
        jest.spyOn(global.Math, "random").mockReturnValue(0.123456);
    });

    const getComponent = (props = {}, matrixClient: MatrixClient = defaultMatrixClient, renderingFn?: any) => {
        const mergedProps = { ...defaultProps, ...props };
        const room = matrixClient.getRoom(mergedProps.mxEvent.getRoomId()) ?? defaultRoom;
        const finalProps = {
            ...mergedProps,
            permalinkCreator: mergedProps.permalinkCreator ?? new RoomPermalinkCreator(room),
        };
        return (renderingFn ?? render)(
            <MatrixClientContext.Provider value={matrixClient}>
                <RoomContext.Provider value={getRoomContext(room, {})}>
                    <TextualBody {...finalProps} />
                </RoomContext.Provider>
            </MatrixClientContext.Provider>,
        );
    };

    it("renders m.emote correctly", () => {
        DMRoomMap.makeShared(defaultMatrixClient);

        const ev = mkEvent({
            type: "m.room.message",
            room: room1Id,
            user: "sender",
            content: {
                body: "winks",
                msgtype: "m.emote",
            },
            event: true,
        });

        const { container } = getComponent({ mxEvent: ev });
        expect(container).toHaveTextContent("* sender winks");
        const content = container.querySelector(".mx_EventTile_body");
        expect(content).toMatchSnapshot();
    });

    it("keeps edited emote bodies inline with the sender", () => {
        DMRoomMap.makeShared(defaultMatrixClient);

        const ev = mkEvent({
            type: "m.room.message",
            room: room1Id,
            user: "sender",
            content: {
                body: "winks",
                msgtype: "m.emote",
            },
            event: true,
        });
        jest.spyOn(ev, "replacingEventDate").mockReturnValue(new Date(1993, 7, 3));

        const { container } = getComponent({ mxEvent: ev, replacingEventId: ev.getId() });

        expect(container).toHaveTextContent("* sender winks(edited)");
    });

    it("updates the body kind when an edit changes msgtype to m.emote", async () => {
        DMRoomMap.makeShared(defaultMatrixClient);

        const ev = mkEvent({
            type: "m.room.message",
            room: room1Id,
            user: "sender",
            content: {
                body: "hello",
                msgtype: "m.text",
            },
            event: true,
        });

        const { container } = getComponent({ mxEvent: ev });
        expect(container).toHaveTextContent("hello");

        const edit = mkEvent({
            type: "m.room.message",
            room: room1Id,
            user: "sender",
            content: {
                "body": "* waves",
                "msgtype": "m.emote",
                "m.new_content": {
                    body: "waves",
                    msgtype: "m.emote",
                },
            },
            event: true,
        });
        act(() => {
            ev.makeReplaced(edit);
        });

        await waitFor(() => expect(container).toHaveTextContent("* sender waves(edited)"));
    });

    it("renders m.notice correctly", () => {
        DMRoomMap.makeShared(defaultMatrixClient);

        const ev = mkEvent({
            type: "m.room.message",
            room: room1Id,
            user: "bot_sender",
            content: {
                body: "this is a notice, probably from a bot",
                msgtype: "m.notice",
            },
            event: true,
        });

        const { container } = getComponent({ mxEvent: ev });
        expect(container).toHaveTextContent(ev.getContent().body);
        const content = container.querySelector(".mx_EventTile_body");
        expect(content).toMatchSnapshot();
    });

    describe("renders plain-text m.text correctly", () => {
        beforeEach(() => {
            DMRoomMap.makeShared(defaultMatrixClient);
        });

        it("simple message renders as expected", () => {
            const ev = mkRoomTextMessage("this is a plaintext message");
            const { container } = getComponent({ mxEvent: ev });
            expect(container).toHaveTextContent(ev.getContent().body);
            const content = container.querySelector(".mx_EventTile_body");
            expect(content).toMatchSnapshot();
        });

        // If pills were rendered within a Portal/same shadow DOM then it'd be easier to test
        it("linkification get applied correctly into the DOM", () => {
            const ev = mkRoomTextMessage("Visit https://matrix.org/");
            const { container } = getComponent({ mxEvent: ev });
            expect(container).toHaveTextContent(ev.getContent().body);
            const content = container.querySelector(".mx_EventTile_body");
            expect(content).toMatchSnapshot();
        });

        it("should not pillify MXIDs", () => {
            const ev = mkRoomTextMessage("Chat with @user:example.com");
            const { container } = getComponent({ mxEvent: ev });
            const content = container.querySelector(".mx_EventTile_body");
            expect(content.innerHTML).toMatchSnapshot();
        });

        it("should pillify an MXID permalink", () => {
            const ev = mkRoomTextMessage("Chat with https://matrix.to/#/@user:example.com");
            const { container } = getComponent({ mxEvent: ev });
            const content = container.querySelector(".mx_EventTile_body");
            expect(content.innerHTML).toMatchSnapshot();
        });

        it("should not pillify room aliases", () => {
            const ev = mkRoomTextMessage("Visit #room:example.com");
            const { container } = getComponent({ mxEvent: ev });
            const content = container.querySelector(".mx_EventTile_body");
            expect(content.innerHTML).toMatchSnapshot();
        });

        it("should pillify a room alias permalink", () => {
            const ev = mkRoomTextMessage("Visit https://matrix.to/#/#room:example.com");
            const { container } = getComponent({ mxEvent: ev });
            const content = container.querySelector(".mx_EventTile_body");
            expect(content.innerHTML).toMatchSnapshot();
        });

        it("should pillify a permalink to a message in the same room with the label »Message from Member«", () => {
            const ev = mkRoomTextMessage(`Visit https://matrix.to/#/${room1Id}/${defaultEvent.getId()}`);
            const { container } = getComponent({ mxEvent: ev });
            const content = container.querySelector(".mx_EventTile_body");
            expect(content.innerHTML.replace(defaultEvent.getId(), "%event_id%")).toMatchSnapshot();
        });

        it("should pillify a permalink to an unknown message in the same room with the label »Message«", () => {
            const ev = mkRoomTextMessage(`Visit https://matrix.to/#/${room1Id}/!abc123`);
            const { container } = getComponent({ mxEvent: ev });
            const content = container.querySelector(".mx_EventTile_body");
            expect(content).toMatchSnapshot();
        });

        it("should pillify a permalink to an event in another room with the label »Message in Room 2«", () => {
            const ev = mkRoomTextMessage(`Visit https://matrix.to/#/${room2Id}/${defaultEvent.getId()}`);
            const { container } = getComponent({ mxEvent: ev });
            const content = container.querySelector(".mx_EventTile_body");
            expect(content.innerHTML.replace(defaultEvent.getId(), "%event_id%")).toMatchSnapshot();
        });

        it("should pillify a keyword responsible for triggering a notification", () => {
            const ev = mkRoomTextMessage("foo bar baz");
            ev.setPushDetails(undefined, {
                actions: [],
                pattern: "bar",
                rule_id: "bar",
                default: false,
                enabled: true,
                kind: PushRuleKind.ContentSpecific,
            });
            const { container } = getComponent({ mxEvent: ev });
            const content = container.querySelector(".mx_EventTile_body");
            expect(content.innerHTML).toMatchSnapshot();
        });
    });

    describe("renders formatted m.text correctly", () => {
        let matrixClient: MatrixClient;
        beforeEach(() => {
            matrixClient = getMockClientWithEventEmitter({
                getRoom: jest.fn(),
                ...mockClientPushProcessor(),
                getAccountData: (): MatrixEvent | undefined => undefined,
                getUserId: () => "@me:my_server",
                getHomeserverUrl: () => "https://my_server/",
                on: (): void => undefined,
                removeListener: (): void => undefined,
                isGuest: () => false,
                mxcUrlToHttp: (s: string) => s,
            });
            mocked(matrixClient.getRoom).mockReturnValue(mkStubRoom(room1Id, "room name", matrixClient));
            DMRoomMap.makeShared(defaultMatrixClient);
        });

        it("italics, bold, underline and strikethrough render as expected", () => {
            const ev = mkFormattedMessage(
                "foo *baz* __bar__ <del>del</del> <u>u</u>",
                "foo <em>baz</em> <strong>bar</strong> <del>del</del> <u>u</u>",
            );
            const { container } = getComponent({ mxEvent: ev }, matrixClient);
            expect(container).toHaveTextContent("foo baz bar del u");
            const content = container.querySelector(".mx_EventTile_body");
            expect(content).toMatchSnapshot();
        });

        it("spoilers get injected properly into the DOM", () => {
            const ev = mkFormattedMessage(
                "Hey [Spoiler for movie](mxc://someserver/somefile)",
                'Hey <span data-mx-spoiler="movie">the movie was awesome</span>',
            );
            const { container } = getComponent({ mxEvent: ev }, matrixClient);
            expect(container).toHaveTextContent("Hey (movie) the movie was awesome");
            const content = container.querySelector(".mx_EventTile_body");
            expect(content).toMatchSnapshot();
        });

        it("linkification is not applied to code blocks", () => {
            const ev = mkFormattedMessage(
                "Visit `https://matrix.org/`\n```\nhttps://matrix.org/\n```",
                "<p>Visit <code>https://matrix.org/</code></p>\n<pre>https://matrix.org/\n</pre>\n",
            );
            const { container } = getComponent({ mxEvent: ev }, matrixClient);
            expect(container).toHaveTextContent("Visit https://matrix.org/ 1https://matrix.org/");
            const content = container.querySelector(".mx_EventTile_body");
            expect(content).toMatchSnapshot();
        });

        it("should syntax highlight code blocks", async () => {
            const ev = mkFormattedMessage(
                "```py\n# Python Program to calculate the square root\n\n# Note: change this value for a different result\nnum = 8 \n\n# To take the input from the user\n#num = float(input('Enter a number: '))\n\nnum_sqrt = num ** 0.5\nprint('The square root of %0.3f is %0.3f'%(num ,num_sqrt))",
                "<pre><code class=\"language-py\"># Python Program to calculate the square root\n\n# Note: change this value for a different result\nnum = 8 \n\n# To take the input from the user\n#num = float(input('Enter a number: '))\n\nnum_sqrt = num ** 0.5\nprint('The square root of %0.3f is %0.3f'%(num ,num_sqrt))\n</code></pre>\n",
            );
            const { container } = getComponent({ mxEvent: ev }, matrixClient);
            await waitFor(() => expect(container.querySelector(".hljs-built_in")).toBeInTheDocument());
            const content = container.querySelector(".mx_EventTile_body");
            expect(content).toMatchSnapshot();
        });

        // If pills were rendered within a Portal/same shadow DOM then it'd be easier to test
        it("pills get injected correctly into the DOM", () => {
            const ev = mkFormattedMessage("Hey User", 'Hey <a href="https://matrix.to/#/@user:server">Member</a>');
            const { container } = getComponent({ mxEvent: ev }, matrixClient);
            expect(container).toHaveTextContent("Hey Member");
            const content = container.querySelector(".mx_EventTile_body");
            expect(content).toMatchSnapshot();
        });

        it("pills do not appear in code blocks", () => {
            const ev = mkFormattedMessage(
                "`@room`\n```\n@room\n```",
                "<p><code>@room</code></p>\n<pre><code>@room\n</code></pre>\n",
            );
            const { container } = getComponent({ mxEvent: ev });
            expect(container).toHaveTextContent("@room 1@room");
            const content = container.querySelector(".mx_EventTile_body");
            expect(content).toMatchSnapshot();
        });

        it("pills do not appear for event permalinks with a custom label", () => {
            const ev = mkFormattedMessage(
                "An [event link](https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com/" +
                    "$16085560162aNpaH:example.com?via=example.com) with text",
                'An <a href="https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com/' +
                    '$16085560162aNpaH:example.com?via=example.com">event link</a> with text',
            );
            const { asFragment, container } = getComponent({ mxEvent: ev }, matrixClient);
            expect(container).toHaveTextContent("An event link with text");
            expect(asFragment()).toMatchSnapshot();
        });

        it("pills appear for event permalinks without a custom label", () => {
            const ev = mkFormattedMessage(
                "See this message https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com/$16085560162aNpaH:example.com?via=example.com",
                'See this message <a href="https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com/$16085560162aNpaH:example.com?via=example.com">' +
                    "https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com/$16085560162aNpaH:example.com?via=example.com</a>",
            );
            const { asFragment } = getComponent({ mxEvent: ev }, matrixClient);
            expect(asFragment()).toMatchSnapshot();
        });

        it("pills appear for room links with vias", () => {
            const ev = mkFormattedMessage(
                "A [room link](https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com" +
                    "?via=example.com&via=bob.com) with vias",
                'A <a href="https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com' +
                    '?via=example.com&amp;via=bob.com">room link</a> with vias',
            );
            const { asFragment, container } = getComponent({ mxEvent: ev }, matrixClient);
            expect(container).toHaveTextContent("A room name with vias");
            expect(asFragment()).toMatchSnapshot();
        });

        it("pills appear for an MXID permalink", () => {
            const ev = mkFormattedMessage(
                "Chat with [@user:example.com](https://matrix.to/#/@user:example.com)",
                'Chat with <a href="https://matrix.to/#/@user:example.com">@user:example.com</a>',
            );
            const { container } = getComponent({ mxEvent: ev }, matrixClient);
            const content = container.querySelector(".mx_EventTile_body");
            expect(content).toMatchSnapshot();
        });

        it("renders formatted body without html correctly", () => {
            const ev = mkEvent({
                type: "m.room.message",
                room: "room_id",
                user: "sender",
                content: {
                    body: "escaped \\*markdown\\*",
                    msgtype: "m.text",
                    format: "org.matrix.custom.html",
                    formatted_body: "escaped *markdown*",
                },
                event: true,
            });

            const { container } = getComponent({ mxEvent: ev }, matrixClient);

            const content = container.querySelector(".mx_EventTile_body");
            expect(content).toMatchSnapshot();
        });
    });

    describe("url preview", () => {
        let matrixClient: MatrixClient;

        beforeEach(() => {
            setMissingEntryGenerator((key) => key.split("|", 2)[1]);
            matrixClient = getMockClientWithEventEmitter({
                getRoom: jest.fn(),
                getUserId: jest.fn(),
                ...mockClientPushProcessor(),
                getAccountData: (): MatrixClient | undefined => undefined,
                getUrlPreview: (url: string) => new Promise(() => {}),
                isGuest: () => false,
                mxcUrlToHttp: (s: string) => s,
            });
            mocked(matrixClient.getRoom).mockReturnValue(mkStubRoom("room_id", "room name", matrixClient));
            DMRoomMap.makeShared(defaultMatrixClient);
        });

        it("renders url previews correctly", () => {
            const ev = mkRoomTextMessage("Visit https://matrix.org/");
            const { container, rerender } = getComponent({ mxEvent: ev, showUrlPreview: true }, matrixClient);

            expect(container).toHaveTextContent(ev.getContent().body);
            expect(container.querySelector("a")).toHaveAttribute("href", "https://matrix.org/");

            // simulate an event edit and check the transition from the old URL preview to the new one
            const ev2 = mkEvent({
                type: "m.room.message",
                room: "room_id",
                user: "sender",
                content: {
                    "m.new_content": {
                        body: "Visit https://vector.im/ and https://riot.im/",
                        msgtype: "m.text",
                    },
                },
                event: true,
            });
            jest.spyOn(ev, "replacingEventDate").mockReturnValue(new Date(1993, 7, 3));
            ev.makeReplaced(ev2);

            getComponent({ mxEvent: ev, showUrlPreview: true, replacingEventId: ev.getId() }, matrixClient, rerender);

            expect(container).toHaveTextContent(ev2.getContent()["m.new_content"].body + "(edited)");

            const links = ["https://vector.im/", "https://riot.im/"];
            const anchorNodes = container.querySelectorAll("a");
            Array.from(anchorNodes).forEach((node, index) => {
                expect(node).toHaveAttribute("href", links[index]);
            });
        });

        it("should listen to showUrlPreview change", () => {
            const ev = mkRoomTextMessage("Visit https://matrix.org/");

            const { container, rerender } = getComponent({ mxEvent: ev, showUrlPreview: false }, matrixClient);
            expect(container.querySelector(".mx_LinkPreviewGroup")).toBeNull();

            getComponent({ mxEvent: ev, showUrlPreview: true }, matrixClient, rerender);
            waitFor(() => {
                // Asynchronous check since the VM needs to recalcuate.
                expect(container.querySelector(".mx_LinkPreviewGroup")).toBeTruthy();
            });
        });
    });
});
