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

SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/

import React from "react";
import { fireEvent, render, RenderResult, screen, waitFor } from "jest-matrix-react";
import {
    EventStatus,
    MatrixEvent,
    Room,
    PendingEventOrdering,
    BeaconIdentifier,
    Beacon,
    getBeaconInfoIdentifier,
    EventType,
    FeatureSupport,
    Thread,
    M_POLL_KIND_DISCLOSED,
    EventTimeline,
} from "matrix-js-sdk/src/matrix";
import { PollStartEvent } from "matrix-js-sdk/src/extensible_events_v1/PollStartEvent";
import { mocked } from "jest-mock";
import userEvent from "@testing-library/user-event";

import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import RoomContext, { TimelineRenderingType } from "../../../../src/contexts/RoomContext";
import { IRoomState } from "../../../../src/components/structures/RoomView";
import { canEditContent } from "../../../../src/utils/EventUtils";
import { copyPlaintext, getSelectedText } from "../../../../src/utils/strings";
import MessageContextMenu from "../../../../src/components/views/context_menus/MessageContextMenu";
import { makeBeaconEvent, makeBeaconInfoEvent, makeLocationEvent, stubClient } from "../../../test-utils";
import dispatcher from "../../../../src/dispatcher/dispatcher";
import SettingsStore from "../../../../src/settings/SettingsStore";
import { ReadPinsEventId } from "../../../../src/components/views/right_panel/types";
import { Action } from "../../../../src/dispatcher/actions";
import { mkVoiceBroadcastInfoStateEvent } from "../../../voice-broadcast/utils/test-utils";
import { VoiceBroadcastInfoState } from "../../../../src/voice-broadcast";
import { createMessageEventContent } from "../../../test-utils/events";

jest.mock("../../../../src/utils/strings", () => ({
    copyPlaintext: jest.fn(),
    getSelectedText: jest.fn(),
}));
jest.mock("../../../../src/utils/EventUtils", () => ({
    ...(jest.requireActual("../../../../src/utils/EventUtils") as object),
    canEditContent: jest.fn(),
}));
jest.mock("../../../../src/dispatcher/dispatcher");

const roomId = "roomid";

