import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { ensureWindowStub, mockPartialVue } from "./helpers/vitestEnvironment.ts";

const fetchGridPageMock = vi.hoisted(() => vi.fn());
const appendAssetsMock = vi.hoisted(() => vi.fn());

function makeStorage() {
    const data = new Map();
    return {
        getItem: vi.fn((key) => data.get(String(key)) ?? null),
        setItem: vi.fn((key, value) => data.set(String(key), String(value))),
        removeItem: vi.fn((key) => data.delete(String(key))),
        clear: vi.fn(() => data.clear()),
    };
}

mockPartialVue({
    nextTick: async () => {},
    reactive: (obj) => obj,
});

vi.mock("../api/client.js", () => ({
    get: vi.fn(),
}));

vi.mock("../app/config.js", () => ({
    APP_DEFAULTS: {
        DEFAULT_PAGE_SIZE: 100,
        MAX_PAGE_SIZE: 2000,
    },
    APP_CONFIG: {
        DEFAULT_PAGE_SIZE: 100,
        MAX_PAGE_SIZE: 2000,
        PREFETCH_NEXT_PAGE: true,
        PREFETCH_NEXT_PAGE_DELAY_MS: 700,
    },
}));

vi.mock("../app/settings.js", () => ({
    loadMajoorSettings: vi.fn(() => ({})),
}));

vi.mock("../components/Badges.js", () => ({
    setFileBadgeCollision: vi.fn(),
}));

vi.mock("../utils/ids.js", () => ({
    pickRootId: vi.fn(() => "root"),
}));

vi.mock("../features/grid/AssetCardRenderer.js", () => ({
    appendAssets: appendAssetsMock,
    rebuildAssetRendererState: vi.fn((state, assets = [], { assetKey = null, preserveHiddenCount = false } = {}) => {
        const previousHiddenCount = Number(state?.hiddenPngSiblings || 0) || 0;
        state.seenKeys = new Set();
        state.assetIdSet = new Set();
        state.filenameCounts = new Map();
        state.nonImageSiblingKeys = new Set();
        state.stemMap = new Map();
        state.renderedFilenameMap = new Map();
        state.hiddenPngSiblings = preserveHiddenCount ? previousHiddenCount : 0;
        for (const asset of Array.isArray(assets) ? assets : []) {
            if (asset?.id != null) state.assetIdSet.add(String(asset.id));
            const key = typeof assetKey === "function" ? assetKey(asset) : "";
            if (key) state.seenKeys.add(key);
        }
    }),
}));

vi.mock("../features/grid/StackGroupCards.js", () => ({
    getStackAwareAssetKey: vi.fn((_grid, _asset, fallback) => fallback),
    ensureDupStackCard: vi.fn(),
    disposeStackGroupCards: vi.fn(),
}));

vi.mock("../vue/composables/useVirtualGrid.js", async () => {
    const actual = await vi.importActual("../vue/composables/useVirtualGrid.js");
    return {
        ...actual,
        compareAssets: vi.fn(() => 0),
        fetchPage: fetchGridPageMock,
        getUpsertBatchState: vi.fn(() => null),
        queueUpsertAsset: vi.fn(() => false),
    };
});

function createVisibleElement({ dataset = {}, scrollTop = 0 } = {}) {
    return {
        dataset,
        scrollTop,
        isConnected: true,
        clientWidth: 320,
        clientHeight: 240,
        getClientRects: vi.fn(() => [{ width: 320, height: 240 }]),
    };
}

