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

import { ModuleObsidianEvents } from "./ModuleObsidianEvents";
import { DEFAULT_SETTINGS, REMOTE_COUCHDB } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { reactiveSource } from "octagonal-wheels/dataobject/reactive";

type SetupOptions = {
    settings?: Partial<typeof DEFAULT_SETTINGS>;
    hidden: boolean;
    isLastHidden?: boolean;
    hasFocus?: boolean;
    isSuspended?: boolean;
    // Platform is read via services.API.isMobile(); default desktop (false) so the feature applies.
    isMobile?: boolean;
};

function setup(opts: SetupOptions) {
    const appLifecycle = {
        isReady: vi.fn(() => true),
        isSuspended: vi.fn(() => opts.isSuspended ?? false),
        onSuspending: vi.fn(async () => true),
        onResuming: vi.fn(async () => true),
        onResumed: vi.fn(async () => true),
    };
    const fileProcessing = { commitPendingFileEvents: vi.fn(async () => true) };
    const boundedRemoteActivityCount = reactiveSource(0);
    const boundedLocalApplicationActivityCount = reactiveSource(0);

    const core = {
        _services: {
            API: {
                addLog: vi.fn(),
                addCommand: vi.fn(),
                registerWindow: vi.fn(),
                addRibbonIcon: vi.fn(),
                registerProtocolHandler: vi.fn(),
                isMobile: vi.fn(() => opts.isMobile ?? false),
            },
            setting: { saveSettingData: vi.fn(async () => undefined) },
            appLifecycle,
            fileProcessing,
            replicator: { boundedRemoteActivityCount, boundedLocalApplicationActivityCount },
        },
        settings: {
            ...DEFAULT_SETTINGS,
            remoteType: REMOTE_COUCHDB,
            isConfigured: true,
            ...opts.settings,
        },
    } as any;
    Object.defineProperty(core, "services", { get: () => core._services });

    const module = new ModuleObsidianEvents({} as any, core);
    module.isLastHidden = opts.isLastHidden ?? false;
    module.hasFocus = opts.hasFocus ?? true;

    // The handler reads `activeWindow.document.hidden`.
    (globalThis as any).activeWindow = { document: { hidden: opts.hidden } };

    return {
        module,
        appLifecycle,
        fileProcessing,
        boundedRemoteActivityCount,
        boundedLocalApplicationActivityCount,
    };
}