describe("MessageContextMenu", () => {
    beforeEach(() => {
        jest.resetAllMocks();
        stubClient();
    });

    it("does show copy link button when supplied a link", () => {
        const eventContent = createMessageEventContent("hello");
        const props = {
            link: "https://google.com/",
        };
        createMenuWithContent(eventContent, props);
        const copyLinkButton = document.querySelector('a[aria-label="Copy link"]');
        expect(copyLinkButton).toHaveAttribute("href", props.link);
    });

    it("does not show copy link button when not supplied a link", () => {
        const eventContent = createMessageEventContent("hello");
        createMenuWithContent(eventContent);
        const copyLinkButton = document.querySelector('a[aria-label="Copy link"]');
        expect(copyLinkButton).toBeFalsy();
    });

    describe("message pinning", () => {
        let room: Room;

        beforeEach(() => {
            room = makeDefaultRoom();

            jest.spyOn(SettingsStore, "getValue").mockReturnValue(true);
            jest.spyOn(
                room.getLiveTimeline().getState(EventTimeline.FORWARDS)!,
                "mayClientSendStateEvent",
            ).mockReturnValue(true);
        });

        afterAll(() => {
            jest.spyOn(SettingsStore, "getValue").mockRestore();
        });

        it("does not show pin option when user does not have rights to pin", () => {
            const eventContent = createMessageEventContent("hello");
            const event = new MatrixEvent({ type: EventType.RoomMessage, content: eventContent });

            // mock permission to disallow adding pinned messages to room
            jest.spyOn(
                room.getLiveTimeline().getState(EventTimeline.FORWARDS)!,
                "mayClientSendStateEvent",
            ).mockReturnValue(false);

            createMenu(event, { rightClick: true }, {}, undefined, room);

            expect(screen.queryByRole("menuitem", { name: "Pin" })).toBeFalsy();
        });

        it("does not show pin option for beacon_info event", () => {
            const deadBeaconEvent = makeBeaconInfoEvent("@alice:server.org", roomId, { isLive: false });

            createMenu(deadBeaconEvent, { rightClick: true }, {}, undefined, room);

            expect(screen.queryByRole("menuitem", { name: "Pin" })).toBeFalsy();
        });

        it("shows pin option when pinning feature is enabled", () => {
            const eventContent = createMessageEventContent("hello");
            const pinnableEvent = new MatrixEvent({
                type: EventType.RoomMessage,
                content: eventContent,
                room_id: roomId,
            });

            createMenu(pinnableEvent, { rightClick: true }, {}, undefined, room);

            expect(screen.getByRole("menuitem", { name: "Pin" })).toBeTruthy();
        });

        it("pins event on pin option click", async () => {
            const onFinished = jest.fn();
            const eventContent = createMessageEventContent("hello");
            const pinnableEvent = new MatrixEvent({
                type: EventType.RoomMessage,
                content: eventContent,
                room_id: roomId,
            });
            pinnableEvent.event.event_id = "!3";
            const client = MatrixClientPeg.safeGet();

            jest.spyOn(room.getLiveTimeline().getState(EventTimeline.FORWARDS)!, "getStateEvents").mockReturnValue({
                // @ts-ignore
                getContent: () => ({ pinned: ["!1", "!2"] }),
            });

            // mock read pins account data
            const pinsAccountData = new MatrixEvent({ content: { event_ids: ["!1", "!2"] } });
            jest.spyOn(room, "getAccountData").mockReturnValue(pinsAccountData);

            createMenu(pinnableEvent, { onFinished, rightClick: true }, {}, undefined, room);

            await userEvent.click(screen.getByRole("menuitem", { name: "Pin" }));

            // added to account data
            await waitFor(() =>
                expect(client.setRoomAccountData).toHaveBeenCalledWith(roomId, ReadPinsEventId, {
                    event_ids: [
                        // from account data
                        "!1",
                        "!2",
                        pinnableEvent.getId(),
                    ],
                }),
            );

            // add to room's pins
            await waitFor(() =>
                expect(client.sendStateEvent).toHaveBeenCalledWith(
                    roomId,
                    EventType.RoomPinnedEvents,
                    {
                        pinned: ["!1", "!2", pinnableEvent.getId()],
                    },
                    "",
                ),
            );

            expect(onFinished).toHaveBeenCalled();
        });

        it("unpins event on pin option click when event is pinned", async () => {
            const eventContent = createMessageEventContent("hello");
            const pinnableEvent = new MatrixEvent({
                type: EventType.RoomMessage,
                content: eventContent,
                room_id: roomId,
            });
            pinnableEvent.event.event_id = "!3";
            const client = MatrixClientPeg.safeGet();

            // make the event already pinned in the room
            const pinEvent = new MatrixEvent({
                type: EventType.RoomPinnedEvents,
                room_id: roomId,
                state_key: "",
                content: { pinned: [pinnableEvent.getId(), "!another-event"] },
            });
            room.getLiveTimeline().getState(EventTimeline.FORWARDS)!.setStateEvents([pinEvent]);

            // mock read pins account data
            const pinsAccountData = new MatrixEvent({ content: { event_ids: ["!1", "!2"] } });
            jest.spyOn(room, "getAccountData").mockReturnValue(pinsAccountData);

            createMenu(pinnableEvent, { rightClick: true }, {}, undefined, room);

            await userEvent.click(screen.getByRole("menuitem", { name: "Unpin" }));

            expect(client.setRoomAccountData).not.toHaveBeenCalled();

            // add to room's pins
            expect(client.sendStateEvent).toHaveBeenCalledWith(
                roomId,
                EventType.RoomPinnedEvents,
                // pinnableEvent's id removed, other pins intact
                { pinned: ["!another-event"] },
                "",
            );
        });
    });

    describe("message forwarding", () => {
        it("allows forwarding a room message", () => {
            const eventContent = createMessageEventContent("hello");
            createMenuWithContent(eventContent);
            expect(document.querySelector('li[aria-label="Forward"]')).toBeTruthy();
        });

        it("does not allow forwarding a poll", () => {
            const eventContent = PollStartEvent.from("why?", ["42"], M_POLL_KIND_DISCLOSED);
            createMenuWithContent(eventContent);
            expect(document.querySelector('li[aria-label="Forward"]')).toBeFalsy();
        });

        it("should not allow forwarding a voice broadcast", () => {
            const broadcastStartEvent = mkVoiceBroadcastInfoStateEvent(
                roomId,
                VoiceBroadcastInfoState.Started,
                "@user:example.com",
                "ABC123",
            );
            createMenu(broadcastStartEvent);
            expect(document.querySelector('li[aria-label="Forward"]')).toBeFalsy();
        });

        describe("forwarding beacons", () => {
            const aliceId = "@alice:server.org";

            it("does not allow forwarding a beacon that is not live", () => {
                const deadBeaconEvent = makeBeaconInfoEvent(aliceId, roomId, { isLive: false });
                const beacon = new Beacon(deadBeaconEvent);
                const beacons = new Map<BeaconIdentifier, Beacon>();
                beacons.set(getBeaconInfoIdentifier(deadBeaconEvent), beacon);
                createMenu(deadBeaconEvent, {}, {}, beacons);
                expect(document.querySelector('li[aria-label="Forward"]')).toBeFalsy();
            });

            it("does not allow forwarding a beacon that is not live but has a latestLocation", () => {
                const deadBeaconEvent = makeBeaconInfoEvent(aliceId, roomId, { isLive: false });
                const beaconLocation = makeBeaconEvent(aliceId, {
                    beaconInfoId: deadBeaconEvent.getId(),
                    geoUri: "geo:51,41",
                });
                const beacon = new Beacon(deadBeaconEvent);
                // @ts-ignore illegally set private prop
                beacon._latestLocationEvent = beaconLocation;
                const beacons = new Map<BeaconIdentifier, Beacon>();
                beacons.set(getBeaconInfoIdentifier(deadBeaconEvent), beacon);
                createMenu(deadBeaconEvent, {}, {}, beacons);
                expect(document.querySelector('li[aria-label="Forward"]')).toBeFalsy();
            });

            it("does not allow forwarding a live beacon that does not have a latestLocation", () => {
                const beaconEvent = makeBeaconInfoEvent(aliceId, roomId, { isLive: true });

                const beacon = new Beacon(beaconEvent);
                const beacons = new Map<BeaconIdentifier, Beacon>();
                beacons.set(getBeaconInfoIdentifier(beaconEvent), beacon);
                createMenu(beaconEvent, {}, {}, beacons);
                expect(document.querySelector('li[aria-label="Forward"]')).toBeFalsy();
            });

            it("allows forwarding a live beacon that has a location", () => {
                const liveBeaconEvent = makeBeaconInfoEvent(aliceId, roomId, { isLive: true });
                const beaconLocation = makeBeaconEvent(aliceId, {
                    beaconInfoId: liveBeaconEvent.getId(),
                    geoUri: "geo:51,41",
                });
                const beacon = new Beacon(liveBeaconEvent);
                // @ts-ignore illegally set private prop
                beacon._latestLocationEvent = beaconLocation;
                const beacons = new Map<BeaconIdentifier, Beacon>();
                beacons.set(getBeaconInfoIdentifier(liveBeaconEvent), beacon);
                createMenu(liveBeaconEvent, {}, {}, beacons);
                expect(document.querySelector('li[aria-label="Forward"]')).toBeTruthy();
            });

            it("opens forward dialog with correct event", () => {
                const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
                const liveBeaconEvent = makeBeaconInfoEvent(aliceId, roomId, { isLive: true });
                const beaconLocation = makeBeaconEvent(aliceId, {
                    beaconInfoId: liveBeaconEvent.getId(),
                    geoUri: "geo:51,41",
                });
                const beacon = new Beacon(liveBeaconEvent);
                // @ts-ignore illegally set private prop
                beacon._latestLocationEvent = beaconLocation;
                const beacons = new Map<BeaconIdentifier, Beacon>();
                beacons.set(getBeaconInfoIdentifier(liveBeaconEvent), beacon);
                createMenu(liveBeaconEvent, {}, {}, beacons);

                fireEvent.click(document.querySelector('li[aria-label="Forward"]')!);

                // called with forwardableEvent, not beaconInfo event
                expect(dispatchSpy).toHaveBeenCalledWith(
                    expect.objectContaining({
                        event: beaconLocation,
                    }),
                );
            });
        });
    });

    describe("open as map link", () => {
        it("does not allow opening a plain message in open street maps", () => {
            const eventContent = createMessageEventContent("hello");
            createMenuWithContent(eventContent);
            expect(document.querySelector('a[aria-label="Open in OpenStreetMap"]')).toBeFalsy();
        });

        it("does not allow opening a beacon that does not have a shareable location event", () => {
            const deadBeaconEvent = makeBeaconInfoEvent("@alice", roomId, { isLive: false });
            const beacon = new Beacon(deadBeaconEvent);
            const beacons = new Map<BeaconIdentifier, Beacon>();
            beacons.set(getBeaconInfoIdentifier(deadBeaconEvent), beacon);
            createMenu(deadBeaconEvent, {}, {}, beacons);
            expect(document.querySelector('a[aria-label="Open in OpenStreetMap"]')).toBeFalsy();
        });

        it("allows opening a location event in open street map", () => {
            const locationEvent = makeLocationEvent("geo:50,50");
            createMenu(locationEvent);
            // exists with a href with the lat/lon from the location event
            expect(document.querySelector('a[aria-label="Open in OpenStreetMap"]')).toHaveAttribute(
                "href",
                "https://www.openstreetmap.org/?mlat=50&mlon=50#map=16/50/50",
            );
        });

        it("allows opening a beacon that has a shareable location event", () => {
            const liveBeaconEvent = makeBeaconInfoEvent("@alice", roomId, { isLive: true });
            const beaconLocation = makeBeaconEvent("@alice", {
                beaconInfoId: liveBeaconEvent.getId(),
                geoUri: "geo:51,41",
            });
            const beacon = new Beacon(liveBeaconEvent);
            // @ts-ignore illegally set private prop
            beacon._latestLocationEvent = beaconLocation;
            const beacons = new Map<BeaconIdentifier, Beacon>();
            beacons.set(getBeaconInfoIdentifier(liveBeaconEvent), beacon);
            createMenu(liveBeaconEvent, {}, {}, beacons);
            // exists with a href with the lat/lon from the location event
            expect(document.querySelector('a[aria-label="Open in OpenStreetMap"]')).toHaveAttribute(
                "href",
                "https://www.openstreetmap.org/?mlat=51&mlon=41#map=16/51/41",
            );
        });
    });

    describe("right click", () => {
        it("copy button does work as expected", () => {
            const text = "hello";
            const eventContent = createMessageEventContent(text);
            mocked(getSelectedText).mockReturnValue(text);

            createRightClickMenuWithContent(eventContent);
            const copyButton = document.querySelector('li[aria-label="Copy"]')!;
            fireEvent.mouseDown(copyButton);
            expect(copyPlaintext).toHaveBeenCalledWith(text);
        });

        it("copy button is not shown when there is nothing to copy", () => {
            const text = "hello";
            const eventContent = createMessageEventContent(text);
            mocked(getSelectedText).mockReturnValue("");

            createRightClickMenuWithContent(eventContent);
            const copyButton = document.querySelector('li[aria-label="Copy"]');
            expect(copyButton).toBeFalsy();
        });

        it("shows edit button when we can edit", () => {
            const eventContent = createMessageEventContent("hello");
            mocked(canEditContent).mockReturnValue(true);

            createRightClickMenuWithContent(eventContent);
            const editButton = document.querySelector('li[aria-label="Edit"]');
            expect(editButton).toBeTruthy();
        });

        it("does not show edit button when we cannot edit", () => {
            const eventContent = createMessageEventContent("hello");
            mocked(canEditContent).mockReturnValue(false);

            createRightClickMenuWithContent(eventContent);
            const editButton = document.querySelector('li[aria-label="Edit"]');
            expect(editButton).toBeFalsy();
        });

        it("shows reply button when we can reply", () => {
            const eventContent = createMessageEventContent("hello");
            const context = {
                canSendMessages: true,
            };

            createRightClickMenuWithContent(eventContent, context);
            const replyButton = document.querySelector('li[aria-label="Reply"]');
            expect(replyButton).toBeTruthy();
        });

        it("does not show reply button when we cannot reply", () => {
            const eventContent = createMessageEventContent("hello");
            const context = {
                canSendMessages: true,
            };
            const unsentMessage = new MatrixEvent({ type: EventType.RoomMessage, content: eventContent });
            // queued messages are not actionable
            unsentMessage.setStatus(EventStatus.QUEUED);

            createMenu(unsentMessage, {}, context);
            const replyButton = document.querySelector('li[aria-label="Reply"]');
            expect(replyButton).toBeFalsy();
        });

        it("shows react button when we can react", () => {
            const eventContent = createMessageEventContent("hello");
            const context = {
                canReact: true,
            };

            createRightClickMenuWithContent(eventContent, context);
            const reactButton = document.querySelector('li[aria-label="React"]');
            expect(reactButton).toBeTruthy();
        });

        it("does not show react button when we cannot react", () => {
            const eventContent = createMessageEventContent("hello");
            const context = {
                canReact: false,
            };

            createRightClickMenuWithContent(eventContent, context);
            const reactButton = document.querySelector('li[aria-label="React"]');
            expect(reactButton).toBeFalsy();
        });

        it("shows view in room button when the event is a thread root", () => {
            const eventContent = createMessageEventContent("hello");
            const mxEvent = new MatrixEvent({ type: EventType.RoomMessage, content: eventContent });
            mxEvent.getThread = () => ({ rootEvent: mxEvent }) as Thread;
            const props = {
                rightClick: true,
            };
            const context = {
                timelineRenderingType: TimelineRenderingType.Thread,
            };

            createMenu(mxEvent, props, context);
            const reactButton = document.querySelector('li[aria-label="View in room"]');
            expect(reactButton).toBeTruthy();
        });

        it("does not show view in room button when the event is not a thread root", () => {
            const eventContent = createMessageEventContent("hello");

            createRightClickMenuWithContent(eventContent);
            const reactButton = document.querySelector('li[aria-label="View in room"]');
            expect(reactButton).toBeFalsy();
        });

        it("creates a new thread on reply in thread click", () => {
            const eventContent = createMessageEventContent("hello");
            const mxEvent = new MatrixEvent({ type: EventType.RoomMessage, content: eventContent });

            Thread.hasServerSideSupport = FeatureSupport.Stable;
            const context = {
                canSendMessages: true,
            };
            jest.spyOn(SettingsStore, "getValue").mockReturnValue(true);

            createRightClickMenu(mxEvent, context);

            const replyInThreadButton = document.querySelector('li[aria-label="Reply in thread"]')!;
            fireEvent.click(replyInThreadButton);

            expect(dispatcher.dispatch).toHaveBeenCalledWith({
                action: Action.ShowThread,
                rootEvent: mxEvent,
                push: false,
            });
        });
    });
});

