// Scheduling for the background context.
//
// The Chrome MV3 service worker is terminated after roughly 30 seconds idle,
// so anything scheduled with setInterval/setTimeout dies with it. These tests
// pin the recurring jobs to the alarms API and to the re-registration path a
// revived worker runs.

// A controllable clock plus a stubbed balance refresh, so a cadence test can
// measure the interval between refreshes that actually happened rather than
// asserting the interval someone intended.
const { makeStorageStub } = require("./support/storageStub");

let mockNow = 0;
const mockBalanceRefreshAt = [];

// jest.resetModules() clears the call record of every jest.fn, and loading the
// worker is exactly that call — so anything that must be counted across a load
// is counted here rather than read off a mock.
let mockSetIntervalCalls = 0;

// Extension storage reads do not take a constant amount of time, and that is
// what makes a guard timed to the alarm period bite: backgroundRefresh()
// stamps its freshness marker after awaiting loadState(), so any read that is
// quicker than the previous one puts the next tick inside a guard of exactly
// one period and the tick is skipped. A simulation with a constant latency
// would sit exactly on the boundary and hide the bug.
const MOCK_STORAGE_LATENCIES_MS = [7, 3, 11, 2, 9, 4, 13, 1, 6, 5];
const MOCK_MAX_STORAGE_LATENCY_MS = Math.max(...MOCK_STORAGE_LATENCIES_MS);
let mockStorageJitter = false;
let mockStorageOpCount = 0;

function mockStorageTick() {
    if (!mockStorageJitter) return;
    mockNow +=
        MOCK_STORAGE_LATENCIES_MS[
            mockStorageOpCount++ % MOCK_STORAGE_LATENCIES_MS.length
        ];
}

jest.mock("../src/shared/balances", () => ({
    refreshBalances: jest.fn(async () => {
        mockBalanceRefreshAt.push(Date.now());
    }),
    getProvider: jest.fn(() => ({})),
}));

function makeAlarmsStub() {
    const alarms = new Map();
    const listeners = [];
    const stub = {
        created: [],
        alarms,
        create: jest.fn((name, info) => {
            stub.created.push({ name, info });
            alarms.set(name, { name, ...info });
        }),
        get: jest.fn(async (name) => alarms.get(name)),
        clear: jest.fn(async (name) => alarms.delete(name)),
        onAlarm: {
            addListener: jest.fn((fn) => listeners.push(fn)),
        },
        fire: (name) => {
            for (const fn of listeners) fn({ name });
        },
        listenerCount: () => listeners.length,
    };
    return stub;
}