describe("useGridLoader adaptive paging", () => {
    beforeEach(() => {
        vi.resetModules();
        vi.clearAllMocks();
        vi.useRealTimers();
        globalThis.sessionStorage = makeStorage();
        globalThis.localStorage = makeStorage();
        ensureWindowStub();
    });

    afterEach(() => {
        vi.useRealTimers();
        try {
            delete (globalThis.navigator as any).deviceMemory;
        } catch {}
    });

    it("increases page size when successive pages add no visible cards", async () => {
        const { useGridLoader } = await import("../vue/composables/useGridLoader.js");

        const requestedLimits = [];
        const requestedOffsets = [];
        fetchGridPageMock.mockImplementation(async (_grid, _query, limit, offset) => {
            requestedLimits.push(limit);
            requestedOffsets.push(offset);
            return {
                ok: true,
                assets: [{ id: `${offset}-${limit}` }],
                total: 7000,
                count: limit,
                limit,
                offset,
            };
        });

        appendAssetsMock.mockReturnValueOnce(0).mockReturnValueOnce(0).mockReturnValueOnce(1);

        const state = {
            loading: false,
            done: false,
            total: 7000,
            offset: 0,
            requestId: 1,
            abortController: null,
            assets: [],
            activeId: "",
            statusMessage: "",
            statusError: false,
        };

        const gridContainer = createVisibleElement({ dataset: {} });
        const scrollElement = createVisibleElement();
        const loader = useGridLoader({
            gridContainerRef: { value: gridContainer },
            state,
            setLoadingMessage: vi.fn(),
            clearLoadingMessage: vi.fn(),
            setStatusMessage: vi.fn(),
            clearStatusMessage: vi.fn(),
            resetAssets: vi.fn(),
            setSelection: vi.fn(),
            reconcileSelection: vi.fn(),
            readScrollElement: () => scrollElement,
            readRenderedCards: () => [],
            scrollToAssetId: vi.fn(),
        });

        const result = await loader.loadNextPage();

        expect(result.ok).toBe(true);
        expect(requestedLimits).toEqual([100, 200, 400]);
        expect(requestedOffsets).toEqual([0, 100, 300]);
        expect(state.offset).toBe(700);
    });

    it("schedules another page attempt when consumed pages add no visible cards", async () => {
        vi.useFakeTimers();
        const { useGridLoader } = await import("../vue/composables/useGridLoader.js");

        const requestedOffsets = [];
        fetchGridPageMock.mockImplementation(async (_grid, _query, limit, offset) => {
            requestedOffsets.push(offset);
            return {
                ok: true,
                assets: Array.from({ length: limit }, (_, index) => ({
                    id: `${offset}-${index}`,
                })),
                total: 8000,
                count: limit,
                limit,
                offset,
            };
        });
        appendAssetsMock.mockReturnValue(0);

        const state = {
            loading: false,
            done: false,
            total: 8000,
            offset: 0,
            requestId: 1,
            abortController: null,
            query: "*",
            assets: [],
            activeId: "",
            statusMessage: "",
            statusError: false,
        };

        const loader = useGridLoader({
            gridContainerRef: { value: createVisibleElement({ dataset: {} }) },
            state,
            setLoadingMessage: vi.fn(),
            clearLoadingMessage: vi.fn(),
            setStatusMessage: vi.fn(),
            clearStatusMessage: vi.fn(),
            resetAssets: vi.fn(),
            setSelection: vi.fn(),
            reconcileSelection: vi.fn(),
            readScrollElement: () => createVisibleElement(),
            readRenderedCards: () => [],
            scrollToAssetId: vi.fn(),
        });

        const result = await loader.loadNextPage();

        expect(result).toMatchObject({ ok: true, skippedEmpty: true });
        expect(requestedOffsets).toEqual([0, 100, 300, 700, 1500, 3100]);

        await vi.advanceTimersByTimeAsync(251);
        await Promise.resolve();
        await Promise.resolve();

        expect(requestedOffsets.length).toBeGreaterThan(6);
    });

    it("skips delayed prefetch on constrained browser runtimes", async () => {
        vi.useFakeTimers();
        Object.defineProperty(globalThis.navigator, "deviceMemory", {
            configurable: true,
            value: 4,
        });
        const { useGridLoader } = await import("../vue/composables/useGridLoader.js");

        const requestedOffsets = [];
        fetchGridPageMock.mockImplementation(async (_grid, _query, limit, offset) => {
            requestedOffsets.push(offset);
            return {
                ok: true,
                assets: Array.from({ length: limit }, (_, index) => ({
                    id: `${offset}-${index}`,
                })),
                total: 8000,
                count: limit,
                limit,
                offset,
            };
        });
        appendAssetsMock.mockReturnValue(0);

        const state = {
            loading: false,
            done: false,
            total: 8000,
            offset: 0,
            requestId: 1,
            abortController: null,
            query: "*",
            assets: [],
            activeId: "",
            statusMessage: "",
            statusError: false,
        };

        const loader = useGridLoader({
            gridContainerRef: { value: createVisibleElement({ dataset: {} }) },
            state,
            setLoadingMessage: vi.fn(),
            clearLoadingMessage: vi.fn(),
            setStatusMessage: vi.fn(),
            clearStatusMessage: vi.fn(),
            resetAssets: vi.fn(),
            setSelection: vi.fn(),
            reconcileSelection: vi.fn(),
            readScrollElement: () => createVisibleElement(),
            readRenderedCards: () => [],
            scrollToAssetId: vi.fn(),
        });

        const result = await loader.loadNextPage();

        expect(result).toMatchObject({ ok: true, skippedEmpty: true });
        expect(requestedOffsets).toEqual([0, 100, 300, 700, 1500, 3100]);

        await vi.advanceTimersByTimeAsync(1000);
        await Promise.resolve();
        await Promise.resolve();

        expect(requestedOffsets).toHaveLength(6);
    });

    it("does not stop infinite scroll when default output browse returns a page-sized total", async () => {
        const { useGridLoader } = await import("../vue/composables/useGridLoader.js");

        const requestedOffsets = [];
        fetchGridPageMock.mockImplementation(async (_grid, _query, limit, offset) => {
            requestedOffsets.push(offset);
            if (offset === 0) {
                return {
                    ok: true,
                    assets: Array.from({ length: 8 }, (_, index) => ({ id: `asset-${index}` })),
                    total: 8,
                    count: 8,
                    limit,
                    offset,
                };
            }
            return {
                ok: true,
                assets: [{ id: "asset-8" }],
                total: null,
                count: 1,
                limit,
                offset,
            };
        });
        appendAssetsMock.mockImplementation((_grid, assets, state) => {
            state.assets.push(...assets);
            return assets.length;
        });

        const state = {
            loading: false,
            done: false,
            total: null,
            offset: 0,
            requestId: 1,
            abortController: null,
            query: "*",
            assets: [],
            activeId: "",
            statusMessage: "",
            statusError: false,
        };
        const loader = useGridLoader({
            gridContainerRef: {
                value: createVisibleElement({
                    dataset: {
                        mjrScope: "output",
                        mjrQuery: "*",
                        mjrSort: "mtime_desc",
                    },
                }),
            },
            state,
            setLoadingMessage: vi.fn(),
            clearLoadingMessage: vi.fn(),
            setStatusMessage: vi.fn(),
            clearStatusMessage: vi.fn(),
            resetAssets: vi.fn(),
            setSelection: vi.fn(),
            reconcileSelection: vi.fn(),
            readScrollElement: () => createVisibleElement(),
            readRenderedCards: () => [],
            scrollToAssetId: vi.fn(),
        });

        await loader.loadNextPage();

        expect(state.offset).toBe(8);
        expect(state.total).toBeNull();
        expect(state.done).toBe(false);

        await loader.loadNextPage();

        expect(requestedOffsets).toEqual([0, 8]);
        expect(state.assets).toHaveLength(9);
    });

    it("keeps append paging from resetting scroll or selection", async () => {
        const { useGridLoader } = await import("../vue/composables/useGridLoader.js");

        fetchGridPageMock.mockResolvedValue({
            ok: true,
            assets: [{ id: "asset-2", filename: "two.png" }],
            total: 20,
            count: 1,
            limit: 100,
            offset: 1,
        });
        appendAssetsMock.mockImplementation((_grid, assets, state) => {
            state.assets.push(...assets);
            return assets.length;
        });

        const state = {
            loading: false,
            done: false,
            total: 20,
            offset: 1,
            requestId: 1,
            abortController: null,
            query: "*",
            assets: [{ id: "asset-1", filename: "one.png" }],
            selectedIds: ["asset-10"],
            activeId: "asset-10",
            selectionAnchorId: "asset-10",
            statusMessage: "",
            statusError: false,
        };
        const scrollElement = createVisibleElement({ scrollTop: 420 });
        scrollElement.scrollHeight = 2000;
        const resetAssets = vi.fn();
        const reconcileSelection = vi.fn();
        const setSelection = vi.fn((ids, activeId) => {
            state.selectedIds = ids;
            state.activeId = activeId;
        });
        const loader = useGridLoader({
            gridContainerRef: { value: createVisibleElement({ dataset: {} }) },
            state,
            setLoadingMessage: vi.fn(),
            clearLoadingMessage: vi.fn(),
            setStatusMessage: vi.fn(),
            clearStatusMessage: vi.fn(),
            resetAssets,
            setSelection,
            reconcileSelection,
            readScrollElement: () => scrollElement,
            readRenderedCards: () => [],
            scrollToAssetId: vi.fn(),
        });

        const result = await loader.appendNextPage();

        expect(result.ok).toBe(true);
        expect(resetAssets).not.toHaveBeenCalled();
        expect(reconcileSelection).not.toHaveBeenCalled();
        expect(scrollElement.scrollTop).toBe(420);
        expect(state.selectedIds).toEqual(["asset-10"]);
        expect(state.activeId).toBe("asset-10");
    });

    it("does not force the old scroll position back when the user scrolls during pagination", async () => {
        const { useGridLoader } = await import("../vue/composables/useGridLoader.js");

        let resolvePage;
        fetchGridPageMock.mockImplementation(
            () =>
                new Promise((resolve) => {
                    resolvePage = resolve;
                }),
        );
        appendAssetsMock.mockImplementation((_grid, assets, state) => {
            state.assets.push(...assets);
            return assets.length;
        });

        const state = {
            loading: false,
            done: false,
            total: 20,
            offset: 1,
            requestId: 1,
            abortController: null,
            query: "*",
            assets: [{ id: "asset-1", filename: "one.png" }],
            selectedIds: [],
            activeId: "",
            statusMessage: "",
            statusError: false,
        };
        const scrollElement = new EventTarget();
        scrollElement.scrollTop = 420;
        scrollElement.scrollHeight = 2000;
        scrollElement.clientHeight = 500;
        scrollElement.clientWidth = 320;
        scrollElement.isConnected = true;
        scrollElement.getClientRects = vi.fn(() => [{ width: 320, height: 500 }]);

        const loader = useGridLoader({
            gridContainerRef: { value: createVisibleElement({ dataset: {} }) },
            state,
            setLoadingMessage: vi.fn(),
            clearLoadingMessage: vi.fn(),
            setStatusMessage: vi.fn(),
            clearStatusMessage: vi.fn(),
            resetAssets: vi.fn(),
            setSelection: vi.fn(),
            reconcileSelection: vi.fn(),
            readScrollElement: () => scrollElement,
            readRenderedCards: () => [],
            scrollToAssetId: vi.fn(),
        });

        const pending = loader.appendNextPage();
        scrollElement.scrollTop = 900;
        scrollElement.dispatchEvent(new Event("scroll"));
        resolvePage({
            ok: true,
            assets: [{ id: "asset-2", filename: "two.png" }],
            total: 20,
            count: 1,
            limit: 100,
            offset: 1,
        });
        await pending;

        expect(scrollElement.scrollTop).toBe(900);
    });

    it("does not prune unloaded selections after a partial reload", async () => {
        const { useGridLoader } = await import("../vue/composables/useGridLoader.js");

        fetchGridPageMock.mockResolvedValue({
            ok: true,
            assets: [{ id: "asset-1", filename: "one.png" }],
            total: 500,
            count: 1,
            limit: 100,
            offset: 0,
        });
        appendAssetsMock.mockImplementation((_grid, assets, state) => {
            state.assets.push(...assets);
            return assets.length;
        });

        const state = {
            loading: false,
            done: false,
            total: null,
            offset: 0,
            requestId: 1,
            abortController: null,
            query: "*",
            assets: [],
            selectedIds: ["asset-40"],
            activeId: "asset-40",
            statusMessage: "",
            statusError: false,
        };
        const resetAssets = vi.fn(({ query = "*", total = null, done = false } = {}) => {
            state.query = query;
            state.total = total;
            state.done = done;
            state.offset = 0;
            state.assets = [];
        });
        const reconcileSelection = vi.fn();
        const loader = useGridLoader({
            gridContainerRef: { value: createVisibleElement({ dataset: {} }) },
            state,
            setLoadingMessage: vi.fn(),
            clearLoadingMessage: vi.fn(),
            setStatusMessage: vi.fn(),
            clearStatusMessage: vi.fn(),
            resetAssets,
            setSelection: vi.fn(),
            reconcileSelection,
            readScrollElement: () => createVisibleElement(),
            readRenderedCards: () => [],
            scrollToAssetId: vi.fn(),
        });

        await loader.reload("*", { preserveVisibleUntilReady: false });

        expect(reconcileSelection).not.toHaveBeenCalled();
        expect(state.selectedIds).toEqual(["asset-40"]);
        expect(state.activeId).toBe("asset-40");
    });

    it("exposes canonical grid state and debug metrics", async () => {
        const { useGridLoader } = await import("../vue/composables/useGridLoader.js");

        const state = {
            loading: false,
            done: false,
            total: 2,
            offset: 1,
            requestId: 7,
            abortController: null,
            query: "*",
            assets: [{ id: "asset-1", filename: "one.png" }],
            selectedIds: ["asset-1"],
            activeId: "asset-1",
            selectionAnchorId: "asset-1",
            statusMessage: "",
            statusError: false,
        };
        const gridContainer = createVisibleElement({
            dataset: {
                mjrScope: "output",
                mjrQuery: "*",
                mjrSort: "mtime_desc",
                mjrShown: "1",
            },
        });
        const scrollElement = createVisibleElement({ scrollTop: 64 });
        scrollElement.scrollHeight = 512;

        const loader = useGridLoader({
            gridContainerRef: { value: gridContainer },
            state,
            setLoadingMessage: vi.fn(),
            clearLoadingMessage: vi.fn(),
            setStatusMessage: vi.fn(),
            clearStatusMessage: vi.fn(),
            resetAssets: vi.fn(),
            setSelection: vi.fn(),
            reconcileSelection: vi.fn(),
            readScrollElement: () => scrollElement,
            readRenderedCards: () => [],
            scrollToAssetId: vi.fn(),
        });

        expect(loader.getCanonicalState()).toMatchObject({
            pagination: {
                query: "*",
                offset: 1,
                total: 2,
                loading: false,
                done: false,
                requestId: 7,
            },
            selection: {
                selectedIds: ["asset-1"],
                activeId: "asset-1",
                anchorId: "asset-1",
            },
            viewport: {
                scrollTop: 64,
            },
            context: {
                scope: "output",
                query: "*",
                sort: "mtime_desc",
            },
        });
        expect(loader.getDebugSnapshot()).toMatchObject({
            counts: {
                loaded: 1,
                visible: 1,
                total: 2,
            },
            metrics: {
                pagesRequested: 0,
                resetCount: 0,
            },
        });
    });

    it("preserves inline query syntax for advanced search tokens", async () => {
        const { useGridLoader } = await import("../vue/composables/useGridLoader.js");

        fetchGridPageMock.mockResolvedValue({
            ok: true,
            assets: [],
            total: 0,
            count: 0,
            limit: 100,
            offset: 0,
        });
        appendAssetsMock.mockReturnValue(0);

        const state = {
            loading: false,
            done: false,
            total: 0,
            offset: 0,
            requestId: 1,
            abortController: null,
            query: "*",
            assets: [],
            activeId: "",
            statusMessage: "",
            statusError: false,
        };

        const loader = useGridLoader({
            gridContainerRef: { value: createVisibleElement({ dataset: {} }) },
            state,
            setLoadingMessage: vi.fn(),
            clearLoadingMessage: vi.fn(),
            setStatusMessage: vi.fn(),
            clearStatusMessage: vi.fn(),
            resetAssets: vi.fn(({ query = "*", total = null, done = false } = {}) => {
                state.query = query;
                state.total = total;
                state.done = done;
                state.offset = 0;
                state.assets = [];
            }),
            setSelection: vi.fn(),
            reconcileSelection: vi.fn(),
            readScrollElement: () => createVisibleElement(),
            readRenderedCards: () => [],
            scrollToAssetId: vi.fn(),
        });

        const query = 'ext:png rating:5 "speed cinematic push-in"';
        await loader.loadAssets(query, { preserveVisibleUntilReady: false });

        expect(state.query).toBe(query);
        expect(fetchGridPageMock).toHaveBeenCalledWith(
            expect.anything(),
            query,
            expect.any(Number),
            0,
            expect.any(Object),
            expect.any(Object),
        );
    });

    it("does not hydrate persisted grid snapshots while the snapshot cache is disabled", async () => {
        appendAssetsMock.mockImplementation((_grid, assets, state) => {
            state.assets = Array.isArray(assets) ? assets.map((asset) => ({ ...asset })) : [];
            return state.assets.length;
        });

        let module = await import("../vue/composables/useGridLoader.js");
        const gridContainer = createVisibleElement({
            dataset: {
                mjrScope: "output",
                mjrQuery: "*",
                mjrSort: "mtime_desc",
            },
        });
        const scrollElement = createVisibleElement();
        const state1 = {
            loading: false,
            done: false,
            total: 50,
            offset: 2,
            requestId: 1,
            abortController: null,
            query: "*",
            assets: [
                { id: 1, filename: "one.png", kind: "image", source: "output" },
                { id: 2, filename: "two.png", kind: "image", source: "output" },
            ],
            activeId: "",
            statusMessage: "",
            statusError: false,
        };

        const loader1 = module.useGridLoader({
            gridContainerRef: { value: gridContainer },
            state: state1,
            setLoadingMessage: vi.fn(),
            clearLoadingMessage: vi.fn(),
            setStatusMessage: vi.fn(),
            clearStatusMessage: vi.fn(),
            resetAssets: vi.fn(),
            setSelection: vi.fn(),
            reconcileSelection: vi.fn(),
            readScrollElement: () => scrollElement,
            readRenderedCards: () => [],
            scrollToAssetId: vi.fn(),
        });
        loader1.dispose();

        vi.resetModules();
        module = await import("../vue/composables/useGridLoader.js");

        const state2 = {
            loading: false,
            done: false,
            total: null,
            offset: 0,
            requestId: 1,
            abortController: null,
            assets: [],
            activeId: "",
            statusMessage: "",
            statusError: false,
        };
        const resetAssets = vi.fn(({ query = "*", total = null, done = false } = {}) => {
            state2.query = query;
            state2.total = total;
            state2.done = done;
            state2.assets = [];
        });
        const loader2 = module.useGridLoader({
            gridContainerRef: { value: gridContainer },
            state: state2,
            setLoadingMessage: vi.fn(),
            clearLoadingMessage: vi.fn(),
            setStatusMessage: vi.fn(),
            clearStatusMessage: vi.fn(),
            resetAssets,
            setSelection: vi.fn(),
            reconcileSelection: vi.fn(),
            readScrollElement: () => scrollElement,
            readRenderedCards: () => [],
            scrollToAssetId: vi.fn(),
        });

        const restored = await loader2.hydrateFromSnapshot({
            scope: "output",
            query: "*",
            sort: "mtime_desc",
        });

        expect(restored).toBe(false);
        expect(state2.assets).toEqual([]);
        expect(state2.offset).toBe(0);
        expect(state2.total).toBeNull();
        expect(state2.done).toBe(false);
    });

    it("pages through an 8000+ asset library across repeated scroll loads", async () => {
        const { useGridLoader } = await import("../vue/composables/useGridLoader.js");

        const total = 8200;
        const requestedOffsets = [];
        fetchGridPageMock.mockImplementation(async (_grid, _query, limit, offset) => {
            requestedOffsets.push(offset);
            const end = Math.min(total, offset + limit);
            return {
                ok: true,
                assets: Array.from({ length: Math.max(0, end - offset) }, (_, index) => ({
                    id: `asset-${offset + index + 1}`,
                })),
                total,
                count: end - offset,
                limit,
                offset,
            };
        });
        appendAssetsMock.mockImplementation((_grid, assets, state) => {
            const list = Array.isArray(assets) ? assets : [];
            state.assets = [...(Array.isArray(state.assets) ? state.assets : []), ...list];
            return list.length;
        });

        const state = {
            loading: false,
            done: false,
            total,
            offset: 0,
            requestId: 1,
            abortController: null,
            query: "*",
            assets: [],
            activeId: "",
            statusMessage: "",
            statusError: false,
        };

        const loader = useGridLoader({
            gridContainerRef: { value: createVisibleElement({ dataset: {} }) },
            state,
            setLoadingMessage: vi.fn(),
            clearLoadingMessage: vi.fn(),
            setStatusMessage: vi.fn(),
            clearStatusMessage: vi.fn(),
            resetAssets: vi.fn(),
            setSelection: vi.fn(),
            reconcileSelection: vi.fn(),
            readScrollElement: () => createVisibleElement(),
            readRenderedCards: () => [],
            scrollToAssetId: vi.fn(),
        });

        for (let guard = 0; guard < 100 && !state.done; guard += 1) {
            const result = await loader.loadNextPage();
            expect(result.ok).toBe(true);
        }

        expect(state.done).toBe(true);
        expect(state.offset).toBe(total);
        expect(state.assets).toHaveLength(total);
        expect(requestedOffsets.length).toBeGreaterThan(10);
        expect(requestedOffsets.at(-1)).toBe(8200 - 100);
    });

    it("does not replace existing assets from snapshots while the snapshot cache is disabled", async () => {
        appendAssetsMock.mockImplementation((_grid, assets, state) => {
            state.assets = Array.isArray(assets) ? assets.map((asset) => ({ ...asset })) : [];
            return state.assets.length;
        });

        let module = await import("../vue/composables/useGridLoader.js");
        const gridContainer = createVisibleElement({
            dataset: {
                mjrScope: "output",
                mjrQuery: "*",
                mjrSort: "mtime_desc",
            },
        });
        const scrollElement = createVisibleElement();
        const snapshotSeedState = {
            loading: false,
            done: false,
            total: 2,
            offset: 2,
            requestId: 1,
            abortController: null,
            query: "*",
            assets: [
                { id: 1, filename: "out-one.png", kind: "image", source: "output" },
                { id: 2, filename: "out-two.png", kind: "image", source: "output" },
            ],
            activeId: "",
            statusMessage: "",
            statusError: false,
        };

        const seedLoader = module.useGridLoader({
            gridContainerRef: { value: gridContainer },
            state: snapshotSeedState,
            setLoadingMessage: vi.fn(),
            clearLoadingMessage: vi.fn(),
            setStatusMessage: vi.fn(),
            clearStatusMessage: vi.fn(),
            resetAssets: vi.fn(),
            setSelection: vi.fn(),
            reconcileSelection: vi.fn(),
            readScrollElement: () => scrollElement,
            readRenderedCards: () => [],
            scrollToAssetId: vi.fn(),
        });
        seedLoader.dispose();

        vi.resetModules();
        module = await import("../vue/composables/useGridLoader.js");
        appendAssetsMock.mockImplementation((_grid, assets, state) => {
            state.assets = Array.isArray(assets) ? assets.map((asset) => ({ ...asset })) : [];
            return state.assets.length;
        });

        const inputGridContainer = createVisibleElement({
            dataset: {
                mjrScope: "output",
                mjrQuery: "*",
                mjrSort: "mtime_desc",
            },
        });
        const state = {
            loading: false,
            done: false,
            total: 1,
            offset: 1,
            requestId: 1,
            abortController: null,
            query: "*",
            assets: [{ id: 9, filename: "input-one.png", kind: "image", source: "input" }],
            activeId: "",
            statusMessage: "",
            statusError: false,
        };
        const resetAssets = vi.fn(({ query = "*", total = null, done = false } = {}) => {
            state.query = query;
            state.total = total;
            state.done = done;
            state.offset = 0;
            state.assets = [];
        });
        const loader = module.useGridLoader({
            gridContainerRef: { value: inputGridContainer },
            state,
            setLoadingMessage: vi.fn(),
            clearLoadingMessage: vi.fn(),
            setStatusMessage: vi.fn(),
            clearStatusMessage: vi.fn(),
            resetAssets,
            setSelection: vi.fn(),
            reconcileSelection: vi.fn(),
            readScrollElement: () => scrollElement,
            readRenderedCards: () => [],
            scrollToAssetId: vi.fn(),
        });

        const restored = await loader.hydrateFromSnapshot(
            { scope: "output", query: "*", sort: "mtime_desc" },
            { allowReplaceExisting: true },
        );

        expect(restored).toBe(false);
        expect(state.assets.map((asset) => asset.filename)).toEqual(["input-one.png"]);
    });

    it("keeps scope-switch assets visible until the replacement page arrives", async () => {
        const { useGridLoader } = await import("../vue/composables/useGridLoader.js");

        let resolvePage;
        fetchGridPageMock.mockImplementation(
            () =>
                new Promise((resolve) => {
                    resolvePage = resolve;
                }),
        );
        appendAssetsMock.mockImplementation((_grid, assets, state) => {
            state.assets = Array.isArray(assets) ? assets.map((asset) => ({ ...asset })) : [];
            return state.assets.length;
        });

        const state = {
            loading: false,
            done: false,
            total: 1,
            offset: 1,
            requestId: 1,
            abortController: new AbortController(),
            query: "*",
            assets: [{ id: "input-1", filename: "input-one.png", kind: "image", source: "input" }],
            activeId: "",
            statusMessage: "",
            statusError: false,
        };
        const gridContainer = createVisibleElement({
            dataset: {
                mjrScope: "output",
                mjrQuery: "*",
                mjrSort: "mtime_desc",
            },
        });
        const scrollElement = createVisibleElement();
        const resetAssets = vi.fn(({ query = "*", total = null, done = false } = {}) => {
            state.query = query;
            state.total = total;
            state.done = done;
            state.offset = 0;
            state.assets = [];
        });

        const loader = useGridLoader({
            gridContainerRef: { value: gridContainer },
            state,
            setLoadingMessage: vi.fn(),
            clearLoadingMessage: vi.fn(),
            setStatusMessage: vi.fn(),
            clearStatusMessage: vi.fn(),
            resetAssets,
            setSelection: vi.fn(),
            reconcileSelection: vi.fn(),
            readScrollElement: () => scrollElement,
            readRenderedCards: () => [],
            scrollToAssetId: vi.fn(),
        });

        const previousController = state.abortController;
        loader.prepareGridForScopeSwitch();
        expect(resetAssets).not.toHaveBeenCalled();
        expect(state.assets.map((asset) => asset.filename)).toEqual(["input-one.png"]);
        expect(previousController.signal.aborted).toBe(true);
        expect(state.abortController).toBeNull();
        expect(state.loading).toBe(true);

        const pendingLoad = loader.loadAssets("*", { reset: true });

        expect(resetAssets).not.toHaveBeenCalled();
        expect(state.assets.map((asset) => asset.filename)).toEqual(["input-one.png"]);
        expect(fetchGridPageMock.mock.calls.at(-1)?.[3]).toBe(0);

        resolvePage({
            ok: true,
            assets: [
                { id: "output-1", filename: "output-one.png", kind: "image", source: "output" },
            ],
            total: 1,
            count: 1,
            limit: 100,
            offset: 0,
        });
        await pendingLoad;

        expect(resetAssets).toHaveBeenCalledTimes(1);
        expect(state.assets.map((asset) => asset.filename)).toEqual(["output-one.png"]);
    });

    it("starts deferred visual resets from offset zero instead of the preserved asset count", async () => {
        const { useGridLoader } = await import("../vue/composables/useGridLoader.js");

        const requestedOffsets = [];
        fetchGridPageMock.mockImplementation(async (_grid, _query, _limit, offset) => {
            requestedOffsets.push(offset);
            return {
                ok: true,
                assets: [
                    { id: "new-1", filename: "new-head.png", kind: "image", source: "output" },
                ],
                total: null,
                count: 1,
                limit: 100,
                offset,
            };
        });
        appendAssetsMock.mockImplementation((_grid, assets, state) => {
            state.assets.push(...assets.map((asset) => ({ ...asset })));
            return assets.length;
        });

        const state = {
            loading: false,
            done: false,
            total: null,
            offset: 200,
            requestId: 1,
            abortController: null,
            query: "old-search",
            assets: Array.from({ length: 200 }, (_, index) => ({
                id: `old-${index}`,
                filename: `old-${index}.png`,
                kind: "image",
                source: "output",
            })),
            activeId: "",
            statusMessage: "",
            statusError: false,
        };
        const gridContainer = createVisibleElement({
            dataset: {
                mjrScope: "output",
                mjrQuery: "*",
                mjrSort: "mtime_desc",
            },
        });
        const scrollElement = createVisibleElement();
        const resetAssets = vi.fn(({ query = "*", total = null, done = false } = {}) => {
            state.query = query;
            state.total = total;
            state.done = done;
            state.offset = 0;
            state.assets = [];
        });

        const loader = useGridLoader({
            gridContainerRef: { value: gridContainer },
            state,
            setLoadingMessage: vi.fn(),
            clearLoadingMessage: vi.fn(),
            setStatusMessage: vi.fn(),
            clearStatusMessage: vi.fn(),
            resetAssets,
            setSelection: vi.fn(),
            reconcileSelection: vi.fn(),
            readScrollElement: () => scrollElement,
            readRenderedCards: () => [],
            scrollToAssetId: vi.fn(),
        });

        await loader.loadAssets("*", { reset: true, preserveVisibleUntilReady: true });

        expect(requestedOffsets).toEqual([0]);
        expect(resetAssets).toHaveBeenCalledTimes(1);
        expect(state.assets.map((asset) => asset.filename)).toEqual(["new-head.png"]);
        expect(state.offset).toBe(1);
    });

    it("does not let an aborted viewport fill skip the scope reload", async () => {
        const { useGridLoader } = await import("../vue/composables/useGridLoader.js");

        fetchGridPageMock.mockResolvedValue({
            ok: true,
            assets: [
                { id: "output-1", filename: "output-one.png", kind: "image", source: "output" },
            ],
            total: 1,
            count: 1,
            limit: 100,
            offset: 0,
        });
        appendAssetsMock.mockImplementation((_grid, assets, state) => {
            state.assets = Array.isArray(assets) ? assets.map((asset) => ({ ...asset })) : [];
            return state.assets.length;
        });

        const state = {
            loading: true,
            done: false,
            total: null,
            offset: 0,
            requestId: 5,
            abortController: new AbortController(),
            query: "*",
            assets: [],
            activeId: "",
            statusMessage: "",
            statusError: false,
        };
        const gridContainer = createVisibleElement({
            dataset: {
                mjrScope: "output",
                mjrQuery: "*",
                mjrSort: "mtime_desc",
            },
        });
        const resetAssets = vi.fn(({ query = "*", total = null, done = false } = {}) => {
            state.query = query;
            state.total = total;
            state.done = done;
            state.offset = 0;
            state.assets = [];
        });

        const loader = useGridLoader({
            gridContainerRef: { value: gridContainer },
            state,
            setLoadingMessage: vi.fn(),
            clearLoadingMessage: vi.fn(),
            setStatusMessage: vi.fn(),
            clearStatusMessage: vi.fn(),
            resetAssets,
            setSelection: vi.fn(),
            reconcileSelection: vi.fn(),
            readScrollElement: () => createVisibleElement(),
            readRenderedCards: () => [],
            scrollToAssetId: vi.fn(),
        });

        await loader.loadAssets("*", { reset: true });

        expect(fetchGridPageMock).toHaveBeenCalledTimes(1);
        expect(fetchGridPageMock.mock.calls[0]?.[3]).toBe(0);
        expect(state.assets.map((asset) => asset.filename)).toEqual(["output-one.png"]);
    });

    it("does not allow background refresh reasons to clear a populated grid immediately", async () => {
        const { useGridLoader } = await import("../vue/composables/useGridLoader.js");

        let resolvePage;
        fetchGridPageMock.mockImplementation(
            () =>
                new Promise((resolve) => {
                    resolvePage = resolve;
                }),
        );
        appendAssetsMock.mockImplementation((_grid, assets, state) => {
            state.assets = Array.isArray(assets) ? assets.map((asset) => ({ ...asset })) : [];
            return state.assets.length;
        });

        const state = {
            loading: false,
            done: false,
            total: 1,
            offset: 1,
            requestId: 1,
            abortController: null,
            query: "*",
            assets: [{ id: "old-1", filename: "old.png", kind: "image" }],
            activeId: "",
            statusMessage: "",
            statusError: false,
            _mjrLastGridContext: {
                scope: "output",
                query: "*",
                customRootId: "",
                subfolder: "",
                collectionId: "",
                viewScope: "",
                kind: "",
                workflowOnly: false,
                minRating: "",
                minSizeMB: "",
                maxSizeMB: "",
                resolutionCompare: "",
                minWidth: "",
                minHeight: "",
                maxWidth: "",
                maxHeight: "",
                workflowType: "",
                dateRange: "",
                dateExact: "",
                sort: "mtime_desc",
                semanticMode: false,
            },
        };
        const gridContainer = createVisibleElement({
            dataset: {
                mjrScope: "output",
                mjrQuery: "*",
                mjrSort: "mtime_desc",
            },
        });
        const resetAssets = vi.fn(({ query = "*", total = null, done = false } = {}) => {
            state.query = query;
            state.total = total;
            state.done = done;
            state.offset = 0;
            state.assets = [];
        });

        const loader = useGridLoader({
            gridContainerRef: { value: gridContainer },
            state,
            setLoadingMessage: vi.fn(),
            clearLoadingMessage: vi.fn(),
            setStatusMessage: vi.fn(),
            clearStatusMessage: vi.fn(),
            resetAssets,
            setSelection: vi.fn(),
            reconcileSelection: vi.fn(),
            readScrollElement: () => createVisibleElement(),
            readRenderedCards: () => [],
            scrollToAssetId: vi.fn(),
        });

        const pendingLoad = loader.loadAssets("*", {
            reset: true,
            preserveVisibleUntilReady: false,
            reason: "scan",
        });

        expect(resetAssets).not.toHaveBeenCalled();
        expect(state.assets.map((asset) => asset.filename)).toEqual(["old.png"]);

        resolvePage({
            ok: true,
            assets: [{ id: "new-1", filename: "new.png", kind: "image" }],
            total: 1,
            count: 1,
            limit: 100,
            offset: 0,
        });
        await pendingLoad;

        expect(resetAssets).toHaveBeenCalledTimes(1);
        expect(loader.getDebugSnapshot().metrics).toMatchObject({
            blockedImmediateResetCount: 1,
            lastResetReason: "scan",
        });
        expect(state.assets.map((asset) => asset.filename)).toEqual(["new.png"]);
    });

    it("allows immediate reset when sort/filter/search scope context changes", async () => {
        const { useGridLoader } = await import("../vue/composables/useGridLoader.js");

        let resolvePage;
        fetchGridPageMock.mockImplementation(
            () =>
                new Promise((resolve) => {
                    resolvePage = resolve;
                }),
        );
        appendAssetsMock.mockImplementation((_grid, assets, state) => {
            state.assets = Array.isArray(assets) ? assets.map((asset) => ({ ...asset })) : [];
            return state.assets.length;
        });

        const state = {
            loading: false,
            done: false,
            total: 1,
            offset: 1,
            requestId: 1,
            abortController: null,
            query: "*",
            assets: [{ id: "old-1", filename: "old.png", kind: "image" }],
            activeId: "",
            statusMessage: "",
            statusError: false,
            _mjrLastGridContext: {
                scope: "output",
                query: "*",
                customRootId: "",
                subfolder: "",
                collectionId: "",
                viewScope: "",
                kind: "",
                workflowOnly: false,
                sort: "mtime_desc",
                semanticMode: false,
            },
        };
        const gridContainer = createVisibleElement({
            dataset: {
                mjrScope: "output",
                mjrQuery: "*",
                mjrSort: "name_asc",
            },
        });
        const resetAssets = vi.fn(({ query = "*", total = null, done = false } = {}) => {
            state.query = query;
            state.total = total;
            state.done = done;
            state.offset = 0;
            state.assets = [];
        });

        const loader = useGridLoader({
            gridContainerRef: { value: gridContainer },
            state,
            setLoadingMessage: vi.fn(),
            clearLoadingMessage: vi.fn(),
            setStatusMessage: vi.fn(),
            clearStatusMessage: vi.fn(),
            resetAssets,
            setSelection: vi.fn(),
            reconcileSelection: vi.fn(),
            readScrollElement: () => createVisibleElement(),
            readRenderedCards: () => [],
            scrollToAssetId: vi.fn(),
        });

        const pendingLoad = loader.loadAssets("*", {
            reset: true,
            preserveVisibleUntilReady: false,
        });

        expect(resetAssets).toHaveBeenCalledTimes(1);
        expect(loader.getDebugSnapshot().metrics.lastResetReason).toBe("sort");

        resolvePage({
            ok: true,
            assets: [{ id: "new-1", filename: "new.png", kind: "image" }],
            total: 1,
            count: 1,
            limit: 100,
            offset: 0,
        });
        await pendingLoad;

        expect(resetAssets).toHaveBeenCalledTimes(1);
        expect(state.assets.map((asset) => asset.filename)).toEqual(["new.png"]);
    });

    it("skips next-page loading while the grid host is hidden", async () => {
        const { useGridLoader } = await import("../vue/composables/useGridLoader.js");

        const state = {
            loading: false,
            done: false,
            total: 7000,
            offset: 0,
            requestId: 1,
            abortController: null,
            assets: [],
            activeId: "",
            statusMessage: "",
            statusError: false,
        };
        const gridContainer = {
            dataset: {},
            isConnected: false,
            getClientRects: vi.fn(() => []),
        };
        const scrollElement = {
            clientWidth: 320,
            clientHeight: 240,
            isConnected: false,
            getClientRects: vi.fn(() => []),
        };

        const loader = useGridLoader({
            gridContainerRef: { value: gridContainer },
            state,
            setLoadingMessage: vi.fn(),
            clearLoadingMessage: vi.fn(),
            setStatusMessage: vi.fn(),
            clearStatusMessage: vi.fn(),
            resetAssets: vi.fn(),
            setSelection: vi.fn(),
            reconcileSelection: vi.fn(),
            readScrollElement: () => scrollElement,
            readRenderedCards: () => [],
            scrollToAssetId: vi.fn(),
        });

        const result = await loader.loadNextPage();

        expect(result).toMatchObject({ ok: true, skipped: true, hidden: true });
        expect(fetchGridPageMock).not.toHaveBeenCalled();
    });

    it("continues loading when only the scroll wrapper has a visible rect", async () => {
        const { useGridLoader } = await import("../vue/composables/useGridLoader.js");

        fetchGridPageMock.mockResolvedValue({
            ok: true,
            assets: [{ id: "asset-1" }],
            total: 1,
            count: 1,
            limit: 100,
            offset: 0,
        });
        appendAssetsMock.mockReturnValue(1);

        const state = {
            loading: false,
            done: false,
            total: 1,
            offset: 0,
            requestId: 1,
            abortController: null,
            assets: [],
            activeId: "",
            statusMessage: "",
            statusError: false,
        };
        const gridContainer = {
            dataset: {},
            isConnected: true,
            clientWidth: 0,
            clientHeight: 0,
            getClientRects: vi.fn(() => []),
        };
        const scrollElement = createVisibleElement();

        const loader = useGridLoader({
            gridContainerRef: { value: gridContainer },
            state,
            setLoadingMessage: vi.fn(),
            clearLoadingMessage: vi.fn(),
            setStatusMessage: vi.fn(),
            clearStatusMessage: vi.fn(),
            resetAssets: vi.fn(),
            setSelection: vi.fn(),
            reconcileSelection: vi.fn(),
            readScrollElement: () => scrollElement,
            readRenderedCards: () => [],
            scrollToAssetId: vi.fn(),
        });

        const result = await loader.loadNextPage();

        expect(result).toMatchObject({ ok: true, count: 1, total: 1 });
        expect(fetchGridPageMock).toHaveBeenCalledTimes(1);
    });
});