function createRightClickMenuWithContent(eventContent: object, context?: Partial<IRoomState>): RenderResult {
    return createMenuWithContent(eventContent, { rightClick: true }, context);
}

function createRightClickMenu(mxEvent: MatrixEvent, context?: Partial<IRoomState>): RenderResult {
    return createMenu(mxEvent, { rightClick: true }, context);
}

function createMenuWithContent(
    eventContent: object,
    props?: Partial<MessageContextMenu["props"]>,
    context?: Partial<IRoomState>,
): RenderResult {
    // XXX: We probably shouldn't be assuming all events are going to be message events, but considering this
    // test is for the Message context menu, it's a fairly safe assumption.
    const mxEvent = new MatrixEvent({ type: EventType.RoomMessage, content: eventContent });
    return createMenu(mxEvent, props, context);
}

function makeDefaultRoom(): Room {
    return new Room(roomId, MatrixClientPeg.safeGet(), "@user:example.com", {
        pendingEventOrdering: PendingEventOrdering.Detached,
    });
}

function createMenu(
    mxEvent: MatrixEvent,
    props?: Partial<MessageContextMenu["props"]>,
    context: Partial<IRoomState> = {},
    beacons: Map<BeaconIdentifier, Beacon> = new Map(),
    room: Room = makeDefaultRoom(),
): RenderResult {
    const client = MatrixClientPeg.safeGet();

    // @ts-ignore illegally set private prop
    room.currentState.beacons = beacons;

    mxEvent.setStatus(EventStatus.SENT);

    client.getUserId = jest.fn().mockReturnValue("@user:example.com");
    client.getRoom = jest.fn().mockReturnValue(room);

    return render(
        <RoomContext.Provider value={context as IRoomState}>
            <MessageContextMenu mxEvent={mxEvent} onFinished={jest.fn()} {...props} />
        </RoomContext.Provider>,
    );
}