describe("alarms module", () => {
    let alarmsStub;
    let alarmsMod;

    beforeEach(() => {
        jest.resetModules();
        alarmsStub = makeAlarmsStub();
        global.chrome = { alarms: alarmsStub };
        alarmsMod = require("../src/shared/alarms");
    });

    afterEach(() => {
        delete global.chrome;
    });

    test("ensureRecurringAlarms schedules the recurring job", async () => {
        const created = await alarmsMod.ensureRecurringAlarms();
        expect(created).toEqual({ balance: true, cleared: [] });

        const names = alarmsStub.created.map((c) => c.name);
        expect(names).toEqual([alarmsMod.BALANCE_REFRESH_ALARM]);
    });

    test("the balance refresh keeps its 60-second cadence", async () => {
        await alarmsMod.ensureRecurringAlarms();
        const balance = alarmsStub.alarms.get(alarmsMod.BALANCE_REFRESH_ALARM);
        expect(balance.periodInMinutes).toBe(1);
    });

    test("a retired job's alarm is cleared, not left running", async () => {
        // The browser holds an alarm until something clears it. Deleting the
        // job from the code is not enough: on every install that ever ran the
        // version which created it, the alarm goes on waking the service
        // worker on its old schedule with nothing to deliver it to.
        for (const name of alarmsMod.OBSOLETE_ALARMS) {
            alarmsStub.create(name, { periodInMinutes: 24 * 60 });
        }
        expect(alarmsMod.OBSOLETE_ALARMS.length).toBeGreaterThan(0);

        const result = await alarmsMod.ensureRecurringAlarms();
        expect(result.cleared).toEqual(alarmsMod.OBSOLETE_ALARMS);
        for (const name of alarmsMod.OBSOLETE_ALARMS) {
            expect(alarmsStub.alarms.get(name)).toBeUndefined();
        }
    });

    test("clearing a retired alarm is not re-reported once it is gone", async () => {
        await alarmsMod.ensureRecurringAlarms();
        const again = await alarmsMod.ensureRecurringAlarms();
        expect(again.cleared).toEqual([]);
    });

    test("no retired name is also a live one", async () => {
        // A name in both lists would be created and then cleared on every
        // start, so the job it schedules would never fire.
        expect(alarmsMod.OBSOLETE_ALARMS).not.toContain(
            alarmsMod.BALANCE_REFRESH_ALARM,
        );
    });

    test("no period is below the browser-enforced minimum", async () => {
        // A period under one minute is silently clamped by the browser, so a
        // request for one would mean the documented cadence is not the real
        // one. Every period must be a whole minute at or above the minimum.
        await alarmsMod.ensureRecurringAlarms();
        for (const { info } of alarmsStub.created) {
            expect(info.periodInMinutes).toBeGreaterThanOrEqual(
                alarmsMod.MIN_ALARM_PERIOD_MINUTES,
            );
            expect(Number.isInteger(info.periodInMinutes)).toBe(true);
        }
    });

    test("a revived worker does not reset an existing alarm's schedule", async () => {
        await alarmsMod.ensureRecurringAlarms();
        expect(alarmsStub.create).toHaveBeenCalledTimes(1);

        // Every wake re-runs the startup path. Re-creating an alarm restarts
        // its period, so a busy extension would push the next fire out
        // forever and the job would never run.
        const again = await alarmsMod.ensureRecurringAlarms();
        expect(again).toEqual({ balance: false, cleared: [] });
        expect(alarmsStub.create).toHaveBeenCalledTimes(1);
    });

    test("a missing alarm is re-created on the next start", async () => {
        await alarmsMod.ensureRecurringAlarms();
        await alarmsStub.clear(alarmsMod.BALANCE_REFRESH_ALARM);

        const again = await alarmsMod.ensureRecurringAlarms();
        expect(again).toEqual({ balance: true, cleared: [] });
        expect(
            alarmsStub.alarms.get(alarmsMod.BALANCE_REFRESH_ALARM),
        ).toBeDefined();
    });

    test("an alarm left over with a stale period is re-created", async () => {
        // An install carries its alarms across an extension update, so a
        // period changed in a new release only ever reaches users if the
        // stale one is reconciled.
        alarmsStub.create(alarmsMod.BALANCE_REFRESH_ALARM, {
            periodInMinutes: 7 * 24 * 60,
        });
        alarmsStub.create.mockClear();

        const created = await alarmsMod.ensureRecurringAlarms();
        expect(created.balance).toBe(true);
        expect(
            alarmsStub.alarms.get(alarmsMod.BALANCE_REFRESH_ALARM)
                .periodInMinutes,
        ).toBe(alarmsMod.BALANCE_REFRESH_PERIOD_MINUTES);
    });

    test("reconciling a period settles instead of re-creating forever", async () => {
        alarmsStub.create(alarmsMod.BALANCE_REFRESH_ALARM, {
            periodInMinutes: 30,
        });
        await alarmsMod.ensureRecurringAlarms();
        alarmsStub.create.mockClear();

        const again = await alarmsMod.ensureRecurringAlarms();
        expect(again).toEqual({ balance: false, cleared: [] });
        expect(alarmsStub.create).not.toHaveBeenCalled();
    });

    test("handlers are dispatched by alarm name from one listener", () => {
        const balance = jest.fn();
        const other = jest.fn();
        expect(
            alarmsMod.registerAlarmHandlers({
                [alarmsMod.BALANCE_REFRESH_ALARM]: balance,
                "autistmask-some-other-job": other,
            }),
        ).toBe(true);
        expect(alarmsStub.listenerCount()).toBe(1);

        alarmsStub.fire(alarmsMod.BALANCE_REFRESH_ALARM);
        expect(balance).toHaveBeenCalledTimes(1);
        expect(other).not.toHaveBeenCalled();

        alarmsStub.fire("autistmask-some-other-job");
        expect(other).toHaveBeenCalledTimes(1);

        alarmsStub.fire("an-alarm-with-no-handler");
        expect(balance).toHaveBeenCalledTimes(1);
        expect(other).toHaveBeenCalledTimes(1);
    });

    test("Firefox MV2 gets the same treatment via browser.alarms", async () => {
        // Both targets are built from one bundle. MV2 has a persistent
        // background page, but it takes the alarm path too, so the schedule
        // is the same code on both browsers.
        jest.resetModules();
        const firefoxAlarms = makeAlarmsStub();
        global.browser = { alarms: firefoxAlarms };
        try {
            const mod = require("../src/shared/alarms");
            const created = await mod.ensureRecurringAlarms();
            expect(created).toEqual({ balance: true, cleared: [] });
            expect(firefoxAlarms.created).toHaveLength(1);
            // The Chrome stub must not have been touched.
            expect(alarmsStub.create).not.toHaveBeenCalled();
        } finally {
            delete global.browser;
        }
    });

    test("a context without the alarms API degrades instead of throwing", async () => {
        jest.resetModules();
        delete global.chrome;
        const mod = require("../src/shared/alarms");
        await expect(mod.ensureRecurringAlarms()).resolves.toEqual({
            balance: false,
            cleared: [],
        });
        expect(mod.registerAlarmHandlers({})).toBe(false);
    });
});

