/*
Copyright 2025 New Vector Ltd.

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 { EventType, KnownMembership, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { mocked } from "jest-mock";

import type { MatrixClient } from "matrix-js-sdk/src/matrix";
import type { RoomNotificationState } from "../../../../src/stores/notifications/RoomNotificationState";
import {
    CHATS_TAG,
    LISTS_UPDATE_EVENT,
    SECTION_CREATED_EVENT,
    RoomListStoreV3Class,
    type Section,
} from "../../../../src/stores/room-list-v3/RoomListStoreV3";
import * as sectionModule from "../../../../src/stores/room-list-v3/section";
import { AsyncStoreWithClient } from "../../../../src/stores/AsyncStoreWithClient";
import { RecencySorter } from "../../../../src/stores/room-list-v3/skip-list/sorters/RecencySorter";
import { mkEvent, mkMessage, mkSpace, mkStubRoom, stubClient, upsertRoomStateEvents } from "../../../test-utils";
import { getMockedRooms } from "./skip-list/getMockedRooms";
import { AlphabeticSorter } from "../../../../src/stores/room-list-v3/skip-list/sorters/AlphabeticSorter";
import dispatcher from "../../../../src/dispatcher/dispatcher";
import SpaceStore from "../../../../src/stores/spaces/SpaceStore";
import { MetaSpace, UPDATE_SELECTED_SPACE } from "../../../../src/stores/spaces";
import { DefaultTagID } from "../../../../src/stores/room-list-v3/skip-list/tag";
import { FilterEnum } from "../../../../src/stores/room-list-v3/skip-list/filters";
import { RoomNotificationStateStore } from "../../../../src/stores/notifications/RoomNotificationStateStore";
import DMRoomMap from "../../../../src/utils/DMRoomMap";
import { SortingAlgorithm } from "../../../../src/stores/room-list-v3/skip-list/sorters";
import SettingsStore from "../../../../src/settings/SettingsStore";
import * as utils from "../../../../src/utils/notifications";
import * as utilsRLS from "../../../../src/stores/room-list-v3/utils.ts";
import { Action } from "../../../../src/dispatcher/actions";
import { SettingLevel } from "../../../../src/settings/SettingLevel.ts";

describe("RoomListStoreV3", () => {
    async function getRoomListStore() {
        const client = stubClient();
        const rooms = getMockedRooms(client);
        client.getVisibleRooms = jest.fn().mockReturnValue(rooms);
        jest.spyOn(AsyncStoreWithClient.prototype, "matrixClient", "get").mockReturnValue(client);
        const store = new RoomListStoreV3Class(dispatcher);
        await store.start();
        return { client, rooms, store, dispatcher };
    }

    beforeEach(() => {
        jest.spyOn(global, "requestAnimationFrame").mockImplementation((cb: FrameRequestCallback) => {
            cb(0);
            return 0;
        });
        jest.spyOn(SpaceStore.instance, "isRoomInSpace").mockImplementation((space) => space === MetaSpace.Home);
        jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockImplementation(() => MetaSpace.Home);
        jest.spyOn(SpaceStore.instance, "storeReadyPromise", "get").mockImplementation(() => Promise.resolve());
        jest.spyOn(RoomNotificationStateStore.instance, "getRoomState").mockImplementation((room) => {
            const state = {
                isUnread: false,
            } as unknown as RoomNotificationState;
            return state;
        });
        jest.spyOn(DMRoomMap, "shared").mockImplementation((() => {
            return {
                getUserIdForRoomId: (id) => "",
            };
        }) as () => DMRoomMap);
    });

    afterEach(() => {
        jest.restoreAllMocks();
    });

    it("Provides an unsorted list of rooms", async () => {
        const { store, rooms } = await getRoomListStore();
        expect(store.getRooms()).toEqual(rooms);
    });

    it("Provides a sorted list of rooms", async () => {
        const { store, rooms, client } = await getRoomListStore();
        const sorter = new RecencySorter(client.getSafeUserId());
        const sortedRooms = sorter.sort(rooms);
        expect(store.getSortedRooms()).toEqual(sortedRooms);
    });

    it("Provides a way to resort", async () => {
        const { store, rooms, client } = await getRoomListStore();

        // List is sorted by recency, sort by alphabetical now
        store.resort(SortingAlgorithm.Alphabetic);
        let sortedRooms = new AlphabeticSorter().sort(rooms);
        expect(store.getSortedRooms()).toEqual(sortedRooms);
        expect(store.activeSortAlgorithm).toEqual(SortingAlgorithm.Alphabetic);

        // Go back to recency sorting
        store.resort(SortingAlgorithm.Recency);
        sortedRooms = new RecencySorter(client.getSafeUserId()).sort(rooms);
        expect(store.getSortedRooms()).toEqual(sortedRooms);
        expect(store.activeSortAlgorithm).toEqual(SortingAlgorithm.Recency);
    });

    it("Uses preferred sorter on startup", async () => {
        jest.spyOn(SettingsStore, "getValue").mockImplementation(() => {
            return SortingAlgorithm.Alphabetic;
        });
        const { store } = await getRoomListStore();
        expect(store.activeSortAlgorithm).toEqual(SortingAlgorithm.Alphabetic);
    });

    describe("Updates", () => {
        it("Room is re-inserted on timeline event", async () => {
            const { store, rooms, dispatcher } = await getRoomListStore();

            // Let's pretend like a new timeline event came on the room in 37th index.
            const room = rooms[37];
            const event = mkMessage({ room: room.roomId, user: `@foo${3}:matrix.org`, ts: 1000, event: true });
            jest.spyOn(room.getLiveTimeline(), "getEvents").mockReturnValue([event]);

            const payload = {
                action: "MatrixActions.Room.timeline",
                event,
                isLiveEvent: true,
                isLiveUnfilteredRoomTimelineEvent: true,
                room,
            };

            const fn = jest.fn();
            store.on(LISTS_UPDATE_EVENT, fn);
            dispatcher.dispatch(payload, true);

            expect(fn).toHaveBeenCalled();
            expect(store.getSortedRooms()[0].roomId).toEqual(room.roomId);
        });

        it("Forgotten room is removed", async () => {
            const { store, rooms, dispatcher } = await getRoomListStore();
            const room = rooms[37];

            // Room at index 37 should be in the store now
            expect(store.getSortedRooms().map((r) => r.roomId)).toContain(room.roomId);

            // Forget room at index 37
            const payload = {
                action: Action.AfterForgetRoom,
                room: room,
            };
            const fn = jest.fn();
            store.on(LISTS_UPDATE_EVENT, fn);
            dispatcher.dispatch(payload, true);

            // Room at index 37 should no longer be in the store
            expect(fn).toHaveBeenCalled();
            expect(store.getSortedRooms().map((r) => r.roomId)).not.toContain(room.roomId);
        });

        it.each([KnownMembership.Join, KnownMembership.Invite])(
            "Room is removed when membership changes to leave",
            async (membership) => {
                const { store, rooms, dispatcher } = await getRoomListStore();

                // Let's say the user leaves room at index 37
                const room = rooms[37];

                const payload = {
                    action: "MatrixActions.Room.myMembership",
                    oldMembership: membership,
                    membership: KnownMembership.Leave,
                    room,
                };

                const fn = jest.fn();
                store.on(LISTS_UPDATE_EVENT, fn);
                dispatcher.dispatch(payload, true);

                expect(fn).toHaveBeenCalled();
                expect(store.getSortedRooms()).not.toContain(room);
            },
        );

        it("Room is not removed when user is kicked", async () => {
            const { store, rooms, dispatcher, client } = await getRoomListStore();

            // Let's say the user gets kicked out of room at index 37
            const room = rooms[37];
            const mockMember = room.getMember(client.getSafeUserId())!;
            mockMember.isKicked = () => true;
            room.getMember = () => mockMember;

            const payload = {
                action: "MatrixActions.Room.myMembership",
                oldMembership: KnownMembership.Join,
                membership: KnownMembership.Leave,
                room,
            };

            const fn = jest.fn();
            store.on(LISTS_UPDATE_EVENT, fn);
            dispatcher.dispatch(payload, true);

            expect(fn).toHaveBeenCalled();
            expect(store.getSortedRooms()).toContain(room);
        });

        it("Predecessor room is removed on room upgrade", async () => {
            const { store, rooms, client, dispatcher } = await getRoomListStore();
            // Let's say that !foo32:matrix.org is being upgraded
            const oldRoom = rooms[32];
            // Create a new room with a predecessor event that points to oldRoom
            const newRoom = new Room("!foonew:matrix.org", client, client.getSafeUserId(), {});
            mocked(client.getRoomUpgradeHistory).mockImplementation((roomId) =>
                roomId === newRoom.roomId ? [oldRoom, newRoom] : [],
            );
            const createWithPredecessor = new MatrixEvent({
                type: EventType.RoomCreate,
                sender: "@foo:foo.org",
                room_id: newRoom.roomId,
                content: {
                    predecessor: { room_id: oldRoom.roomId, event_id: "tombstone_event_id" },
                },
                event_id: "$create",
                state_key: "",
            });
            upsertRoomStateEvents(newRoom, [createWithPredecessor]);

            const fn = jest.fn();
            store.on(LISTS_UPDATE_EVENT, fn);
            dispatcher.dispatch(
                {
                    action: "MatrixActions.Room.myMembership",
                    membership: KnownMembership.Join,
                    room: newRoom,
                },
                true,
            );

            expect(fn).toHaveBeenCalled();
            const roomIds = store.getSortedRooms().map((r) => r.roomId);
            expect(roomIds).not.toContain(oldRoom.roomId);
            expect(roomIds).toContain(newRoom.roomId);
        });

        it("should not remove predecessor room based on non-reciprocated relationship", async () => {
            const { store, rooms, client, dispatcher } = await getRoomListStore();
            const oldRoom = rooms[32];
            // Create a new room with a predecessor event that points to oldRoom, but oldRoom does not point back
            const newRoom = new Room("!nefarious:matrix.org", client, client.getSafeUserId(), {});
            const createWithPredecessor = new MatrixEvent({
                type: EventType.RoomCreate,
                sender: "@foo:foo.org",
                room_id: newRoom.roomId,
                content: {
                    predecessor: { room_id: oldRoom.roomId, event_id: "tombstone_event_id" },
                },
                event_id: "$create",
                state_key: "",
            });
            upsertRoomStateEvents(newRoom, [createWithPredecessor]);

            const fn = jest.fn();
            store.on(LISTS_UPDATE_EVENT, fn);
            dispatcher.dispatch(
                {
                    action: "MatrixActions.Room.myMembership",
                    membership: KnownMembership.Join,
                    room: newRoom,
                },
                true,
            );

            expect(fn).toHaveBeenCalled();
            const roomIds = store.getSortedRooms().map((r) => r.roomId);
            expect(roomIds).toContain(oldRoom.roomId);
            expect(roomIds).toContain(newRoom.roomId);
        });

        it("Rooms are re-inserted on m.direct event", async () => {
            const { store, dispatcher, client } = await getRoomListStore();

            // Let's mock the client to return new rooms with the name "My DM Room"
            client.getRoom = (roomId: string) => mkStubRoom(roomId, "My DM Room", client);

            // Let's create a m.direct event that we can dispatch
            const content = {
                "@bar1:matrix.org": ["!foo1:matrix.org", "!foo2:matrix.org"],
                "@bar2:matrix.org": ["!foo3:matrix.org", "!foo4:matrix.org"],
                "@bar3:matrix.org": ["!foo5:matrix.org"],
            };
            const event = mkEvent({
                event: true,
                content,
                user: "@foo:matrix.org",
                type: EventType.Direct,
            });

            const fn = jest.fn();
            store.on(LISTS_UPDATE_EVENT, fn);

            // Do the actual dispatch
            dispatcher.dispatch(
                {
                    action: "MatrixActions.accountData",
                    event_type: EventType.Direct,
                    event,
                },
                true,
            );

            // Ensure only one emit occurs
            expect(fn).toHaveBeenCalledTimes(1);

            /*
             When the dispatched event is processed by the room-list, the associated
             rooms will be fetched via client.getRoom and will be re-inserted into the
             skip list. We can confirm that this happened by checking if all the dm rooms
             have the same name ("My DM Room") since we've mocked the client to return such rooms.
             */
            const ids = [
                "!foo1:matrix.org",
                "!foo2:matrix.org",
                "!foo3:matrix.org",
                "!foo4:matrix.org",
                "!foo5:matrix.org",
            ];
            const rooms = store.getSortedRooms().filter((r) => ids.includes(r.roomId));
            rooms.forEach((room) => expect(room.name).toBe("My DM Room"));
        });

        it("Room is re-inserted on tag change", async () => {
            const { store, rooms, dispatcher } = await getRoomListStore();
            const fn = jest.fn();
            store.on(LISTS_UPDATE_EVENT, fn);
            dispatcher.dispatch(
                {
                    action: "MatrixActions.Room.tags",
                    room: rooms[10],
                },
                true,
            );
            expect(fn).toHaveBeenCalled();
        });

        it("Room is re-inserted on decryption", async () => {
            const { store, rooms, client, dispatcher } = await getRoomListStore();
            jest.spyOn(client, "getRoom").mockImplementation(() => rooms[10]);

            const fn = jest.fn();
            store.on(LISTS_UPDATE_EVENT, fn);
            dispatcher.dispatch(
                {
                    action: "MatrixActions.Event.decrypted",
                    event: { getRoomId: () => rooms[10].roomId },
                },
                true,
            );
            expect(fn).toHaveBeenCalled();
        });

        it("Logs a warning if room couldn't be found from room-id on decryption action", async () => {
            const { store, client, dispatcher } = await getRoomListStore();
            jest.spyOn(client, "getRoom").mockImplementation(() => null);
            const warnSpy = jest.spyOn(logger, "warn");

            const fn = jest.fn();
            store.on(LISTS_UPDATE_EVENT, fn);

            // Dispatch a decrypted action but the room does not exist.
            dispatcher.dispatch(
                {
                    action: "MatrixActions.Event.decrypted",
                    event: {
                        getRoomId: () => "!doesnotexist:matrix.org",
                        getId: () => "some-id",
                    },
                },
                true,
            );

            expect(warnSpy).toHaveBeenCalled();
            expect(fn).not.toHaveBeenCalled();
        });

        describe("Update from read receipt", () => {
            function getReadReceiptEvent(userId: string) {
                const content = {
                    some_id: {
                        "m.read": {
                            [userId]: {
                                ts: 5000,
                            },
                        },
                    },
                };
                const event = mkEvent({
                    event: true,
                    content,
                    user: "@foo:matrix.org",
                    type: EventType.Receipt,
                });
                return event;
            }

            it("Room is re-inserted on read receipt from our user", async () => {
                const { store, rooms, client, dispatcher } = await getRoomListStore();
                const event = getReadReceiptEvent(client.getSafeUserId());
                const fn = jest.fn();
                store.on(LISTS_UPDATE_EVENT, fn);
                dispatcher.dispatch(
                    {
                        action: "MatrixActions.Room.receipt",
                        room: rooms[10],
                        event,
                    },
                    true,
                );
                expect(fn).toHaveBeenCalled();
            });

            it("Read receipt from other users do not cause room to be re-inserted", async () => {
                const { store, rooms, dispatcher } = await getRoomListStore();
                const event = getReadReceiptEvent("@foobar:matrix.org");
                const fn = jest.fn();
                store.on(LISTS_UPDATE_EVENT, fn);
                dispatcher.dispatch(
                    {
                        action: "MatrixActions.Room.receipt",
                        room: rooms[10],
                        event,
                    },
                    true,
                );
                expect(fn).not.toHaveBeenCalled();
            });
        });

        /**
         * Create a space and add it to rooms
         * @param rooms An array of rooms to which the new space is added.
         * @param inSpaceIndices  A list of indices from which rooms are added to the space.
         */
        function createSpace(rooms: Room[], inSpaceIndices: number[], client: MatrixClient) {
            const roomIds = inSpaceIndices.map((i) => rooms[i].roomId);
            const spaceRoom = mkSpace(client, "!space1:matrix.org", [], roomIds);
            rooms.push(spaceRoom);
            return { spaceRoom, roomIds };
        }

        function setupMocks(spaceRoom: Room, roomIds: string[]) {
            jest.spyOn(SpaceStore.instance, "isRoomInSpace").mockImplementation((space, id) => {
                if (space === MetaSpace.Home && !roomIds.includes(id)) return true;
                if (space === spaceRoom.roomId && roomIds.includes(id)) return true;
                return false;
            });
            jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockImplementation(() => spaceRoom.roomId);
        }

        function getClientAndRooms() {
            const client = stubClient();
            const rooms = getMockedRooms(client);
            client.getVisibleRooms = jest.fn().mockReturnValue(rooms);
            jest.spyOn(AsyncStoreWithClient.prototype, "matrixClient", "get").mockReturnValue(client);
            return { client, rooms };
        }

        describe("Spaces", () => {
            it("Newly created space is not added by the store", async () => {
                const { client, rooms } = getClientAndRooms();
                const infoSpy = jest.spyOn(logger, "info");

                const store = new RoomListStoreV3Class(dispatcher);
                await store.start();

                // Create a space and let the store know about it
                const { spaceRoom } = createSpace(rooms, [6, 8, 13, 27, 75], client);
                dispatcher.dispatch(
                    {
                        action: "MatrixActions.Room.myMembership",
                        oldMembership: KnownMembership.Leave,
                        membership: KnownMembership.Invite,
                        room: spaceRoom,
                    },
                    true,
                );

                // Space room should not be added
                expect(store.getSortedRooms()).not.toContain(spaceRoom);
                expect(infoSpy).toHaveBeenCalledWith(
                    expect.stringContaining("RoomListStoreV3: Refusing to add new room"),
                );
            });

            it("Filtering by spaces work", async () => {
                const { client, rooms } = getClientAndRooms();
                // Let's choose 5 rooms to put in space
                const { spaceRoom, roomIds } = createSpace(rooms, [6, 8, 13, 27, 75], client);

                // Mock the space store
                jest.spyOn(SpaceStore.instance, "isRoomInSpace").mockImplementation((space, id) => {
                    if (space === MetaSpace.Home && !roomIds.includes(id)) return true;
                    if (space === spaceRoom.roomId && roomIds.includes(id)) return true;
                    return false;
                });

                const store = new RoomListStoreV3Class(dispatcher);
                await store.start();
                const fn = jest.fn();
                store.on(LISTS_UPDATE_EVENT, fn);

                // The rooms which belong to the space should not be shown
                const result = store
                    .getSortedRoomsInActiveSpace()
                    .sections.flatMap((s) => s.rooms)
                    .map((r) => r.roomId);
                for (const id of roomIds) {
                    expect(result).not.toContain(id);
                }

                // Lets switch to the space
                jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockImplementation(() => spaceRoom.roomId);
                SpaceStore.instance.emit(UPDATE_SELECTED_SPACE);
                expect(fn).toHaveBeenCalled();
                const result2 = store
                    .getSortedRoomsInActiveSpace()
                    .sections.flatMap((s) => s.rooms)
                    .map((r) => r.roomId);
                for (const id of roomIds) {
                    expect(result2).toContain(id);
                }
            });
        });

        describe("Filters", () => {
            it("filters by both space and favourite", async () => {
                const { client, rooms } = getClientAndRooms();
                // Let's choose 5 rooms to put in space
                const { spaceRoom, roomIds } = createSpace(rooms, [6, 8, 13, 27, 75], client);

                // Let's say that 8, 27 an 75 are favourite rooms
                [8, 27, 75].forEach((i) => {
                    rooms[i].tags[DefaultTagID.Favourite] = {};
                });

                setupMocks(spaceRoom, roomIds);
                const store = new RoomListStoreV3Class(dispatcher);
                await store.start();

                // Sorted, filtered rooms should be 8, 27 and 75
                const result = store
                    .getSortedRoomsInActiveSpace([FilterEnum.FavouriteFilter])
                    .sections.flatMap((s) => s.rooms);
                expect(result).toHaveLength(3);
                for (const i of [8, 27, 75]) {
                    expect(result).toContain(rooms[i]);
                }
            });

            it("filters are recalculated on room update", async () => {
                const { client, rooms } = getClientAndRooms();
                // Let's choose 5 rooms to put in space
                const { spaceRoom, roomIds } = createSpace(rooms, [6, 8, 13, 27, 75], client);

                // Let's say that 8, 27 an 75 are favourite rooms
                [8, 27, 75].forEach((i) => {
                    rooms[i].tags[DefaultTagID.Favourite] = {};
                });

                setupMocks(spaceRoom, roomIds);
                const store = new RoomListStoreV3Class(dispatcher);
                await store.start();

                // Let's say 27 got unfavourited
                const fn = jest.fn();
                store.on(LISTS_UPDATE_EVENT, fn);
                rooms[27].tags = {};
                dispatcher.dispatch(
                    {
                        action: "MatrixActions.Room.tags",
                        room: rooms[27],
                    },
                    true,
                );
                expect(fn).toHaveBeenCalled();

                // Sorted, filtered rooms should be 27 and 75
                const result = store
                    .getSortedRoomsInActiveSpace([FilterEnum.FavouriteFilter])
                    .sections.flatMap((s) => s.rooms);
                expect(result).toHaveLength(2);
                for (const i of [8, 75]) {
                    expect(result).toContain(rooms[i]);
                }
            });

            it("supports filtering unread rooms", async () => {
                const { client, rooms } = getClientAndRooms();
                // Let's choose 5 rooms to put in space
                const { spaceRoom, roomIds } = createSpace(rooms, [6, 8, 13, 27, 75], client);

                // Let's say 8, 27 are unread
                jest.spyOn(RoomNotificationStateStore.instance, "getRoomState").mockImplementation((room) => {
                    const state = {
                        hasUnreadCount: [rooms[8], rooms[27]].includes(room),
                    } as unknown as RoomNotificationState;
                    return state;
                });

                setupMocks(spaceRoom, roomIds);
                const store = new RoomListStoreV3Class(dispatcher);
                await store.start();

                // Should only give us rooms at index 8 and 27
                const result = store
                    .getSortedRoomsInActiveSpace([FilterEnum.UnreadFilter])
                    .sections.flatMap((s) => s.rooms);
                expect(result).toHaveLength(2);
                for (const i of [8, 27]) {
                    expect(result).toContain(rooms[i]);
                }
            });

            it("unread filter matches rooms that are marked as unread", async () => {
                const { client, rooms } = getClientAndRooms();
                // Let's choose 5 rooms to put in space
                const { spaceRoom, roomIds } = createSpace(rooms, [6, 8, 13, 27, 75], client);

                setupMocks(spaceRoom, roomIds);
                const store = new RoomListStoreV3Class(dispatcher);
                await store.start();

                // Since there's no unread yet, we expect zero results
                let result = store
                    .getSortedRoomsInActiveSpace([FilterEnum.UnreadFilter])
                    .sections.flatMap((s) => s.rooms);
                expect(result).toHaveLength(0);

                // Mock so that room at index 8 is marked as unread
                jest.spyOn(utils, "getMarkedUnreadState").mockImplementation((room) => room.roomId === rooms[8].roomId);
                dispatcher.dispatch(
                    {
                        action: "MatrixActions.Room.accountData",
                        room: rooms[8],
                        event_type: utils.MARKED_UNREAD_TYPE_STABLE,
                    },
                    true,
                );

                // Now we expect room at index 8 to show as unread
                result = store.getSortedRoomsInActiveSpace([FilterEnum.UnreadFilter]).sections.flatMap((s) => s.rooms);
                expect(result).toHaveLength(1);
                expect(result).toContain(rooms[8]);
            });

            it("supports filtering by people and rooms", async () => {
                const { client, rooms } = getClientAndRooms();
                // Let's choose 5 rooms to put in space
                const { spaceRoom, roomIds } = createSpace(rooms, [6, 8, 13, 27, 75], client);

                // Let's say 8, 27 are dms
                const ids = [8, 27].map((i) => rooms[i].roomId);
                jest.spyOn(DMRoomMap, "shared").mockImplementation((() => {
                    return {
                        getUserIdForRoomId: (id) => (ids.includes(id) ? "@myuser:matrix.org" : ""),
                    };
                }) as () => DMRoomMap);

                setupMocks(spaceRoom, roomIds);
                const store = new RoomListStoreV3Class(dispatcher);
                await store.start();

                // Should only give us rooms at index 8 and 27
                const peopleRooms = store
                    .getSortedRoomsInActiveSpace([FilterEnum.PeopleFilter])
                    .sections.flatMap((s) => s.rooms);
                expect(peopleRooms).toHaveLength(2);
                for (const i of [8, 27]) {
                    expect(peopleRooms).toContain(rooms[i]);
                }

                // Rest are normal rooms
                const nonDms = store
                    .getSortedRoomsInActiveSpace([FilterEnum.RoomsFilter])
                    .sections.flatMap((s) => s.rooms);
                expect(nonDms).toHaveLength(3);
                for (const i of [6, 13, 75]) {
                    expect(nonDms).toContain(rooms[i]);
                }
            });

            it("supports filtering invited rooms", async () => {
                const { client, rooms } = getClientAndRooms();

                // Let's add 5 rooms that we are invited to
                const invitedRooms = getMockedRooms(client, 5);
                for (const room of invitedRooms) {
                    room.getMyMembership = jest.fn().mockReturnValue(KnownMembership.Invite);
                }

                rooms.push(...invitedRooms);

                // Let's choose 5 rooms to put in space
                const { spaceRoom, roomIds } = createSpace(rooms, [6, 8, 100, 101, 102, 103, 104], client);
                setupMocks(spaceRoom, roomIds);
                const store = new RoomListStoreV3Class(dispatcher);
                await store.start();

                const result = store
                    .getSortedRoomsInActiveSpace([FilterEnum.InvitesFilter])
                    .sections.flatMap((s) => s.rooms);
                expect(result).toHaveLength(5);
                for (const room of invitedRooms) {
                    expect(result).toContain(room);
                }
            });

            it("supports filtering by mentions", async () => {
                const { client, rooms } = getClientAndRooms();
                // Let's choose 5 rooms to put in space
                const { spaceRoom, roomIds } = createSpace(rooms, [6, 8, 13, 27, 75], client);

                // Let's say 8, 27 have mentions
                jest.spyOn(RoomNotificationStateStore.instance, "getRoomState").mockImplementation((room) => {
                    const state = {
                        isMention: [rooms[8], rooms[27]].includes(room),
                    } as unknown as RoomNotificationState;
                    return state;
                });

                setupMocks(spaceRoom, roomIds);
                const store = new RoomListStoreV3Class(dispatcher);
                await store.start();

                // Should only give us rooms at index 8 and 27
                const result = store
                    .getSortedRoomsInActiveSpace([FilterEnum.MentionsFilter])
                    .sections.flatMap((s) => s.rooms);
                expect(result).toHaveLength(2);
                for (const i of [8, 27]) {
                    expect(result).toContain(rooms[i]);
                }
            });

            it("supports filtering low priority rooms", async () => {
                const { client, rooms } = getClientAndRooms();
                // Let's choose 5 rooms to put in space
                const { spaceRoom, roomIds } = createSpace(rooms, [6, 8, 13, 27, 75], client);

                // Let's say that 8, 27 an 75 are low priority rooms
                [8, 27, 75].forEach((i) => {
                    rooms[i].tags[DefaultTagID.LowPriority] = {};
                });

                setupMocks(spaceRoom, roomIds);
                const store = new RoomListStoreV3Class(dispatcher);
                await store.start();

                // Sorted, filtered rooms should be 8, 27 and 75
                const result = store
                    .getSortedRoomsInActiveSpace([FilterEnum.LowPriorityFilter])
                    .sections.flatMap((s) => s.rooms);
                expect(result).toHaveLength(3);
                for (const i of [8, 27, 75]) {
                    expect(result).toContain(rooms[i]);
                }
            });

            it("supports multiple filters", async () => {
                const { client, rooms } = getClientAndRooms();
                // Let's choose 5 rooms to put in space
                const { spaceRoom, roomIds } = createSpace(rooms, [6, 8, 13, 27, 75], client);

                // Let's say that 8 is a favourite room
                rooms[8].tags[DefaultTagID.Favourite] = {};

                // Let's say 8, 27 are unread
                jest.spyOn(RoomNotificationStateStore.instance, "getRoomState").mockImplementation((room) => {
                    const state = {
                        hasUnreadCount: [rooms[8], rooms[27]].includes(room),
                    } as unknown as RoomNotificationState;
                    return state;
                });

                setupMocks(spaceRoom, roomIds);
                const store = new RoomListStoreV3Class(dispatcher);
                await store.start();

                // Should give us only room at 8 since that's the only room which matches both filters
                const result = store
                    .getSortedRoomsInActiveSpace([FilterEnum.UnreadFilter, FilterEnum.FavouriteFilter])
                    .sections.flatMap((s) => s.rooms);
                expect(result).toHaveLength(1);
                expect(result).toContain(rooms[8]);
            });

            it("should update filters on membership change", async () => {
                await SettingsStore.setValue("feature_ask_to_join", null, SettingLevel.DEVICE, true);
                const { store, client, dispatcher } = await getRoomListStore();
                const room = new Room("!fooknock:matrix.org", client, client.getSafeUserId(), {});

                room.getMyMembership = jest.fn().mockReturnValue(KnownMembership.Knock);
                dispatcher.dispatch(
                    {
                        action: "MatrixActions.Room.myMembership",
                        membership: KnownMembership.Knock,
                        room,
                    },
                    true,
                );
                expect(
                    store.getSortedRoomsInActiveSpace([FilterEnum.InvitesFilter]).sections.flatMap((s) => s.rooms),
                ).not.toContain(room);

                room.getMyMembership = jest.fn().mockReturnValue(KnownMembership.Invite);
                dispatcher.dispatch(
                    {
                        action: "MatrixActions.Room.myMembership",
                        oldMembership: KnownMembership.Knock,
                        membership: KnownMembership.Invite,
                        room,
                    },
                    true,
                );
                expect(
                    store.getSortedRoomsInActiveSpace([FilterEnum.InvitesFilter]).sections.flatMap((s) => s.rooms),
                ).toContain(room);
            });
        });
    });

    describe("Sections", () => {
        function enableSections(): void {
            jest.spyOn(SettingsStore, "getValue").mockImplementation((setting: string) => {
                if (setting === "feature_room_list_sections") return true;
                if (setting === "RoomList.OrderedCustomSections") return [];
                return false;
            });
        }

        function findSection(sections: Section[], tag: string): Section | undefined {
            return sections.find((s) => s.tag === tag);
        }

        function getClientAndRooms() {
            const client = stubClient();
            const rooms = getMockedRooms(client);
            client.getVisibleRooms = jest.fn().mockReturnValue(rooms);
            jest.spyOn(AsyncStoreWithClient.prototype, "matrixClient", "get").mockReturnValue(client);
            return { client, rooms };
        }

        it("returns a single chats section when sections feature is disabled", async () => {
            const { rooms } = getClientAndRooms();
            // Mark some rooms as favourite so we can verify they are NOT split out
            [0, 1, 2].forEach((i) => {
                rooms[i].tags[DefaultTagID.Favourite] = {};
            });

            const store = new RoomListStoreV3Class(dispatcher);
            await store.start();

            const result = store.getSortedRoomsInActiveSpace();
            expect(result.sections).toHaveLength(1);
            expect(result.sections[0].tag).toBe(CHATS_TAG);
            // All rooms, including favourites, are in the single section
            for (const i of [0, 1, 2]) {
                expect(result.sections[0].rooms).toContain(rooms[i]);
            }
        });

        it("returns three sections in the correct order when enabled", async () => {
            enableSections();
            getClientAndRooms();

            const store = new RoomListStoreV3Class(dispatcher);
            await store.start();

            const result = store.getSortedRoomsInActiveSpace();
            expect(result.sections).toHaveLength(3);
            expect(result.sections[0].tag).toBe(DefaultTagID.Favourite);
            expect(result.sections[1].tag).toBe(CHATS_TAG);
            expect(result.sections[2].tag).toBe(DefaultTagID.LowPriority);
        });

        it.each([
            { tag: DefaultTagID.Favourite, label: "Favourite" },
            { tag: DefaultTagID.LowPriority, label: "LowPriority" },
        ])("places tagged rooms only in the $label section", async ({ tag }) => {
            enableSections();
            const { rooms } = getClientAndRooms();

            // Mark rooms 3, 7 with the given tag
            [3, 7].forEach((i) => {
                rooms[i].tags[tag] = {};
            });

            const store = new RoomListStoreV3Class(dispatcher);
            await store.start();

            const { sections } = store.getSortedRoomsInActiveSpace();
            const targetSection = findSection(sections, tag)!;
            const chatsSection = findSection(sections, CHATS_TAG)!;

            for (const i of [3, 7]) {
                expect(targetSection.rooms).toContain(rooms[i]);
                expect(chatsSection.rooms).not.toContain(rooms[i]);
            }
        });

        it("places regular rooms only in the Chats section", async () => {
            enableSections();
            const { rooms } = getClientAndRooms();

            // Mark some rooms as favourite / low priority so the rest are regular
            rooms[0].tags[DefaultTagID.Favourite] = {};
            rooms[1].tags[DefaultTagID.LowPriority] = {};

            const store = new RoomListStoreV3Class(dispatcher);
            await store.start();

            const { sections } = store.getSortedRoomsInActiveSpace();
            const favSection = findSection(sections, DefaultTagID.Favourite)!;
            const chatsSection = findSection(sections, CHATS_TAG)!;
            const lowPrioritySection = findSection(sections, DefaultTagID.LowPriority)!;

            // A regular room (index 5) should be in chats only
            expect(chatsSection.rooms).toContain(rooms[5]);
            expect(favSection.rooms).not.toContain(rooms[5]);
            expect(lowPrioritySection.rooms).not.toContain(rooms[5]);
        });

        it("all rooms are accounted for across all sections", async () => {
            enableSections();
            const { rooms } = getClientAndRooms();

            [2, 5].forEach((i) => {
                rooms[i].tags[DefaultTagID.Favourite] = {};
            });
            [11].forEach((i) => {
                rooms[i].tags[DefaultTagID.LowPriority] = {};
            });

            const store = new RoomListStoreV3Class(dispatcher);
            await store.start();

            const { sections } = store.getSortedRoomsInActiveSpace();
            const totalRooms = sections.flatMap((s) => s.rooms).length;
            // All 100 rooms should be distributed across the three sections
            expect(totalRooms).toBe(rooms.length);
        });

        it("applies additional filter keys within each section", async () => {
            enableSections();
            const { rooms } = getClientAndRooms();

            // Rooms 3 and 7 are favourites; room 7 is also unread
            [3, 7].forEach((i) => {
                rooms[i].tags[DefaultTagID.Favourite] = {};
            });
            jest.spyOn(RoomNotificationStateStore.instance, "getRoomState").mockImplementation((room) => {
                const state = {
                    hasUnreadCount: room === rooms[7],
                } as unknown as RoomNotificationState;
                return state;
            });

            const store = new RoomListStoreV3Class(dispatcher);
            await store.start();

            const { sections } = store.getSortedRoomsInActiveSpace([FilterEnum.UnreadFilter]);
            const favSection = findSection(sections, DefaultTagID.Favourite)!;

            // Only room 7 is both favourite AND unread
            expect(favSection.rooms).toHaveLength(1);
            expect(favSection.rooms).toContain(rooms[7]);
        });

        it("sections respect space filtering", async () => {
            enableSections();
            const { rooms } = getClientAndRooms();

            // Room 3 is a favourite room in the space
            rooms[3].tags[DefaultTagID.Favourite] = {};

            const spaceRoomId = "!space1:matrix.org";
            const inSpaceIds = [3, 10, 20].map((i) => rooms[i].roomId);
            jest.spyOn(SpaceStore.instance, "isRoomInSpace").mockImplementation((space, id) => {
                if (space === spaceRoomId && inSpaceIds.includes(id)) return true;
                return false;
            });
            jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockImplementation(() => spaceRoomId);

            const store = new RoomListStoreV3Class(dispatcher);
            await store.start();

            const { sections, spaceId } = store.getSortedRoomsInActiveSpace();
            expect(spaceId).toBe(spaceRoomId);

            const allRooms = sections.flatMap((s) => s.rooms);
            const allRoomIds = allRooms.map((r) => r.roomId);

            // Only rooms in the space should appear
            for (const id of inSpaceIds) {
                expect(allRoomIds).toContain(id);
            }
            // Rooms not in the space should not appear
            expect(allRoomIds).not.toContain(rooms[50].roomId);

            // Room 3 should be in the Favourite section specifically
            const favSection = findSection(sections, DefaultTagID.Favourite)!;
            expect(favSection.rooms).toContain(rooms[3]);
        });

        describe("createSection", () => {
            it("emits SECTION_CREATED_EVENT and LISTS_UPDATE_EVENT when section is created", async () => {
                enableSections();
                getClientAndRooms();
                jest.spyOn(sectionModule, "createSection").mockResolvedValue("element.io.section.test-tag");

                const store = new RoomListStoreV3Class(dispatcher);
                await store.start();

                const sectionCreatedListener = jest.fn();
                const listsUpdateListener = jest.fn();
                store.on(SECTION_CREATED_EVENT, sectionCreatedListener);
                store.on(LISTS_UPDATE_EVENT, listsUpdateListener);

                await store.createSection();

                expect(sectionCreatedListener).toHaveBeenCalledWith("element.io.section.test-tag");
            });

            it("does not emit when section creation is cancelled", async () => {
                enableSections();
                getClientAndRooms();
                jest.spyOn(sectionModule, "createSection").mockResolvedValue(undefined);

                const store = new RoomListStoreV3Class(dispatcher);
                await store.start();

                const sectionCreatedListener = jest.fn();
                store.on(SECTION_CREATED_EVENT, sectionCreatedListener);

                await store.createSection();

                expect(sectionCreatedListener).not.toHaveBeenCalled();
            });
        });

        it("updates sections when RoomList.OrderedCustomSections setting changes", async () => {
            enableSections();
            const { rooms } = getClientAndRooms();

            let settingsWatcher: (settingName: string) => void = () => {};
            jest.spyOn(SettingsStore, "watchSetting").mockImplementation((settingName, _roomId, callback) => {
                if (settingName === "RoomList.OrderedCustomSections") settingsWatcher = callback as () => void;
                return "watcher-id";
            });

            const customTag = "element.io.section.custom";

            jest.spyOn(SettingsStore, "getValue").mockImplementation((setting: string) => {
                if (setting === "feature_room_list_sections") return true;
                if (setting === "RoomList.OrderedCustomSections") return [];
                return false;
            });

            const store = new RoomListStoreV3Class(dispatcher);
            await store.start();

            // Initial state: 3 sections (Favourite, Chats, LowPriority)
            expect(store.getSortedRoomsInActiveSpace().sections).toHaveLength(3);

            // Mark a room with the custom tag and update the settings
            rooms[0].tags = { [customTag]: { order: 0 } };
            jest.spyOn(SettingsStore, "getValue").mockImplementation((setting: string) => {
                if (setting === "feature_room_list_sections") return true;
                if (setting === "RoomList.OrderedCustomSections") return [customTag];
                return false;
            });

            // Trigger the settings watcher
            settingsWatcher("RoomList.OrderedCustomSections");

            // Now there should be 4 sections (Favourite, custom, Chats, LowPriority)
            expect(store.getSortedRoomsInActiveSpace().sections).toHaveLength(4);
            const customSection = findSection(store.getSortedRoomsInActiveSpace().sections, customTag)!;
            expect(customSection.rooms).toContain(rooms[0]);
        });
    });

    describe("Muted rooms", () => {
        async function getRoomListStoreWithMutedRooms() {
            const client = stubClient();
            const rooms = getMockedRooms(client);

            // Let's say that rooms 34, 84, 64, 14, 57 are muted
            const mutedIndices = [34, 84, 64, 14, 57];
            const mutedRooms = mutedIndices.map((i) => rooms[i]);
            jest.spyOn(RoomNotificationStateStore.instance, "getRoomState").mockImplementation((room) => {
                const state = {
                    muted: mutedRooms.includes(room),
                } as unknown as RoomNotificationState;
                return state;
            });

            client.getVisibleRooms = jest.fn().mockReturnValue(rooms);
            jest.spyOn(AsyncStoreWithClient.prototype, "matrixClient", "get").mockReturnValue(client);
            const store = new RoomListStoreV3Class(dispatcher);
            await store.start();
            return { client, rooms, mutedIndices, mutedRooms, store, dispatcher };
        }

        it("Muted rooms are sorted to the bottom of the list", async () => {
            const { store, mutedRooms, client } = await getRoomListStoreWithMutedRooms();
            const lastFiveRooms = store.getSortedRooms().slice(95);
            const expectedRooms = new RecencySorter(client.getSafeUserId()).sort(mutedRooms);
            // We expect the muted rooms to be at the bottom sorted by recency
            expect(lastFiveRooms).toEqual(expectedRooms);
        });

        it("Muted rooms are sorted within themselves", async () => {
            const { store, rooms } = await getRoomListStoreWithMutedRooms();

            // Let's say that rooms 14 and 34 get new messages in that order
            let ts = 1000;
            for (const room of [rooms[14], rooms[34]]) {
                const event = mkMessage({ room: room.roomId, user: `@foo${3}:matrix.org`, ts: 1000, event: true });
                jest.spyOn(room.getLiveTimeline(), "getEvents").mockReturnValue([event]);

                const payload = {
                    action: "MatrixActions.Room.timeline",
                    event,
                    isLiveEvent: true,
                    isLiveUnfilteredRoomTimelineEvent: true,
                    room,
                };
                dispatcher.dispatch(payload, true);
                ts = ts + 1;
            }

            const lastFiveRooms = store.getSortedRooms().slice(95);
            // The order previously would  have been 84, 64, 57, 34, 14
            // Expected new order is 34, 14, 84, 64, 57
            const expectedRooms = [rooms[34], rooms[14], rooms[84], rooms[64], rooms[57]];
            expect(lastFiveRooms).toEqual(expectedRooms);
        });

        it("Muted room is correctly sorted when unmuted", async () => {
            const { store, mutedRooms, rooms, client } = await getRoomListStoreWithMutedRooms();

            // Let's say that muted room 64 becomes un-muted.
            const unmutedRoom = rooms[64];
            jest.spyOn(utilsRLS, "getChangedOverrideRoomMutePushRules").mockImplementation(() => [unmutedRoom.roomId]);
            client.getRoom = jest.fn().mockReturnValue(unmutedRoom);
            const payload = {
                action: "MatrixActions.accountData",
                event_type: EventType.PushRules,
            };
            mutedRooms.splice(2, 1);
            dispatcher.dispatch(payload, true);

            const lastFiveRooms = store.getSortedRooms().slice(95);
            // We expect room at index 64 to no longer be at the bottom
            expect(lastFiveRooms).not.toContain(unmutedRoom);
            // Room 64 should go to index 34 since we're sorting by recency
            expect(store.getSortedRooms()[34]).toEqual(unmutedRoom);
        });
    });

    describe("Low priority rooms", () => {
        async function getRoomListStoreWithRooms() {
            const client = stubClient();
            const rooms = getMockedRooms(client);

            // Let's say that rooms 34, 84, 64, 14, 57 are low priority
            const lowPriorityIndices = [34, 84, 64, 14, 57];
            const lowPriorityRooms = lowPriorityIndices.map((i) => rooms[i]);
            for (const room of lowPriorityRooms) {
                room.tags[DefaultTagID.LowPriority] = {};
            }

            // Let's say that rooms 14, 57, 65, 78, 82, 5, 36 are muted
            const mutedIndices = [14, 57, 65, 78, 82, 5, 36];
            const mutedRooms = mutedIndices.map((i) => rooms[i]);
            jest.spyOn(RoomNotificationStateStore.instance, "getRoomState").mockImplementation((room) => {
                const state = {
                    muted: mutedRooms.includes(room),
                } as unknown as RoomNotificationState;
                return state;
            });

            client.getVisibleRooms = jest.fn().mockReturnValue(rooms);
            jest.spyOn(AsyncStoreWithClient.prototype, "matrixClient", "get").mockReturnValue(client);
            const store = new RoomListStoreV3Class(dispatcher);
            await store.start();

            // We expect the following order: Low Priority -> Low Priority & Muted -> Muted
            const expectedRoomIds = [84, 64, 34, 57, 14, 82, 78, 65, 36, 5].map((i) => rooms[i].roomId);

            return {
                client,
                rooms,
                expectedRoomIds,
                store,
                dispatcher,
            };
        }

        it("Low priority rooms are pushed to the bottom of the list just before muted rooms", async () => {
            const { store, expectedRoomIds } = await getRoomListStoreWithRooms();
            const result = store
                .getSortedRooms()
                .slice(90)
                .map((r) => r.roomId);
            expect(result).toEqual(expectedRoomIds);
        });
    });
});
