/*
 * 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 { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
import { mocked } from "jest-mock";
import { waitFor } from "jest-matrix-react";

import { createTestClient, flushPromises, flushPromisesWithFakeTimers, mkStubRoom, stubClient } from "../../test-utils";
import RoomListStoreV3, { CHATS_TAG, RoomListStoreV3Event } from "../../../src/stores/room-list-v3/RoomListStoreV3";
import SpaceStore from "../../../src/stores/spaces/SpaceStore";
import { FilterEnum } from "../../../src/stores/room-list-v3/skip-list/filters";
import dispatcher from "../../../src/dispatcher/dispatcher";
import { Action } from "../../../src/dispatcher/actions";
import { SdkContextClass } from "../../../src/contexts/SDKContext";
import DMRoomMap from "../../../src/utils/DMRoomMap";
import { RoomListViewModel } from "../../../src/viewmodels/room-list/RoomListViewModel";
import { hasCreateRoomRights } from "../../../src/viewmodels/room-list/utils";
import { DefaultTagID } from "../../../src/stores/room-list-v3/skip-list/tag";
import SettingsStore from "../../../src/settings/SettingsStore";

jest.mock("../../../src/viewmodels/room-list/utils", () => ({
    hasCreateRoomRights: jest.fn().mockReturnValue(false),
    hasAccessToOptionsMenu: jest.fn().mockReturnValue(true),
    hasAccessToNotificationMenu: jest.fn().mockReturnValue(true),
}));

describe("RoomListViewModel", () => {
    let matrixClient: MatrixClient;
    let room1: Room;
    let room2: Room;
    let room3: Room;
    let viewModel: RoomListViewModel;

    beforeEach(() => {
        matrixClient = createTestClient();
        room1 = mkStubRoom("!room1:server", "Room 1", matrixClient);
        room2 = mkStubRoom("!room2:server", "Room 2", matrixClient);
        room3 = mkStubRoom("!room3:server", "Room 3", matrixClient);

        // Setup DMRoomMap
        const dmRoomMap = {
            getUserIdForRoomId: jest.fn().mockReturnValue(null),
        } as unknown as DMRoomMap;
        DMRoomMap.setShared(dmRoomMap);

        jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
            spaceId: "home",
            sections: [{ tag: CHATS_TAG, rooms: [room1, room2, room3] }],
        });

        jest.spyOn(RoomListStoreV3.instance, "isLoadingRooms", "get").mockReturnValue(false);
        jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(null);
        jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue(null);

        mocked(hasCreateRoomRights).mockReturnValue(false);
    });

    afterEach(() => {
        viewModel?.dispose();
        jest.restoreAllMocks();
    });

    describe("Initialization", () => {
        it("should initialize with correct snapshot", () => {
            viewModel = new RoomListViewModel({ client: matrixClient });

            const snapshot = viewModel.getSnapshot();
            expect(snapshot.sections[0].roomIds).toEqual(["!room1:server", "!room2:server", "!room3:server"]);
            expect(snapshot.isRoomListEmpty).toBe(false);
            expect(snapshot.isLoadingRooms).toBe(false);
            expect(snapshot.roomListState.spaceId).toBe("home");
            expect(snapshot.filterIds.length).toBeGreaterThan(0);
            expect(snapshot.activeFilterId).toBeUndefined();
        });

        it("should initialize with empty room list", () => {
            jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
                spaceId: "home",
                sections: [{ tag: CHATS_TAG, rooms: [] }],
            });

            viewModel = new RoomListViewModel({ client: matrixClient });

            expect(viewModel.getSnapshot().sections).toEqual([]);
            expect(viewModel.getSnapshot().isRoomListEmpty).toBe(true);
        });

        it("should set canCreateRoom based on user rights", () => {
            mocked(hasCreateRoomRights).mockReturnValue(true);
            viewModel = new RoomListViewModel({ client: matrixClient });

            expect(viewModel.getSnapshot().canCreateRoom).toBe(true);
        });
    });

    describe("Room list updates", () => {
        it("should update room list when ListsUpdate event fires", () => {
            viewModel = new RoomListViewModel({ client: matrixClient });

            const newRoom = mkStubRoom("!room4:server", "Room 4", matrixClient);
            jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
                spaceId: "home",
                sections: [{ tag: CHATS_TAG, rooms: [room1, room2, room3, newRoom] }],
            });

            RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);

            expect(viewModel.getSnapshot().sections[0].roomIds).toEqual([
                "!room1:server",
                "!room2:server",
                "!room3:server",
                "!room4:server",
            ]);
        });

        it("should update loading state when ListsLoaded event fires", () => {
            jest.spyOn(RoomListStoreV3.instance, "isLoadingRooms", "get").mockReturnValue(true);
            viewModel = new RoomListViewModel({ client: matrixClient });

            expect(viewModel.getSnapshot().isLoadingRooms).toBe(true);

            RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsLoaded);

            expect(viewModel.getSnapshot().isLoadingRooms).toBe(false);
        });

        // This test ensures that the room list item vms are preserved when the room list is changing
        it("should keep existing view model when ListsUpdate event fires", () => {
            viewModel = new RoomListViewModel({ client: matrixClient });

            // Create view model for room1
            const room1VM = viewModel.getRoomItemViewModel("!room1:server");
            expect(room1VM).toBeDefined();

            RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);

            // View model should be still valid
            expect(room1VM!.isDisposed).toBe(false);
        });
    });

    describe("Space switching", () => {
        it("should update room list when space changes", () => {
            viewModel = new RoomListViewModel({ client: matrixClient });

            const spaceRoomList = [room1, room2];

            jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
                spaceId: "!space:server",
                sections: [{ tag: CHATS_TAG, rooms: spaceRoomList }],
            });

            jest.spyOn(SpaceStore.instance, "getLastSelectedRoomIdForSpace").mockReturnValue("!room1:server");

            RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);

            expect(viewModel.getSnapshot().roomListState.spaceId).toBe("!space:server");
            expect(viewModel.getSnapshot().sections[0].roomIds).toEqual(["!room1:server", "!room2:server"]);
        });

        it("should clear view models when space changes", () => {
            viewModel = new RoomListViewModel({ client: matrixClient });

            // Get view models for visible rooms
            const vm1 = viewModel.getRoomItemViewModel("!room1:server")!;
            const vm2 = viewModel.getRoomItemViewModel("!room2:server")!;

            const disposeSpy1 = jest.spyOn(vm1, "dispose");
            const disposeSpy2 = jest.spyOn(vm2, "dispose");

            // Change space
            jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
                spaceId: "!space:server",
                sections: [{ tag: CHATS_TAG, rooms: [room3] }],
            });

            RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);

            expect(disposeSpy1).toHaveBeenCalled();
            expect(disposeSpy2).toHaveBeenCalled();
        });

        it("should clear roomsMap when space changes and repopulate with new rooms", () => {
            viewModel = new RoomListViewModel({ client: matrixClient });

            const newSpaceRoom = mkStubRoom("!spaceroom:server", "Space Room", matrixClient);

            jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
                spaceId: "!space:server",
                sections: [{ tag: CHATS_TAG, rooms: [newSpaceRoom] }],
            });
            jest.spyOn(SpaceStore.instance, "getLastSelectedRoomIdForSpace").mockReturnValue(null);

            RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);

            // New space room should be accessible
            expect(() => viewModel.getRoomItemViewModel("!spaceroom:server")).not.toThrow();
            // Old rooms from the home space should not be accessible
            expect(viewModel.getRoomItemViewModel("!room1:server")).toBeUndefined();
        });
    });

    describe("Active room tracking", () => {
        it("should update active room index when room is selected", async () => {
            viewModel = new RoomListViewModel({ client: matrixClient });

            jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room2:server");

            dispatcher.dispatch({
                action: Action.ActiveRoomChanged,
                oldRoomId: "!room1:server",
                newRoomId: "!room2:server",
            });

            // Use setTimeout to allow the dispatcher callback to run
            await flushPromises();
            expect(viewModel.getSnapshot().roomListState.activeRoomIndex).toBe(1);
        });

        it("should return undefined active room index when no room is selected", async () => {
            viewModel = new RoomListViewModel({ client: matrixClient });

            jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue(null);

            dispatcher.dispatch({
                action: Action.ActiveRoomChanged,
                oldRoomId: "!room1:server",
                newRoomId: null,
            });

            // Use setTimeout to allow the dispatcher callback to run
            await flushPromises();
            expect(viewModel.getSnapshot().roomListState.activeRoomIndex).toBeUndefined();
        });
    });

    describe("Sticky room behavior", () => {
        it("should keep selected room at same index when room list updates", async () => {
            viewModel = new RoomListViewModel({ client: matrixClient });

            // Select room at index 1
            jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room2:server");
            dispatcher.dispatch({
                action: Action.ActiveRoomChanged,
                newRoomId: "!room2:server",
            });

            await flushPromises();
            expect(viewModel.getSnapshot().roomListState.activeRoomIndex).toBe(1);

            // Simulate room list update that would move room2 to front
            jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
                spaceId: "home",
                sections: [{ tag: CHATS_TAG, rooms: [room2, room1, room3] }], // room2 moved to front
            });

            RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);

            // Active room should still be at index 1 (sticky behavior)
            expect(viewModel.getSnapshot().roomListState.activeRoomIndex).toBe(1);
            expect(viewModel.getSnapshot().sections[0].roomIds[1]).toBe("!room2:server");
        });

        it("should not apply sticky behavior when user changes rooms", async () => {
            viewModel = new RoomListViewModel({ client: matrixClient });

            // Select room at index 1
            jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room2:server");
            dispatcher.dispatch({
                action: Action.ActiveRoomChanged,
                newRoomId: "!room2:server",
            });

            await flushPromises();

            // User switches to room3
            jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room3:server");
            dispatcher.dispatch({
                action: Action.ActiveRoomChanged,
                oldRoomId: "!room2:server",
                newRoomId: "!room3:server",
            });

            await flushPromises();
            expect(viewModel.getSnapshot().roomListState.activeRoomIndex).toBe(2);
        });
    });

    describe("Filters", () => {
        it("should toggle filter on", () => {
            viewModel = new RoomListViewModel({ client: matrixClient });

            expect(viewModel.getSnapshot().activeFilterId).toBeUndefined();

            jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
                spaceId: "home",
                sections: [{ tag: CHATS_TAG, rooms: [room1] }],
                filterKeys: [FilterEnum.UnreadFilter],
            });

            viewModel.onToggleFilter("unread");

            expect(viewModel.getSnapshot().activeFilterId).toBe("unread");
            expect(viewModel.getSnapshot().sections[0].roomIds).toEqual(["!room1:server"]);
        });

        it("should toggle filter off", () => {
            viewModel = new RoomListViewModel({ client: matrixClient });

            // Turn filter on
            jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
                spaceId: "home",
                sections: [{ tag: CHATS_TAG, rooms: [room1] }],
                filterKeys: [FilterEnum.UnreadFilter],
            });
            viewModel.onToggleFilter("unread");

            expect(viewModel.getSnapshot().activeFilterId).toBe("unread");

            // Turn filter off
            jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
                spaceId: "home",
                sections: [{ tag: CHATS_TAG, rooms: [room1, room2, room3] }],
            });
            viewModel.onToggleFilter("unread");

            expect(viewModel.getSnapshot().activeFilterId).toBeUndefined();
            expect(viewModel.getSnapshot().sections[0].roomIds).toEqual([
                "!room1:server",
                "!room2:server",
                "!room3:server",
            ]);
        });
    });

    describe("Room item view models", () => {
        it("should create room item view model on demand", () => {
            viewModel = new RoomListViewModel({ client: matrixClient });

            const itemViewModel = viewModel.getRoomItemViewModel("!room1:server");

            expect(itemViewModel).toBeDefined();
            expect(itemViewModel!.getSnapshot().room).toBe(room1);
        });

        it("should reuse existing room item view model", () => {
            viewModel = new RoomListViewModel({ client: matrixClient });

            const itemViewModel1 = viewModel.getRoomItemViewModel("!room1:server");
            const itemViewModel2 = viewModel.getRoomItemViewModel("!room1:server");

            expect(itemViewModel1).toBe(itemViewModel2);
        });

        it("should return undefined for non-existent room", () => {
            viewModel = new RoomListViewModel({ client: matrixClient });

            expect(viewModel.getRoomItemViewModel("!nonexistent:server")).toBeUndefined();
        });

        it("should not throw when requesting view model for a room removed from the list but still in roomsMap", () => {
            viewModel = new RoomListViewModel({ client: matrixClient });

            // Normal list update removes room2 from the list
            jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
                spaceId: "home",
                sections: [{ tag: CHATS_TAG, rooms: [room1, room3] }],
            });

            RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);

            expect(() => viewModel.getRoomItemViewModel("!room2:server")).not.toThrow();
        });

        it("should return undefined for a room from old space after space change", () => {
            viewModel = new RoomListViewModel({ client: matrixClient });

            const spaceRoom = mkStubRoom("!newroom:server", "New Room", matrixClient);

            // Space change: new space only has spaceRoom
            jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
                spaceId: "!space:server",
                sections: [{ tag: CHATS_TAG, rooms: [spaceRoom] }],
            });
            jest.spyOn(SpaceStore.instance, "getLastSelectedRoomIdForSpace").mockReturnValue(null);

            RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);

            expect(viewModel.getRoomItemViewModel("!room1:server")).toBeUndefined();
        });

        it("should recover when roomsMap is stale but roomsResult has the room", () => {
            viewModel = new RoomListViewModel({ client: matrixClient });

            // Manually clear roomsMap to simulate stale cache, but keep roomsResult intact
            (viewModel as any).roomsMap.clear();

            // getRoomItemViewModel should retry by re-populating roomsMap from roomsResult
            expect(() => viewModel.getRoomItemViewModel("!room1:server")).not.toThrow();
        });

        it("should dispose view models for rooms no longer visible", () => {
            viewModel = new RoomListViewModel({ client: matrixClient });

            const vm1 = viewModel.getRoomItemViewModel("!room1:server")!;
            const vm2 = viewModel.getRoomItemViewModel("!room2:server")!;
            const vm3 = viewModel.getRoomItemViewModel("!room3:server")!;

            const disposeSpy1 = jest.spyOn(vm1, "dispose");
            const disposeSpy3 = jest.spyOn(vm3, "dispose");

            // Update to show only middle room (index 1)
            viewModel.updateVisibleRooms(1, 2);

            expect(disposeSpy1).toHaveBeenCalled();
            expect(disposeSpy3).toHaveBeenCalled();

            // vm2 should still exist
            const vm2Again = viewModel.getRoomItemViewModel("!room2:server");
            expect(vm2Again).toBe(vm2);
        });
    });

    describe("Room creation", () => {
        it("should dispatch CreateChat action when createChatRoom is called", () => {
            viewModel = new RoomListViewModel({ client: matrixClient });

            const dispatchSpy = jest.spyOn(dispatcher, "fire");

            viewModel.createChatRoom();

            expect(dispatchSpy).toHaveBeenCalledWith(Action.CreateChat);
        });

        it("should dispatch CreateRoom action without parent space", () => {
            viewModel = new RoomListViewModel({ client: matrixClient });

            const dispatchSpy = jest.spyOn(dispatcher, "dispatch");

            viewModel.createRoom();

            expect(dispatchSpy).toHaveBeenCalledWith({
                action: Action.CreateRoom,
            });
        });

        it("should dispatch CreateRoom action with parent space", () => {
            const spaceRoom = mkStubRoom("!space:server", "Space", matrixClient);
            jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(spaceRoom);

            viewModel = new RoomListViewModel({ client: matrixClient });

            const dispatchSpy = jest.spyOn(dispatcher, "dispatch");

            viewModel.createRoom();

            expect(dispatchSpy).toHaveBeenCalledWith({
                action: Action.CreateRoom,
                parent_space: spaceRoom,
            });
        });
    });

    describe("notifyCollapseState", () => {
        it("should dispatch collapseSections=undefined when feature_room_list_sections is disabled", () => {
            viewModel = new RoomListViewModel({ client: matrixClient });

            const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
            RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);

            expect(dispatchSpy).toHaveBeenCalledWith({
                action: Action.RoomListSectionsCollapseStateChanged,
                collapseSections: undefined,
            });
        });
    });

    describe("Keyboard navigation (ViewRoomDelta)", () => {
        beforeEach(() => {
            // stubClient sets up MatrixClientPeg which is needed when ViewRoom action is dispatched
            stubClient();
        });

        it("should navigate to next room when delta is 1", async () => {
            viewModel = new RoomListViewModel({ client: matrixClient });

            jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room1:server");

            const dispatchSpy = jest.spyOn(dispatcher, "dispatch");

            dispatcher.dispatch({
                action: Action.ViewRoomDelta,
                delta: 1,
                unread: false,
            });

            await flushPromises();

            expect(dispatchSpy).toHaveBeenCalledWith(
                expect.objectContaining({
                    action: Action.ViewRoom,
                    room_id: "!room2:server",
                }),
            );
        });

        it("should navigate to previous room when delta is -1", async () => {
            viewModel = new RoomListViewModel({ client: matrixClient });

            jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room2:server");

            const dispatchSpy = jest.spyOn(dispatcher, "dispatch");

            dispatcher.dispatch({
                action: Action.ViewRoomDelta,
                delta: -1,
                unread: false,
            });

            await flushPromises();

            expect(dispatchSpy).toHaveBeenCalledWith(
                expect.objectContaining({
                    action: Action.ViewRoom,
                    room_id: "!room1:server",
                }),
            );
        });

        it("should wrap around to last room when navigating backwards from first room", async () => {
            viewModel = new RoomListViewModel({ client: matrixClient });

            jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room1:server");

            const dispatchSpy = jest.spyOn(dispatcher, "dispatch");

            dispatcher.dispatch({
                action: Action.ViewRoomDelta,
                delta: -1,
                unread: false,
            });

            await flushPromises();

            expect(dispatchSpy).toHaveBeenCalledWith(
                expect.objectContaining({
                    action: Action.ViewRoom,
                    room_id: "!room3:server",
                }),
            );
        });

        it("should not navigate when current room is not found", async () => {
            viewModel = new RoomListViewModel({ client: matrixClient });

            jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!unknown:server");

            const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
            dispatchSpy.mockClear();

            dispatcher.dispatch({
                action: Action.ViewRoomDelta,
                delta: 1,
                unread: false,
            });

            await flushPromises();

            // Should not dispatch ViewRoom since current room wasn't found
            expect(dispatchSpy).not.toHaveBeenCalledWith(
                expect.objectContaining({
                    action: Action.ViewRoom,
                }),
            );
        });

        it("should not navigate when no room is selected", async () => {
            viewModel = new RoomListViewModel({ client: matrixClient });

            jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue(null);

            const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
            dispatchSpy.mockClear();

            dispatcher.dispatch({
                action: Action.ViewRoomDelta,
                delta: 1,
                unread: false,
            });

            await flushPromises();

            expect(dispatchSpy).not.toHaveBeenCalledWith(
                expect.objectContaining({
                    action: Action.ViewRoom,
                }),
            );
        });
    });

    describe("Cleanup", () => {
        beforeEach(() => {
            jest.useFakeTimers();
        });

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

        it("should dispose all room item view models on dispose", () => {
            viewModel = new RoomListViewModel({ client: matrixClient });

            const vm1 = viewModel.getRoomItemViewModel("!room1:server")!;
            const vm2 = viewModel.getRoomItemViewModel("!room2:server")!;

            const disposeSpy1 = jest.spyOn(vm1, "dispose");
            const disposeSpy2 = jest.spyOn(vm2, "dispose");

            viewModel.dispose();

            expect(disposeSpy1).toHaveBeenCalled();
            expect(disposeSpy2).toHaveBeenCalled();
        });

        describe("Toast", () => {
            it("should show toast when SectionCreated event fires", () => {
                viewModel = new RoomListViewModel({ client: matrixClient });
                RoomListStoreV3.instance.emit(RoomListStoreV3Event.SectionCreated);
                expect(viewModel.getSnapshot().toast).toBe("section_created");
            });

            it("should show toast when RoomTagged event fires", () => {
                viewModel = new RoomListViewModel({ client: matrixClient });
                RoomListStoreV3.instance.emit(RoomListStoreV3Event.RoomTagged);
                expect(viewModel.getSnapshot().toast).toBe("chat_moved");
            });

            it("should clear toast when closeToast is called", () => {
                viewModel = new RoomListViewModel({ client: matrixClient });

                RoomListStoreV3.instance.emit(RoomListStoreV3Event.SectionCreated);
                expect(viewModel.getSnapshot().toast).toBe("section_created");

                viewModel.closeToast();
                expect(viewModel.getSnapshot().toast).toBeUndefined();
            });

            it("should auto-close toast after 15 seconds", () => {
                viewModel = new RoomListViewModel({ client: matrixClient });

                RoomListStoreV3.instance.emit(RoomListStoreV3Event.SectionCreated);
                expect(viewModel.getSnapshot().toast).toBe("section_created");

                jest.advanceTimersByTime(15 * 1000);
                expect(viewModel.getSnapshot().toast).toBeUndefined();
            });

            it("should reset the auto-close timer when a new section is created", () => {
                viewModel = new RoomListViewModel({ client: matrixClient });

                RoomListStoreV3.instance.emit(RoomListStoreV3Event.SectionCreated);
                jest.advanceTimersByTime(10 * 1000);

                // Second section created — resets the timer
                RoomListStoreV3.instance.emit(RoomListStoreV3Event.SectionCreated);
                jest.advanceTimersByTime(10 * 1000);

                // Toast should still be visible (only 10s since last emit)
                expect(viewModel.getSnapshot().toast).toBe("section_created");

                jest.advanceTimersByTime(5 * 1000);
                expect(viewModel.getSnapshot().toast).toBeUndefined();
            });
        });

        describe("Sections (feature_room_list_sections)", () => {
            let favRoom1: Room;
            let favRoom2: Room;
            let lowPriorityRoom: Room;
            let regularRoom1: Room;
            let regularRoom2: Room;

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

                favRoom1 = mkStubRoom("!fav1:server", "Fav 1", matrixClient);
                favRoom2 = mkStubRoom("!fav2:server", "Fav 2", matrixClient);
                lowPriorityRoom = mkStubRoom("!low1:server", "Low 1", matrixClient);
                regularRoom1 = mkStubRoom("!reg1:server", "Reg 1", matrixClient);
                regularRoom2 = mkStubRoom("!reg2:server", "Reg 2", matrixClient);

                jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
                    spaceId: "home",
                    sections: [
                        { tag: DefaultTagID.Favourite, rooms: [favRoom1, favRoom2] },
                        { tag: CHATS_TAG, rooms: [regularRoom1, regularRoom2] },
                        { tag: DefaultTagID.LowPriority, rooms: [lowPriorityRoom] },
                    ],
                });
            });

            it("should initialize with multiple sections", () => {
                viewModel = new RoomListViewModel({ client: matrixClient });

                const snapshot = viewModel.getSnapshot();
                expect(snapshot.sections).toHaveLength(3);
                expect(snapshot.sections[0].id).toBe(DefaultTagID.Favourite);
                expect(snapshot.sections[0].roomIds).toEqual(["!fav1:server", "!fav2:server"]);
                expect(snapshot.sections[1].id).toBe(CHATS_TAG);
                expect(snapshot.sections[1].roomIds).toEqual(["!reg1:server", "!reg2:server"]);
                expect(snapshot.sections[2].id).toBe(DefaultTagID.LowPriority);
                expect(snapshot.sections[2].roomIds).toEqual(["!low1:server"]);
            });

            it("should not be a flat list when multiple sections exist", () => {
                viewModel = new RoomListViewModel({ client: matrixClient });

                expect(viewModel.getSnapshot().isFlatList).toBe(false);
            });

            it("should be a flat list when only chats section has rooms", () => {
                jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
                    spaceId: "home",
                    sections: [
                        { tag: DefaultTagID.Favourite, rooms: [] },
                        { tag: CHATS_TAG, rooms: [regularRoom1] },
                        { tag: DefaultTagID.LowPriority, rooms: [] },
                    ],
                });

                viewModel = new RoomListViewModel({ client: matrixClient });

                expect(viewModel.getSnapshot().isFlatList).toBe(true);
                expect(viewModel.getSnapshot().sections).toHaveLength(1);
                expect(viewModel.getSnapshot().sections[0].id).toBe(CHATS_TAG);
            });

            it("should exclude favourite and low_priority from filter list", () => {
                viewModel = new RoomListViewModel({ client: matrixClient });

                const snapshot = viewModel.getSnapshot();
                expect(snapshot.filterIds).not.toContain("favourite");
                expect(snapshot.filterIds).not.toContain("low_priority");
                // Other filters should still be present
                expect(snapshot.filterIds).toContain("unread");
                expect(snapshot.filterIds).toContain("people");
            });

            it("should omit empty sections from snapshot", () => {
                jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
                    spaceId: "home",
                    sections: [
                        { tag: DefaultTagID.Favourite, rooms: [] },
                        { tag: CHATS_TAG, rooms: [regularRoom1] },
                        { tag: DefaultTagID.LowPriority, rooms: [] },
                    ],
                });

                viewModel = new RoomListViewModel({ client: matrixClient });

                const snapshot = viewModel.getSnapshot();
                expect(snapshot.sections).toHaveLength(1);
                expect(snapshot.sections[0].id).toBe(CHATS_TAG);
            });

            it("should create section header view models on demand", () => {
                viewModel = new RoomListViewModel({ client: matrixClient });

                const headerVM = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite);
                expect(headerVM).toBeDefined();
                expect(headerVM.getSnapshot().id).toBe(DefaultTagID.Favourite);
                expect(headerVM.getSnapshot().isExpanded).toBe(true);
            });

            it("should reuse section header view models", () => {
                viewModel = new RoomListViewModel({ client: matrixClient });

                const headerVM1 = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite);
                const headerVM2 = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite);
                expect(headerVM1).toBe(headerVM2);
            });

            it("should hide room IDs when a section is collapsed", () => {
                viewModel = new RoomListViewModel({ client: matrixClient });

                // Collapse the favourite section
                const favHeader = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite);
                favHeader.onClick();
                expect(favHeader.isExpanded).toBe(false);

                const snapshot = viewModel.getSnapshot();
                const favSection = snapshot.sections.find((s) => s.id === DefaultTagID.Favourite);
                expect(favSection).toBeDefined();
                // Collapsed sections have an empty roomIds list
                expect(favSection!.roomIds).toEqual([]);

                // Other sections remain unaffected
                const chatsSection = snapshot.sections.find((s) => s.id === CHATS_TAG);
                expect(chatsSection!.roomIds).toEqual(["!reg1:server", "!reg2:server"]);
            });

            it("should compute activeRoomIndex relative to visible rooms when a section is collapsed", async () => {
                viewModel = new RoomListViewModel({ client: matrixClient });

                // Collapse the favourite section (which has 2 rooms: fav1, fav2)
                const favHeader = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite);
                favHeader.onClick();
                expect(favHeader.isExpanded).toBe(false);

                // Select regularRoom1, which is the first room in the chats section
                jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!reg1:server");
                dispatcher.dispatch({
                    action: Action.ActiveRoomChanged,
                    newRoomId: "!reg1:server",
                });

                await waitFor(() => {
                    const snapshot = viewModel.getSnapshot();
                    // The favourite section is collapsed so its 2 rooms are not visible.
                    // regularRoom1 should be at index 0 in the visible list, not index 2.
                    expect(snapshot.roomListState.activeRoomIndex).toBe(0);
                });
            });

            it("should restore room IDs when a section is re-expanded", () => {
                viewModel = new RoomListViewModel({ client: matrixClient });

                const favHeader = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite);

                // Collapse then re-expand
                favHeader.onClick();
                favHeader.onClick();
                expect(favHeader.isExpanded).toBe(true);

                const snapshot = viewModel.getSnapshot();
                const favSection = snapshot.sections.find((s) => s.id === DefaultTagID.Favourite);
                expect(favSection!.roomIds).toEqual(["!fav1:server", "!fav2:server"]);
            });

            it("should update sections when room list changes", () => {
                viewModel = new RoomListViewModel({ client: matrixClient });

                const newFav = mkStubRoom("!fav3:server", "Fav 3", matrixClient);

                jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
                    spaceId: "home",
                    sections: [
                        { tag: DefaultTagID.Favourite, rooms: [favRoom1, favRoom2, newFav] },
                        { tag: CHATS_TAG, rooms: [regularRoom1, regularRoom2] },
                        { tag: DefaultTagID.LowPriority, rooms: [lowPriorityRoom] },
                    ],
                });

                RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);

                const snapshot = viewModel.getSnapshot();
                expect(snapshot.sections[0].roomIds).toEqual(["!fav1:server", "!fav2:server", "!fav3:server"]);
            });

            it("should preserve section collapse state across list updates", () => {
                viewModel = new RoomListViewModel({ client: matrixClient });

                // Collapse favourites
                const favHeader = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite);
                favHeader.onClick();

                // Trigger a list update
                RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);

                const snapshot = viewModel.getSnapshot();
                const favSection = snapshot.sections.find((s) => s.id === DefaultTagID.Favourite);
                expect(favSection!.roomIds).toEqual([]);
            });

            it("should track section collapse state per space", () => {
                viewModel = new RoomListViewModel({ client: matrixClient });

                // Collapse favourites in the home space
                const favHeader = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite);
                favHeader.onClick();

                // Switch to a different space with its own rooms
                const spaceFav = mkStubRoom("!spacefav:server", "Space Fav", matrixClient);
                const spaceReg = mkStubRoom("!spacereg:server", "Space Reg", matrixClient);
                jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
                    spaceId: "!space:server",
                    sections: [
                        { tag: DefaultTagID.Favourite, rooms: [spaceFav] },
                        { tag: CHATS_TAG, rooms: [spaceReg] },
                        { tag: DefaultTagID.LowPriority, rooms: [] },
                    ],
                });
                jest.spyOn(SpaceStore.instance, "getLastSelectedRoomIdForSpace").mockReturnValue(null);

                RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);

                let snapshot = viewModel.getSnapshot();
                // Favourites should be expanded in the new space (per-space state)
                let favSection = snapshot.sections.find((s) => s.id === DefaultTagID.Favourite);
                expect(favSection).toBeDefined();
                expect(favSection!.roomIds).toEqual(["!spacefav:server"]);

                // Other sections should also be expanded
                let chatsSection = snapshot.sections.find((s) => s.id === CHATS_TAG);
                expect(chatsSection!.roomIds).toEqual(["!spacereg:server"]);

                // Switch back to home space
                jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
                    spaceId: "home",
                    sections: [
                        { tag: DefaultTagID.Favourite, rooms: [favRoom1, favRoom2] },
                        { tag: CHATS_TAG, rooms: [regularRoom1] },
                        { tag: DefaultTagID.LowPriority, rooms: [] },
                    ],
                });

                RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);

                snapshot = viewModel.getSnapshot();
                // Favourites should still be collapsed in the home space
                favSection = snapshot.sections.find((s) => s.id === DefaultTagID.Favourite);
                expect(favSection).toBeDefined();
                expect(favSection!.roomIds).toEqual([]);

                // Chats should be expanded
                chatsSection = snapshot.sections.find((s) => s.id === CHATS_TAG);
                expect(chatsSection!.roomIds).toEqual(["!reg1:server"]);
            });

            it("should apply filters across all sections", () => {
                viewModel = new RoomListViewModel({ client: matrixClient });

                // Only favRoom1 is unread
                jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
                    spaceId: "home",
                    sections: [
                        { tag: DefaultTagID.Favourite, rooms: [favRoom1] },
                        { tag: CHATS_TAG, rooms: [] },
                        { tag: DefaultTagID.LowPriority, rooms: [] },
                    ],
                    filterKeys: [FilterEnum.UnreadFilter],
                });

                viewModel.onToggleFilter("unread");

                const snapshot = viewModel.getSnapshot();
                expect(snapshot.activeFilterId).toBe("unread");
                // Only the favourite section should remain (chats and low priority are empty)
                expect(snapshot.sections).toHaveLength(1);
                expect(snapshot.sections[0].id).toBe(DefaultTagID.Favourite);
                expect(snapshot.sections[0].roomIds).toEqual(["!fav1:server"]);
            });

            it("should expand collapsed sections that have results when a filter is toggled on", () => {
                viewModel = new RoomListViewModel({ client: matrixClient });

                // Collapse the favourite section
                const favHeader = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite);
                favHeader.onClick();
                expect(favHeader.isExpanded).toBe(false);

                // Toggle a filter that returns rooms in the favourite section
                jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
                    spaceId: "home",
                    sections: [
                        { tag: DefaultTagID.Favourite, rooms: [favRoom1] },
                        { tag: CHATS_TAG, rooms: [] },
                        { tag: DefaultTagID.LowPriority, rooms: [] },
                    ],
                    filterKeys: [FilterEnum.UnreadFilter],
                });
                viewModel.onToggleFilter("unread");

                // The favourite section should be expanded and its rooms visible
                expect(favHeader.isExpanded).toBe(true);
                const snapshot = viewModel.getSnapshot();
                const favSection = snapshot.sections.find((s) => s.id === DefaultTagID.Favourite);
                expect(favSection!.roomIds).toEqual(["!fav1:server"]);
            });

            describe("Collapse/expand all sections", () => {
                it("should collapse all sections when Action.RoomListCollapseAllSections is dispatched", async () => {
                    viewModel = new RoomListViewModel({ client: matrixClient });

                    const favHeader = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite);
                    const chatsHeader = viewModel.getSectionHeaderViewModel(CHATS_TAG);
                    expect(favHeader.isExpanded).toBe(true);

                    dispatcher.dispatch({ action: Action.RoomListCollapseAllSections });
                    await flushPromisesWithFakeTimers();

                    expect(favHeader.isExpanded).toBe(false);
                    expect(chatsHeader.isExpanded).toBe(false);

                    const snapshot = viewModel.getSnapshot();
                    expect(snapshot.sections.find((s) => s.id === DefaultTagID.Favourite)!.roomIds).toEqual([]);
                    expect(snapshot.sections.find((s) => s.id === CHATS_TAG)!.roomIds).toEqual([]);
                });

                it("should expand all sections when Action.RoomListExpandAllSections is dispatched", async () => {
                    viewModel = new RoomListViewModel({ client: matrixClient });

                    // Collapse first
                    const favHeader = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite);
                    favHeader.onClick();
                    expect(favHeader.isExpanded).toBe(false);

                    dispatcher.dispatch({ action: Action.RoomListExpandAllSections });
                    await flushPromisesWithFakeTimers();

                    expect(favHeader.isExpanded).toBe(true);
                    const snapshot = viewModel.getSnapshot();
                    expect(snapshot.sections.find((s) => s.id === DefaultTagID.Favourite)!.roomIds).toEqual([
                        "!fav1:server",
                        "!fav2:server",
                    ]);
                });
            });

            describe("notifyCollapseState", () => {
                it("should dispatch collapseSections=expand when all sections are expanded (default)", () => {
                    viewModel = new RoomListViewModel({ client: matrixClient });

                    const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
                    RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);

                    expect(dispatchSpy).toHaveBeenCalledWith({
                        action: Action.RoomListSectionsCollapseStateChanged,
                        collapseSections: "expand",
                    });
                });

                it("should dispatch collapseSection=collapse when all sections are collapsed", () => {
                    viewModel = new RoomListViewModel({ client: matrixClient });

                    // Collapse all sections
                    viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite).isExpanded = false;
                    viewModel.getSectionHeaderViewModel(CHATS_TAG).isExpanded = false;
                    viewModel.getSectionHeaderViewModel(DefaultTagID.LowPriority).isExpanded = false;

                    const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
                    RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);

                    expect(dispatchSpy).toHaveBeenCalledWith({
                        action: Action.RoomListSectionsCollapseStateChanged,
                        collapseSections: "collapse",
                    });
                });

                it("should dispatch collapseSection=undefined when it is a flat list", () => {
                    jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
                        spaceId: "home",
                        sections: [
                            { tag: DefaultTagID.Favourite, rooms: [] },
                            { tag: CHATS_TAG, rooms: [regularRoom1] },
                            { tag: DefaultTagID.LowPriority, rooms: [] },
                        ],
                    });
                    viewModel = new RoomListViewModel({ client: matrixClient });

                    const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
                    RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);

                    expect(dispatchSpy).toHaveBeenCalledWith({
                        action: Action.RoomListSectionsCollapseStateChanged,
                        collapseSections: undefined,
                    });
                });
            });

            it("should apply sticky room within the correct section", async () => {
                stubClient();
                viewModel = new RoomListViewModel({ client: matrixClient });

                // Select favRoom1 (index 0 globally, index 0 in favourites section)
                jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!fav1:server");
                dispatcher.dispatch({
                    action: Action.ActiveRoomChanged,
                    newRoomId: "!fav1:server",
                });
                await flushPromisesWithFakeTimers();

                expect(viewModel.getSnapshot().roomListState.activeRoomIndex).toBe(0);

                // Room list update moves favRoom1 to second position within favourites
                jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
                    spaceId: "home",
                    sections: [
                        { tag: DefaultTagID.Favourite, rooms: [favRoom2, favRoom1] },
                        { tag: CHATS_TAG, rooms: [regularRoom1, regularRoom2] },
                        { tag: DefaultTagID.LowPriority, rooms: [lowPriorityRoom] },
                    ],
                });

                RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);

                // Sticky room should keep favRoom1 at index 0 within the favourites section
                const snapshot = viewModel.getSnapshot();
                expect(snapshot.sections[0].roomIds[0]).toBe("!fav1:server");
                expect(snapshot.roomListState.activeRoomIndex).toBe(0);
            });
        });
    });
});