describe("watchWindowVisibilityAsync — keepReplicationActiveInBackground", () => {
    afterEach(() => {
        // The handler reads a global `activeWindow`; clear it so it doesn't leak into sibling spec
        // files running in the same worker.
        delete (globalThis as any).activeWindow;
    });

    it("does NOT suspend on hide when enabled in LiveSync mode on the desktop app", async () => {
        const { module, appLifecycle } = setup({
            settings: { keepReplicationActiveInBackground: true, liveSync: true },
            hidden: true,
        });
        await module.watchWindowVisibilityAsync();
        expect(appLifecycle.onSuspending).not.toHaveBeenCalled();
    });

    it("suspends on hide by default (setting off)", async () => {
        const { module, appLifecycle } = setup({
            settings: { keepReplicationActiveInBackground: false, liveSync: true },
            hidden: true,
        });
        await module.watchWindowVisibilityAsync();
        expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1);
    });

    it("defers desktop suspension while bounded remote activity is running", async () => {
        const { module, appLifecycle, boundedRemoteActivityCount } = setup({
            settings: { keepReplicationActiveInBackground: false, liveSync: false },
            hidden: true,
        });
        boundedRemoteActivityCount.value = 1;

        await module.watchWindowVisibilityAsync();

        expect(appLifecycle.onSuspending).not.toHaveBeenCalled();
    });

    it("suspends a hidden desktop window after the final bounded remote activity ends", async () => {
        const { module, appLifecycle, boundedRemoteActivityCount } = setup({
            settings: { keepReplicationActiveInBackground: false, liveSync: false },
            hidden: true,
        });
        boundedRemoteActivityCount.value = 1;
        await module.watchWindowVisibilityAsync();

        boundedRemoteActivityCount.value = 0;

        await vi.waitFor(() => expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1));
    });

    it("defers suspension while local document application is active", async () => {
        const { module, appLifecycle, boundedLocalApplicationActivityCount } = setup({
            settings: { keepReplicationActiveInBackground: false, liveSync: false },
            hidden: true,
        });
        boundedLocalApplicationActivityCount.value = 1;

        await module.watchWindowVisibilityAsync();

        expect(appLifecycle.onSuspending).not.toHaveBeenCalled();

        boundedLocalApplicationActivityCount.value = 0;
        await vi.waitFor(() => expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1));
    });

    it("defers mobile suspension while bounded remote activity is running", async () => {
        const { module, appLifecycle, boundedRemoteActivityCount } = setup({
            settings: { keepReplicationActiveInBackground: false, liveSync: false },
            hidden: true,
            isMobile: true,
        });
        boundedRemoteActivityCount.value = 1;

        await module.watchWindowVisibilityAsync();

        expect(appLifecycle.onSuspending).not.toHaveBeenCalled();

        boundedRemoteActivityCount.value = 0;

        await vi.waitFor(() => expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1));
    });

    it("records deferred suspension while rebuild file watching is suspended", async () => {
        const { module, appLifecycle, boundedRemoteActivityCount, fileProcessing } = setup({
            settings: { suspendFileWatching: true },
            hidden: true,
        });
        boundedRemoteActivityCount.value = 1;

        await module.watchWindowVisibilityAsync();

        expect(appLifecycle.onSuspending).not.toHaveBeenCalled();
        expect(fileProcessing.commitPendingFileEvents).not.toHaveBeenCalled();

        boundedRemoteActivityCount.value = 0;

        await vi.waitFor(() => expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1));
    });

    it("resumes after a hidden rebuild finishes and the window becomes visible", async () => {
        const { module, appLifecycle, boundedRemoteActivityCount } = setup({
            settings: { suspendFileWatching: true },
            hidden: true,
        });
        boundedRemoteActivityCount.value = 1;

        await module.watchWindowVisibilityAsync();
        boundedRemoteActivityCount.value = 0;
        await vi.waitFor(() => expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1));

        (module.settings as typeof DEFAULT_SETTINGS).suspendFileWatching = false;
        (globalThis as any).activeWindow.document.hidden = false;
        await module.watchWindowVisibilityAsync();

        expect(appLifecycle.onResuming).toHaveBeenCalledTimes(1);
        expect(appLifecycle.onResumed).toHaveBeenCalledTimes(1);
    });

    it("does not resume when the window becomes visible before deferred suspension runs", async () => {
        const { module, appLifecycle, boundedRemoteActivityCount } = setup({
            settings: { keepReplicationActiveInBackground: false, liveSync: false },
            hidden: true,
        });
        boundedRemoteActivityCount.value = 1;
        await module.watchWindowVisibilityAsync();

        (globalThis as any).activeWindow.document.hidden = false;
        await module.watchWindowVisibilityAsync();

        expect(appLifecycle.onSuspending).not.toHaveBeenCalled();
        expect(appLifecycle.onResuming).not.toHaveBeenCalled();
        expect(appLifecycle.onResumed).not.toHaveBeenCalled();

        boundedRemoteActivityCount.value = 0;
        await Promise.resolve();
        expect(appLifecycle.onSuspending).not.toHaveBeenCalled();
        expect(appLifecycle.onResuming).not.toHaveBeenCalled();
        expect(appLifecycle.onResumed).not.toHaveBeenCalled();
    });

    it("forces onSuspending before the resume on becoming visible when enabled (LiveSync teardown)", async () => {
        const { module, appLifecycle } = setup({
            settings: { keepReplicationActiveInBackground: true, liveSync: true },
            hidden: false,
            isLastHidden: true, // hidden -> visible transition
        });
        await module.watchWindowVisibilityAsync();
        // Decision-logic only: on visible + enabled + LiveSync the handler calls onSuspending (the
        // forced teardown) before onResuming. The actual stalled-channel replacement is exercised by
        // the manual integration test, not here.
        expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1);
        expect(appLifecycle.onResuming).toHaveBeenCalledTimes(1);
        expect(appLifecycle.onResumed).toHaveBeenCalledTimes(1);
        expect(appLifecycle.onSuspending.mock.invocationCallOrder[0]).toBeLessThan(
            appLifecycle.onResuming.mock.invocationCallOrder[0]
        );
    });

    it("defers the LiveSync teardown on becoming visible until bounded remote activity ends", async () => {
        const { module, appLifecycle, boundedRemoteActivityCount } = setup({
            settings: { keepReplicationActiveInBackground: true, liveSync: true },
            hidden: false,
            isLastHidden: true,
        });
        boundedRemoteActivityCount.value = 1;

        await module.watchWindowVisibilityAsync();

        expect(appLifecycle.onSuspending).not.toHaveBeenCalled();
        expect(appLifecycle.onResuming).not.toHaveBeenCalled();
        expect(appLifecycle.onResumed).not.toHaveBeenCalled();

        boundedRemoteActivityCount.value = 0;

        await vi.waitFor(() => expect(appLifecycle.onResumed).toHaveBeenCalledTimes(1));
        expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1);
        expect(appLifecycle.onResuming).toHaveBeenCalledTimes(1);
        expect(appLifecycle.onSuspending.mock.invocationCallOrder[0]).toBeLessThan(
            appLifecycle.onResuming.mock.invocationCallOrder[0]
        );
    });

    it("does not force a teardown on becoming visible by default (setting off)", async () => {
        const { module, appLifecycle } = setup({
            settings: { keepReplicationActiveInBackground: false, liveSync: true },
            hidden: false,
            isLastHidden: true,
        });
        await module.watchWindowVisibilityAsync();
        expect(appLifecycle.onSuspending).not.toHaveBeenCalled();
        expect(appLifecycle.onResumed).toHaveBeenCalledTimes(1);
    });

    it("does not apply in On-Events mode even if the flag is set (no scope leak)", async () => {
        const { module, appLifecycle } = setup({
            settings: {
                keepReplicationActiveInBackground: true,
                liveSync: false,
                periodicReplication: false,
            },
            hidden: true,
        });
        await module.watchWindowVisibilityAsync();
        expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1);
    });

    it("does NOT suspend on hide when enabled in Periodic mode (the periodic timer also stalls otherwise)", async () => {
        const { module, appLifecycle } = setup({
            settings: {
                keepReplicationActiveInBackground: true,
                liveSync: false,
                periodicReplication: true,
            },
            hidden: true,
        });
        await module.watchWindowVisibilityAsync();
        expect(appLifecycle.onSuspending).not.toHaveBeenCalled();
    });

    it("does NOT force a teardown on becoming visible in Periodic mode (only the continuous channel can stall)", async () => {
        const { module, appLifecycle } = setup({
            settings: {
                keepReplicationActiveInBackground: true,
                liveSync: false,
                periodicReplication: true,
            },
            hidden: false,
            isLastHidden: true,
        });
        await module.watchWindowVisibilityAsync();
        // The teardown is gated on liveSync: a periodic timer doesn't go half-open, so bouncing it
        // on every restore would be needless churn. Resume still runs normally.
        expect(appLifecycle.onSuspending).not.toHaveBeenCalled();
        expect(appLifecycle.onResuming).toHaveBeenCalledTimes(1);
        expect(appLifecycle.onResumed).toHaveBeenCalledTimes(1);
    });

    it("does not apply on mobile even if the flag is set", async () => {
        const { module, appLifecycle } = setup({
            settings: { keepReplicationActiveInBackground: true, liveSync: true },
            hidden: true,
            isMobile: true,
        });
        await module.watchWindowVisibilityAsync();
        expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1);
    });
});