// Loads the background worker against stubbed browser APIs. The returned
// store is the extension storage the worker sees, so a test can seed wallet
// state and read back what the worker persisted.
// The stub clones in both directions, as the real chrome.storage.local does,
// and carries the latency simulation above on every operation. It used to
// alias, which for this file meant the worker's in-memory wallets and the
// "stored" ones were one object — see tests/support/storageStub.js.
function loadBackground(initialStore = {}) {
    const storage = makeStorageStub(initialStore, mockStorageTick);
    const alarmsStub = makeAlarmsStub();
    const listeners = { onInstalled: [], onStartup: [] };
    global.chrome = {
        alarms: alarmsStub,
        storage,
        runtime: {
            onMessage: { addListener: jest.fn() },
            onConnect: { addListener: jest.fn() },
            onInstalled: {
                addListener: jest.fn((fn) => listeners.onInstalled.push(fn)),
            },
            onStartup: {
                addListener: jest.fn((fn) => listeners.onStartup.push(fn)),
            },
            getURL: (p) => "chrome-extension://test/" + p,
            lastError: null,
        },
        windows: {
            onRemoved: { addListener: jest.fn() },
            create: jest.fn(),
        },
        tabs: { query: jest.fn(), sendMessage: jest.fn() },
        action: { setPopup: jest.fn() },
    };
    // Present so that a startup path which went to the network would be
    // recorded rather than throwing, which is what makes "no request was made"
    // an observation instead of an assumption.
    global.fetch = jest.fn(async () => ({
        ok: true,
        json: async () => ({}),
    }));
    jest.resetModules();
    require("../src/background/index");
    return { alarmsStub, listeners, storage };
}

// Flush the promise chains the startup path and the alarm handlers run on.
async function settle() {
    for (let i = 0; i < 3; i++) {
        await new Promise((resolve) => setImmediate(resolve));
    }
}

describe("background worker scheduling", () => {
    let alarmsStub;
    let timers;

    beforeEach(() => {
        mockSetIntervalCalls = 0;
        timers = {
            setInterval: jest
                .spyOn(global, "setInterval")
                .mockImplementation(() => {
                    mockSetIntervalCalls++;
                    return 0;
                }),
        };
    });

    afterEach(() => {
        timers.setInterval.mockRestore();
        delete global.chrome;
        delete global.fetch;
        jest.resetModules();
    });

    test("startup schedules the recurring jobs as alarms, not timers", async () => {
        alarmsStub = loadBackground().alarmsStub;
        // Let the startup path's promises settle.
        await settle();

        const names = alarmsStub.created.map((c) => c.name);
        const { BALANCE_REFRESH_ALARM } = require("../src/shared/alarms");
        expect(names).toEqual([BALANCE_REFRESH_ALARM]);
        expect(mockSetIntervalCalls).toBe(0);
    });

    test("startup contacts nothing", async () => {
        // The phishing blocklist is vendored at build time and there is no
        // other startup fetch, so a worker coming up asks nobody anything.
        // Every wake used to be a candidate for a blocklist download.
        loadBackground();
        await settle();
        expect(global.fetch).not.toHaveBeenCalled();
    });

    test("an onAlarm listener is installed on startup", async () => {
        alarmsStub = loadBackground().alarmsStub;
        await settle();
        expect(alarmsStub.listenerCount()).toBe(1);
    });

    test("onInstalled and onStartup both re-establish the schedule", async () => {
        const loaded = loadBackground();
        alarmsStub = loaded.alarmsStub;
        await settle();

        expect(loaded.listeners.onInstalled).toHaveLength(1);
        expect(loaded.listeners.onStartup).toHaveLength(1);

        // A browser start after the alarms were dropped must put them back.
        alarmsStub.alarms.clear();
        alarmsStub.created.length = 0;
        loaded.listeners.onStartup[0]();
        await settle();
        expect(alarmsStub.created).toHaveLength(1);
    });

    test("the install-time listener and the top-level call share one run", async () => {
        // On a fresh install both fire, close enough that both could observe
        // an alarm missing and create it — and a second create restarts the
        // period the first one just set.
        const loaded = loadBackground();
        alarmsStub = loaded.alarmsStub;
        loaded.listeners.onInstalled[0]();
        await settle();

        expect(alarmsStub.created).toHaveLength(1);
        expect(alarmsStub.created.map((c) => c.name)).toEqual([
            "autistmask-balance-refresh",
        ]);
    });
});

