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

vi.mock("octagonal-wheels/number", () => ({
    sizeToHumanReadable: vi.fn((value: number) => `${value} B`),
}));
vi.mock("octagonal-wheels/concurrency/lock_v2", () => ({
    serialized: vi.fn((_key: string, task: () => unknown) => task()),
}));
vi.mock("octagonal-wheels/collection", () => ({
    arrayToChunkedArray: vi.fn((values: unknown[]) => [values]),
}));
vi.mock("@vrtmrz/livesync-commonlib/compat/common/utils", async (importOriginal) => {
    const actual = await importOriginal<typeof import("@vrtmrz/livesync-commonlib/compat/common/utils")>();
    return {
        ...actual,
        delay: vi.fn(async () => undefined),
    };
});
vi.mock("@/features/LiveSyncCommands", () => ({
    LiveSyncCommands: class LiveSyncCommands {
        core!: { settings: unknown; services: unknown };
        get settings() {
            return this.core.settings;
        }
        get services() {
            return this.core.services;
        }
    },
}));
vi.mock("@/common/events", () => ({
    EVENT_ANALYSE_DB_USAGE: "analyse",
    EVENT_REQUEST_PERFORM_GC_V3: "gc",
    eventHub: {
        onEvent: vi.fn(),
    },
}));
import {
    DEFAULT_SETTINGS,
    REMOTE_COUCHDB,
    REMOTE_MINIO,
    REMOTE_P2P,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { LocalDatabaseMaintenance } from "./CmdLocalDatabaseMainte";
import { ensureLocalDatabaseMaintenancePrerequisites } from "./maintenancePrerequisites";

function createPrerequisites(settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {}) {
    const askSelectStringDialogue = vi.fn(
        async (
            _message: string,
            _buttons: readonly ["Apply and continue", "Cancel"],
            _options: { title: string; defaultAction: "Cancel" }
        ): Promise<"Apply and continue" | "Cancel" | false | undefined> => "Apply and continue"
    );
    const applyPartial = vi.fn(async () => undefined);
    const settings = {
        ...DEFAULT_SETTINGS,
        doNotUseFixedRevisionForChunks: false,
        readChunksOnline: true,
        ...settingsOverride,
    };

    return { settings, askSelectStringDialogue, applyPartial };
}

describe("LocalDatabaseMaintenance prerequisites", () => {
    it("shows database analysis in Advanced mode and Garbage Collection only in applicable Edge Case mode", () => {
        const commands: Array<{
            id: string;
            checkCallback?: (checking: boolean) => boolean | void;
        }> = [];
        const settings: {
            useAdvancedMode: boolean;
            useEdgeCaseMode: boolean;
            remoteType: string;
        } = {
            useAdvancedMode: false,
            useEdgeCaseMode: false,
            remoteType: REMOTE_COUCHDB,
        };
        const maintenance = Object.create(LocalDatabaseMaintenance.prototype) as LocalDatabaseMaintenance;
        Object.assign(maintenance, {
            core: {
                settings,
                services: {
                    API: {
                        addCommand: vi.fn((command) => commands.push(command)),
                    },
                },
            },
            _isDatabaseReady: vi.fn(() => true),
        });

        maintenance.onload();

        const analyse = commands.find(({ id }) => id === "analyse-database");
        const garbageCollect = commands.find(({ id }) => id === "gc-v3");
        expect(analyse?.checkCallback?.(true)).toBe(false);
        expect(garbageCollect?.checkCallback?.(true)).toBe(false);

        settings.useAdvancedMode = true;
        expect(analyse?.checkCallback?.(true)).toBe(true);
        expect(garbageCollect?.checkCallback?.(true)).toBe(false);

        settings.useEdgeCaseMode = true;
        expect(garbageCollect?.checkCallback?.(true)).toBe(true);

        settings.remoteType = REMOTE_P2P;
        expect(garbageCollect?.checkCallback?.(true)).toBe(false);

        settings.remoteType = REMOTE_MINIO;
        expect(garbageCollect?.checkCallback?.(true)).toBe(false);
    });

    it("asks to disable on-demand chunk fetching before maintenance actions", async () => {
        const { settings, askSelectStringDialogue, applyPartial } = createPrerequisites();

        const result = await ensureLocalDatabaseMaintenancePrerequisites({
            operationName: "Garbage Collection",
            settings: {
                readChunksOnline: settings.readChunksOnline,
            },
            askSelectStringDialogue,
            applyPartial,
        });

        expect(result).toBe(true);
        expect(askSelectStringDialogue).toHaveBeenCalledWith(
            expect.stringContaining("Garbage Collection requires the following settings"),
            ["Apply and continue", "Cancel"],
            {
                title: "Garbage Collection prerequisites",
                defaultAction: "Cancel",
            }
        );
        expect(applyPartial).toHaveBeenCalledWith(
            {
                readChunksOnline: false,
            },
            true
        );
        expect(vi.mocked(askSelectStringDialogue).mock.calls[0]?.[0]).not.toContain("Compute revisions for chunks");
    });

    it("cancels maintenance actions when prerequisite changes are rejected", async () => {
        const { settings, askSelectStringDialogue, applyPartial } = createPrerequisites();
        askSelectStringDialogue.mockResolvedValueOnce("Cancel");

        const result = await ensureLocalDatabaseMaintenancePrerequisites({
            operationName: "Garbage Collection",
            settings: {
                readChunksOnline: settings.readChunksOnline,
            },
            askSelectStringDialogue,
            applyPartial,
        });

        expect(result).toBe(false);
        expect(applyPartial).not.toHaveBeenCalled();
    });

    it("continues without asking when prerequisite settings already match", async () => {
        const { settings, askSelectStringDialogue, applyPartial } = createPrerequisites({
            doNotUseFixedRevisionForChunks: true,
            readChunksOnline: false,
        });

        const result = await ensureLocalDatabaseMaintenancePrerequisites({
            operationName: "Garbage Collection",
            settings: {
                readChunksOnline: settings.readChunksOnline,
            },
            askSelectStringDialogue,
            applyPartial,
        });

        expect(askSelectStringDialogue).not.toHaveBeenCalled();
        expect(applyPartial).not.toHaveBeenCalled();
    });

    it("retirement guard: ignores the obsolete fixed-revision key as a maintenance prerequisite", async () => {
        const { settings, askSelectStringDialogue, applyPartial } = createPrerequisites({
            doNotUseFixedRevisionForChunks: false,
            readChunksOnline: false,
        });

        const result = await ensureLocalDatabaseMaintenancePrerequisites({
            operationName: "Garbage Collection",
            settings: {
                readChunksOnline: settings.readChunksOnline,
            },
            askSelectStringDialogue,
            applyPartial,
        });

        expect(result).toBe(true);
        expect(askSelectStringDialogue).not.toHaveBeenCalled();
        expect(applyPartial).not.toHaveBeenCalled();
    });

    it("describes the current chunk-recreation action without promising historical recovery", async () => {
        const maintenance = Object.create(LocalDatabaseMaintenance.prototype) as LocalDatabaseMaintenance;
        const askSelectStringDialogue = vi.fn().mockResolvedValue("Cancel");
        Object.assign(maintenance, {
            core: {
                confirm: {
                    askSelectStringDialogue,
                },
            },
            _log: vi.fn(),
        });
        vi.spyOn(maintenance, "ensureAvailable").mockResolvedValue(true);
        vi.spyOn(maintenance, "trackChanges").mockResolvedValue(undefined);

        await maintenance.performGC();

        const message = vi.mocked(askSelectStringDialogue).mock.calls[0]?.[0] as string;
        expect(message).toContain("Hatch -> Recreate chunks for current Vault files");
        expect(message).toContain("only from files currently present in the Vault");
        expect(message).not.toContain("Recreate missing chunks for all files");
    });
});

describe("LocalDatabaseMaintenance Garbage Collection V3", () => {
    it("does not report remote compaction as successful after its completion wait times out", async () => {
        const maintenance = Object.create(LocalDatabaseMaintenance.prototype) as LocalDatabaseMaintenance;
        const notice = vi.fn();
        const remoteDatabase = {
            compact: vi.fn(async () => ({ ok: true })),
            info: vi.fn(async () => ({ compact_running: true })),
            close: vi.fn(async () => undefined),
        };
        Object.assign(maintenance, {
            core: {
                replicator: {
                    connectRemoteCouchDBWithSetting: vi.fn(async () => ({ db: remoteDatabase })),
                },
                settings: {
                    ...DEFAULT_SETTINGS,
                    remoteType: REMOTE_COUCHDB,
                },
            },
            _notice: notice,
        });

        await maintenance.compactDatabase();

        expect(notice).toHaveBeenCalledWith("Compaction on remote database timed out.", "gc-compact");
        expect(notice).not.toHaveBeenCalledWith(
            "Compaction on remote database completed successfully.",
            "gc-compact"
        );
        expect(remoteDatabase.close).toHaveBeenCalledOnce();
    });

    it.each([
        ["no device progress entries", {}],
        [
            "an unparseable device progress entry",
            {
                "device-a": {
                    progress: "",
                    device_name: "Device A",
                    app_version: "1.12.7",
                    plugin_version: "1.0.0-beta.0",
                },
            },
        ],
        [
            "a missing device progress entry",
            {
                "device-a": {
                    device_name: "Device A",
                    app_version: "1.12.7",
                    plugin_version: "1.0.0-beta.0",
                },
            },
        ],
    ] as const)("cancels before collection when the milestone has %s", async (_case, nodeInfo) => {
        const maintenance = Object.create(LocalDatabaseMaintenance.prototype) as LocalDatabaseMaintenance;
        const pushModes: string[] = [];
        const allChunks = vi.fn(async () => ({
            used: new Set<string>(),
            existing: new Map(),
        }));
        const replicator = {
            openOneShotReplication: vi.fn(async (...args: unknown[]) => {
                pushModes.push(String(args[3]));
                return true;
            }),
            getConnectedDeviceList: vi.fn(async () => ({
                accepted_nodes: Object.keys(nodeInfo),
                node_info: nodeInfo,
            })),
        };
        const notice = vi.fn();
        Object.assign(maintenance, {
            core: {
                settings: {
                    ...DEFAULT_SETTINGS,
                    remoteType: REMOTE_COUCHDB,
                },
                replicator,
                confirm: {
                    askSelectStringDialogue: vi.fn(async () => "Proceed Garbage Collection"),
                },
            },
            localDatabase: {
                allChunks,
                localDatabase: {
                    bulkDocs: vi.fn(async () => []),
                },
            },
            _notice: notice,
        });
        vi.spyOn(maintenance, "ensureAvailable").mockResolvedValue(true);
        vi.spyOn(maintenance, "compactDatabase").mockResolvedValue(undefined);
        vi.spyOn(maintenance, "clearHash").mockImplementation(() => undefined);

        await maintenance.gcv3();

        expect(allChunks).not.toHaveBeenCalled();
        expect(pushModes).toEqual(["sync"]);
        expect(notice).toHaveBeenCalledWith(
            "No connected device information found. Cancelling Garbage Collection."
        );
    });

    it("keeps chunks referenced by a live conflict revision and deletes only unreachable chunks", async () => {
        const maintenance = Object.create(LocalDatabaseMaintenance.prototype) as LocalDatabaseMaintenance;
        const pushModes: string[] = [];
        const deletedChunks: Array<{ _id: string; _rev?: string; _deleted?: boolean }> = [];
        const allChunks = vi.fn(async () => ({
            used: new Set(["h:winner", "h:conflict"]),
            existing: new Map([
                ["h:winner", { _id: "h:winner", _rev: "1-winner", type: "leaf", data: "winner" }],
                ["h:conflict", { _id: "h:conflict", _rev: "1-conflict", type: "leaf", data: "conflict" }],
                ["h:obsolete", { _id: "h:obsolete", _rev: "1-obsolete", type: "leaf", data: "obsolete" }],
            ]),
        }));
        const rawDocuments = new Map<string, object>([
            [
                "note.md",
                {
                    _id: "note.md",
                    _rev: "2-winner",
                    _conflicts: ["2-conflict"],
                    type: "plain",
                    children: ["h:winner"],
                },
            ],
            ["h:winner", { _id: "h:winner", _rev: "1-winner", type: "leaf", data: "winner" }],
            ["h:conflict", { _id: "h:conflict", _rev: "1-conflict", type: "leaf", data: "conflict" }],
            ["h:obsolete", { _id: "h:obsolete", _rev: "1-obsolete", type: "leaf", data: "obsolete" }],
        ]);
        const findEntryNames = vi.fn(async function* () {
            yield* rawDocuments.keys();
        });
        const getRaw = vi.fn(async (id: string) => rawDocuments.get(id));
        const localDatabase = {
            allChunks,
            localDatabase: {
                info: vi.fn(async () => ({ doc_count: rawDocuments.size })),
                bulkDocs: vi.fn(async (docs: Array<{ _id: string; _rev?: string; _deleted?: boolean }>) => {
                    deletedChunks.push(...docs);
                    return docs.map(({ _id }) => ({ ok: true, id: _id, rev: "2-deleted" }));
                }),
            },
            findEntryNames,
            getRaw,
        };
        const replicator = {
            openOneShotReplication: vi.fn(
                async (
                    _settings: typeof DEFAULT_SETTINGS,
                    _showResult: boolean,
                    _ignoreCleanLock: boolean,
                    mode: string
                ) => {
                    pushModes.push(mode);
                    return true;
                }
            ),
            getConnectedDeviceList: vi.fn(async () => ({
                accepted_nodes: ["device-a"],
                node_info: {
                    "device-a": {
                        progress: "10-local",
                        device_name: "Device A",
                        app_version: "1.12.7",
                        plugin_version: "1.0.0-beta.0",
                    },
                },
            })),
        };
        Object.assign(maintenance, {
            core: {
                settings: {
                    ...DEFAULT_SETTINGS,
                    remoteType: REMOTE_COUCHDB,
                },
                replicator,
                confirm: {
                    askSelectStringDialogue: vi.fn(async () => "Proceed Garbage Collection"),
                },
            },
            localDatabase,
            _notice: vi.fn(),
        });
        vi.spyOn(maintenance, "ensureAvailable").mockResolvedValue(true);
        vi.spyOn(maintenance, "compactDatabase").mockResolvedValue(undefined);
        vi.spyOn(maintenance, "clearHash").mockImplementation(() => undefined);

        await maintenance.gcv3();

        expect(allChunks).toHaveBeenCalledOnce();
        expect(findEntryNames).not.toHaveBeenCalled();
        expect(getRaw).not.toHaveBeenCalled();
        expect(deletedChunks).toEqual([
            {
                _id: "h:obsolete",
                _rev: "1-obsolete",
                _deleted: true,
            },
        ]);
        expect(pushModes).toEqual(["sync", "pushOnly"]);
        expect(maintenance.compactDatabase).toHaveBeenCalledOnce();
    });
});