// The alarm period alone must set the cadence. A freshness guard timed to the
// period vetoes the very tick it gates, because the guard is measured from
// when the last run finished and the alarm fires one run-duration before that.
// These tests measure the interval between refreshes that actually ran.
describe("balance refresh steady-state cadence", () => {
    const {
        BALANCE_REFRESH_PERIOD_MINUTES,
        BALANCE_REFRESH_ALARM,
    } = require("../src/shared/alarms");
    const PERIOD_MS = BALANCE_REFRESH_PERIOD_MINUTES * 60 * 1000;

    let clockSpy;
    let timerSpy;

    function seededStore() {
        return {
            autistmask: {
                hasWallet: true,
                // A whole wallet record, not a bare address: a stored profile
                // is validated against the schema on every read now
                // (src/shared/stateSchema.js), and a wallet with no address
                // list is one of the shapes that refuses to load.
                wallets: [
                    {
                        name: "Wallet 1",
                        type: "hd",
                        addresses: [
                            {
                                address:
                                    "0x0000000000000000000000000000000000000001",
                                balance: "0",
                                tokenBalances: [],
                            },
                        ],
                    },
                ],
                lastBalanceRefresh: 0,
            },
        };
    }

    beforeEach(() => {
        mockNow = Date.UTC(2026, 0, 1, 0, 0, 0);
        mockBalanceRefreshAt.length = 0;
        mockSetIntervalCalls = 0;
        mockStorageOpCount = 0;
        mockStorageJitter = false;
        clockSpy = jest.spyOn(Date, "now").mockImplementation(() => mockNow);
        timerSpy = jest.spyOn(global, "setInterval").mockImplementation(() => {
            mockSetIntervalCalls++;
            return 0;
        });
    });

    afterEach(() => {
        mockStorageJitter = false;
        clockSpy.mockRestore();
        timerSpy.mockRestore();
        delete global.chrome;
        delete global.fetch;
        jest.resetModules();
    });

    test("ten alarm ticks produce ten refreshes, one per period", async () => {
        const { alarmsStub } = loadBackground(seededStore());
        await settle();
        mockStorageJitter = true;

        const TICKS = 10;
        let tickAt = mockNow + PERIOD_MS;
        for (let i = 0; i < TICKS; i++) {
            mockNow = tickAt;
            tickAt += PERIOD_MS;
            alarmsStub.fire(BALANCE_REFRESH_ALARM);
            await settle();
        }

        // No tick was a no-op. This is the assertion that fails when the guard
        // is timed to the alarm period.
        expect(mockBalanceRefreshAt).toHaveLength(TICKS);

        // And the observed cadence is one period, not two.
        const intervals = mockBalanceRefreshAt
            .slice(1)
            .map((t, i) => t - mockBalanceRefreshAt[i]);
        for (const interval of intervals) {
            expect(interval).toBeGreaterThanOrEqual(
                PERIOD_MS - MOCK_MAX_STORAGE_LATENCY_MS,
            );
            expect(interval).toBeLessThanOrEqual(
                PERIOD_MS + MOCK_MAX_STORAGE_LATENCY_MS,
            );
        }
    });

    test("a refresh an open popup just did still suppresses the tick", async () => {
        // The guard's actual job, and the reason it is shortened rather than
        // removed: while the popup is open it refreshes every 10 seconds and
        // stamps the same field, and the background job has nothing to add.
        const { alarmsStub, storage } = loadBackground(seededStore());
        await settle();

        mockNow += PERIOD_MS;
        // As the open popup's own refresh would leave it: written to storage,
        // not poked into an object the worker happens to share.
        storage.write("autistmask", {
            ...storage.read("autistmask"),
            lastBalanceRefresh: mockNow - 10 * 1000,
        });
        alarmsStub.fire(BALANCE_REFRESH_ALARM);
        await settle();

        expect(mockBalanceRefreshAt).toHaveLength(0);
    });
});
