// Extension storage stub for the Node test environment. The module resolves
// the storage API on use, so this only has to exist before the first call.
// Values round-trip through JSON the way structured cloning would, so a test
// cannot pass by holding a live reference to the module's own array.
const storageStore = {};
global.chrome = {
    storage: {
        local: {
            get: async (key) =>
                Object.prototype.hasOwnProperty.call(storageStore, key)
                    ? { [key]: JSON.parse(JSON.stringify(storageStore[key])) }
                    : {},
            set: async (items) => {
                for (const [key, value] of Object.entries(items)) {
                    storageStore[key] = JSON.parse(JSON.stringify(value));
                }
            },
            remove: async (key) => {
                delete storageStore[key];
            },
        },
    },
};

const {
    isPhishingDomain,
    loadConfig,
    getBlocklistSize,
    getDeltaSize,
    hostnameVariants,
    DELTA_STORAGE_KEY,
    _reset,
    _getVendoredBlacklistSize,
    _getDeltaBlacklist,
} = require("../src/shared/phishingDomains");

function clearStorage() {
    for (const key of Object.keys(storageStore)) {
        delete storageStore[key];
    }
}

// The MV3 service worker is torn down when idle and re-evaluated on the next
// event, which wipes every module-level variable. Re-requiring the module with
// the registry reset is exactly that: fresh in-memory state, same extension
// storage underneath.
function restartWorker() {
    jest.resetModules();
    return require("../src/shared/phishingDomains");
}

// Reset delta state before each test to avoid cross-test contamination.
// Note: vendored sets are immutable and always present.
beforeEach(() => {
    _reset();
    clearStorage();
});

describe("phishingDomains", () => {
    describe("vendored blocklist", () => {
        test("vendored blacklist is loaded from bundled JSON", () => {
            // The vendored blocklist should have a large number of entries
            expect(_getVendoredBlacklistSize()).toBeGreaterThan(100000);
        });

        test("detects domains from vendored blacklist", () => {
            // These are well-known phishing domains in the vendored list
            expect(isPhishingDomain("hopprotocol.pro")).toBe(true);
            expect(isPhishingDomain("blast-pools.pages.dev")).toBe(true);
        });

        test("getBlocklistSize includes vendored entries", () => {
            expect(getBlocklistSize()).toBeGreaterThan(100000);
        });
    });

    describe("hostnameVariants", () => {
        test("returns exact hostname plus parent domains", () => {
            const variants = hostnameVariants("sub.evil.com");
            expect(variants).toEqual(["sub.evil.com", "evil.com"]);
        });

        test("returns just the hostname for a bare domain", () => {
            const variants = hostnameVariants("example.com");
            expect(variants).toEqual(["example.com"]);
        });

        test("handles deep subdomain chains", () => {
            const variants = hostnameVariants("a.b.c.d.com");
            expect(variants).toEqual([
                "a.b.c.d.com",
                "b.c.d.com",
                "c.d.com",
                "d.com",
            ]);
        });

        test("lowercases hostnames", () => {
            const variants = hostnameVariants("Evil.COM");
            expect(variants).toEqual(["evil.com"]);
        });
    });

    describe("delta computation via loadConfig", () => {
        test("loadConfig computes delta of new entries not in vendored list", () => {
            loadConfig({
                blacklist: [
                    "brand-new-scam-site-xyz123.com",
                    "hopprotocol.pro", // already in vendored
                ],
            });
            // Only the new domain should be in the delta
            expect(
                _getDeltaBlacklist().has("brand-new-scam-site-xyz123.com"),
            ).toBe(true);
            expect(_getDeltaBlacklist().has("hopprotocol.pro")).toBe(false);
            expect(getDeltaSize()).toBe(1);
        });

        test("re-loading config replaces previous delta", () => {
            loadConfig({
                blacklist: ["first-scam-xyz.com"],
            });
            expect(isPhishingDomain("first-scam-xyz.com")).toBe(true);

            loadConfig({
                blacklist: ["second-scam-xyz.com"],
            });
            expect(isPhishingDomain("first-scam-xyz.com")).toBe(false);
            expect(isPhishingDomain("second-scam-xyz.com")).toBe(true);
        });

        test("getBlocklistSize includes both vendored and delta", () => {
            const baseSize = getBlocklistSize();
            loadConfig({
                blacklist: ["delta-only-scam-xyz.com"],
            });
            expect(getBlocklistSize()).toBe(baseSize + 1);
        });
    });

    describe("isPhishingDomain with delta + vendored", () => {
        test("detects domain from delta blacklist", () => {
            loadConfig({
                blacklist: ["fresh-scam-xyz.com"],
            });
            expect(isPhishingDomain("fresh-scam-xyz.com")).toBe(true);
        });

        test("detects domain from vendored blacklist", () => {
            // No delta loaded — vendored still works
            expect(isPhishingDomain("hopprotocol.pro")).toBe(true);
        });

        test("returns false for clean domains", () => {
            expect(isPhishingDomain("etherscan.io")).toBe(false);
            expect(isPhishingDomain("example.com")).toBe(false);
        });

        test("detects subdomain of blacklisted domain (vendored)", () => {
            expect(isPhishingDomain("app.hopprotocol.pro")).toBe(true);
        });

        test("detects subdomain of blacklisted domain (delta)", () => {
            loadConfig({
                blacklist: ["delta-phish-xyz.com"],
            });
            expect(isPhishingDomain("sub.delta-phish-xyz.com")).toBe(true);
        });

        test("case-insensitive matching", () => {
            loadConfig({
                blacklist: ["Delta-Scam-XYZ.COM"],
            });
            expect(isPhishingDomain("delta-scam-xyz.com")).toBe(true);
            expect(isPhishingDomain("DELTA-SCAM-XYZ.COM")).toBe(true);
        });

        test("returns false for empty/null hostname", () => {
            expect(isPhishingDomain("")).toBe(false);
            expect(isPhishingDomain(null)).toBe(false);
        });

        test("handles config with no blacklist key", () => {
            loadConfig({});
            expect(getDeltaSize()).toBe(0);
            // Vendored list still works
            expect(isPhishingDomain("hopprotocol.pro")).toBe(true);
        });
    });

    describe("extension storage persistence", () => {
        test("delta is persisted to extension storage, not localStorage", async () => {
            await loadConfig({
                blacklist: ["persisted-scam-xyz.com"],
            });
            const stored = storageStore[DELTA_STORAGE_KEY];
            expect(stored).toBeDefined();
            expect(stored.blacklist).toContain("persisted-scam-xyz.com");
        });

        test("the fetch timestamp is persisted alongside the delta", async () => {
            const before = Date.now();
            await loadConfig({ blacklist: ["timestamped-scam-xyz.com"] });
            const stored = storageStore[DELTA_STORAGE_KEY];
            expect(typeof stored.lastFetchTime).toBe("number");
            expect(stored.lastFetchTime).toBeGreaterThanOrEqual(before);
        });

        test("an oversized delta is dropped entirely, timestamp included", async () => {
            // A record above the 256 KiB cap is not worth keeping; the
            // timestamp goes with it so the next start re-fetches rather than
            // claiming freshness for a delta that was never stored.
            const huge = [];
            for (let i = 0; i < 20000; i++) {
                huge.push(`oversize-scam-${i}-xyzxyzxyzxyzxyz.com`);
            }
            await loadConfig({ blacklist: huge });
            expect(storageStore[DELTA_STORAGE_KEY]).toBeUndefined();
        });

        test("delta is cleared on _reset", () => {
            loadConfig({
                blacklist: ["temp-scam-xyz.com"],
            });
            expect(getDeltaSize()).toBe(1);
            _reset();
            expect(getDeltaSize()).toBe(0);
        });
    });

    describe("real-world blocklist patterns", () => {
        test("detects known phishing domains from vendored list", () => {
            expect(isPhishingDomain("uniswap-trade.web.app")).toBe(true);
            expect(isPhishingDomain("hopprotocol.pro")).toBe(true);
            expect(isPhishingDomain("blast-pools.pages.dev")).toBe(true);
        });

        test("does not flag legitimate domains", () => {
            expect(isPhishingDomain("opensea.io")).toBe(false);
            expect(isPhishingDomain("etherscan.io")).toBe(false);
        });
    });
});

describe("phishing list across a service worker restart", () => {
    beforeEach(() => {
        clearStorage();
        jest.resetModules();
    });

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

    test("a revived worker restores the persisted delta without re-fetching", async () => {
        const first = require("../src/shared/phishingDomains");
        await first.loadConfig({ blacklist: ["restart-scam-xyz.com"] });

        const revived = restartWorker();
        // Nothing in memory yet — this is a brand new module instance.
        expect(revived.getDeltaSize()).toBe(0);

        global.fetch = jest.fn();
        await revived.initPhishingList();

        expect(global.fetch).not.toHaveBeenCalled();
        expect(revived.getDeltaSize()).toBe(1);
        expect(revived.isPhishingDomain("restart-scam-xyz.com")).toBe(true);
    });

    test("repeated wakes inside the cache window never re-fetch", async () => {
        const first = require("../src/shared/phishingDomains");
        await first.loadConfig({ blacklist: ["no-storm-scam-xyz.com"] });

        global.fetch = jest.fn();
        for (let i = 0; i < 5; i++) {
            const revived = restartWorker();
            await revived.initPhishingList();
        }
        expect(global.fetch).not.toHaveBeenCalled();
    });

    test("a persisted timestamp older than the TTL causes a fetch on startup", async () => {
        const first = require("../src/shared/phishingDomains");
        await first.loadConfig({ blacklist: ["stale-scam-xyz.com"] });

        // Age the persisted record past the 24-hour TTL.
        storageStore[first.DELTA_STORAGE_KEY].lastFetchTime =
            Date.now() - first.CACHE_TTL_MS - 1000;

        const revived = restartWorker();
        global.fetch = jest.fn(async () => ({
            ok: true,
            json: async () => ({ blacklist: ["refreshed-scam-xyz.com"] }),
        }));
        await revived.initPhishingList();

        expect(global.fetch).toHaveBeenCalledTimes(1);
        expect(revived.isPhishingDomain("refreshed-scam-xyz.com")).toBe(true);
        expect(revived.isPhishingDomain("stale-scam-xyz.com")).toBe(false);
    });

    test("a first start with nothing persisted fetches immediately", async () => {
        const fresh = restartWorker();
        global.fetch = jest.fn(async () => ({
            ok: true,
            json: async () => ({ blacklist: ["first-run-scam-xyz.com"] }),
        }));
        await fresh.initPhishingList();

        expect(global.fetch).toHaveBeenCalledTimes(1);
        expect(fresh.isPhishingDomain("first-run-scam-xyz.com")).toBe(true);
    });

    test("updatePhishingList honours the persisted timestamp on its own", async () => {
        // The startup path calls updatePhishingList() directly, so it must
        // load persisted state itself rather than relying on anything else
        // having finished first.
        const first = require("../src/shared/phishingDomains");
        await first.loadConfig({ blacklist: ["alarm-tick-scam-xyz.com"] });

        const revived = restartWorker();
        global.fetch = jest.fn();
        await revived.updatePhishingList();

        expect(global.fetch).not.toHaveBeenCalled();
        expect(revived.isPhishingDomain("alarm-tick-scam-xyz.com")).toBe(true);
    });
});

// The alarm period alone must set the cadence. lastFetchTime is stamped when
// the fetch completes, so it lands one fetch latency after the alarm that
// caused it; a freshness guard timed to the alarm period therefore vetoes
// every scheduled tick and halves the real refresh rate. These tests measure
// the interval between fetches that actually happened.
describe("phishing refresh steady-state cadence", () => {
    const { PHISHING_REFRESH_PERIOD_MINUTES } = require("../src/shared/alarms");
    const PERIOD_MS = PHISHING_REFRESH_PERIOD_MINUTES * 60 * 1000;

    let clockSpy;
    let now;

    beforeEach(() => {
        clearStorage();
        jest.resetModules();
        now = Date.UTC(2026, 0, 1, 0, 0, 0);
        clockSpy = jest.spyOn(Date, "now").mockImplementation(() => now);
    });

    afterEach(() => {
        clockSpy.mockRestore();
        delete global.fetch;
    });

    function fetchStub(latencyMs, seen) {
        return jest.fn(async () => {
            seen.push(now);
            // A network fetch takes time, and lastFetchTime is stamped after
            // it, not when the alarm fired.
            now += latencyMs;
            return { ok: true, json: async () => ({ blacklist: [] }) };
        });
    }

    test("ten alarm ticks produce ten fetches, one per period", async () => {
        const fetchedAt = [];
        global.fetch = fetchStub(5000, fetchedAt);

        const startup = require("../src/shared/phishingDomains");
        const T0 = now;
        await startup.initPhishingList();
        expect(fetchedAt).toEqual([T0]);

        const TICKS = 10;
        let tickAt = T0 + PERIOD_MS;
        for (let i = 0; i < TICKS; i++) {
            now = tickAt;
            tickAt += PERIOD_MS;
            // The browser wakes a terminated worker to deliver the alarm, so
            // every tick starts from cold memory and the persisted record.
            const revived = restartWorker();
            await revived.refreshPhishingListOnSchedule();
        }

        expect(fetchedAt).toHaveLength(TICKS + 1);
        const intervals = fetchedAt.slice(1).map((t, i) => t - fetchedAt[i]);
        expect(intervals).toEqual(new Array(TICKS).fill(PERIOD_MS));
    });

    test("the scheduled tick fetches whatever the last fetch's latency was", async () => {
        // The alarm fires one period after the previous alarm, which is
        // `latency` short of one period since the fetch it caused completed.
        for (const latency of [200, 1000, 5000]) {
            clearStorage();
            jest.resetModules();
            storageStore[DELTA_STORAGE_KEY] = {
                blacklist: [],
                lastFetchTime: now - PERIOD_MS + latency,
                lastAttemptTime: now - PERIOD_MS,
            };
            const mod = require("../src/shared/phishingDomains");
            const fetchedAt = [];
            global.fetch = fetchStub(latency, fetchedAt);

            await mod.refreshPhishingListOnSchedule();
            expect(fetchedAt).toHaveLength(1);
        }
    });

    test("a worker wake inside the cache window still does not fetch", async () => {
        // The TTL is not removed, only taken off the scheduled path. Chrome
        // revives the worker every ~30 seconds and every revival runs the
        // startup path, so the TTL still has to keep that off the network.
        storageStore[DELTA_STORAGE_KEY] = {
            blacklist: [],
            lastFetchTime: now - PERIOD_MS + 5000,
            lastAttemptTime: now - PERIOD_MS,
        };
        const mod = require("../src/shared/phishingDomains");
        global.fetch = jest.fn();

        await mod.initPhishingList();
        expect(global.fetch).not.toHaveBeenCalled();
    });
});

describe("phishing list timestamps that cannot be trusted", () => {
    let clockSpy;
    let now;

    beforeEach(() => {
        clearStorage();
        jest.resetModules();
        now = Date.UTC(2026, 0, 1, 0, 0, 0);
        clockSpy = jest.spyOn(Date, "now").mockImplementation(() => now);
    });

    afterEach(() => {
        clockSpy.mockRestore();
        delete global.fetch;
    });

    function okFetch() {
        return jest.fn(async () => ({
            ok: true,
            json: async () => ({ blacklist: ["recovered-scam-xyz.com"] }),
        }));
    }

    // jest.resetModules() clears the call record of a jest.fn, and simulating
    // a worker restart is exactly that call. Anything counted across restarts
    // has to be counted outside the mock.
    function countingFetch(counter, response) {
        return async () => {
            counter.calls++;
            return response();
        };
    }

    test("a lastFetchTime in the future is discarded rather than trusted", async () => {
        // Clock skew or a restored profile backup writes one. Every guard
        // measures `Date.now() - stamp` and only tests the lower bound, so a
        // stamp a year ahead would suppress updates for a year, and now that
        // the value is persisted it would outlive every worker.
        storageStore[DELTA_STORAGE_KEY] = {
            blacklist: ["poisoned-scam-xyz.com"],
            lastFetchTime: now + 365 * 24 * 60 * 60 * 1000,
            lastAttemptTime: 0,
        };
        const mod = require("../src/shared/phishingDomains");
        global.fetch = okFetch();

        await mod.initPhishingList();

        expect(global.fetch).toHaveBeenCalledTimes(1);
        expect(mod.isPhishingDomain("recovered-scam-xyz.com")).toBe(true);
        // And the record it leaves behind is sane, so recovery is permanent.
        expect(
            storageStore[DELTA_STORAGE_KEY].lastFetchTime,
        ).toBeLessThanOrEqual(now);
    });

    test("a lastAttemptTime in the future does not suppress the retry", async () => {
        storageStore[DELTA_STORAGE_KEY] = {
            lastAttemptTime: now + 365 * 24 * 60 * 60 * 1000,
        };
        const mod = require("../src/shared/phishingDomains");
        global.fetch = okFetch();

        await mod.initPhishingList();
        expect(global.fetch).toHaveBeenCalledTimes(1);
    });

    test("an oversized delta does not re-download on every worker wake", async () => {
        // The delta and its freshness claim are both dropped, which is right,
        // but nothing then says a fetch just happened. Chrome cycles the
        // worker roughly every 30 seconds idle, so without the attempt stamp
        // this is a full blocklist download per wake, forever.
        const huge = [];
        for (let i = 0; i < 20000; i++) {
            huge.push(`oversize-scam-${i}-xyzxyzxyzxyzxyz.com`);
        }
        const counter = { calls: 0 };
        global.fetch = countingFetch(counter, () => ({
            ok: true,
            json: async () => ({ blacklist: huge }),
        }));

        for (let wake = 0; wake < 4; wake++) {
            const revived = restartWorker();
            await revived.initPhishingList();
            now += 30 * 1000; // idle timeout, worker torn down and revived
        }

        expect(counter.calls).toBe(1);
        expect(storageStore[DELTA_STORAGE_KEY].blacklist).toBeUndefined();
        expect(typeof storageStore[DELTA_STORAGE_KEY].lastAttemptTime).toBe(
            "number",
        );
    });

    test("a failing fetch is not retried on every worker wake either", async () => {
        const counter = { calls: 0 };
        global.fetch = countingFetch(counter, () => ({
            ok: false,
            status: 503,
        }));

        for (let wake = 0; wake < 4; wake++) {
            const revived = restartWorker();
            await revived.initPhishingList();
            now += 30 * 1000;
        }

        expect(counter.calls).toBe(1);
    });

    test("the retry floor expires, so a failure is not permanent", async () => {
        const {
            MIN_FETCH_ATTEMPT_INTERVAL_MS,
        } = require("../src/shared/phishingDomains");
        const counter = { calls: 0 };
        global.fetch = countingFetch(counter, () => ({
            ok: false,
            status: 503,
        }));

        await restartWorker().initPhishingList();
        expect(counter.calls).toBe(1);

        // Still inside the floor: no retry.
        now += MIN_FETCH_ATTEMPT_INTERVAL_MS - 1000;
        await restartWorker().initPhishingList();
        expect(counter.calls).toBe(1);

        // Past it: the extension goes back to the network.
        now += 2000;
        await restartWorker().initPhishingList();
        expect(counter.calls).toBe(2);
    });

    test("the scheduled tick ignores the retry floor", async () => {
        // The alarm period is far above the floor, but the floor exists to
        // throttle wakes, not the schedule.
        storageStore[DELTA_STORAGE_KEY] = { lastAttemptTime: now - 1000 };
        const mod = require("../src/shared/phishingDomains");
        global.fetch = okFetch();

        await mod.refreshPhishingListOnSchedule();
        expect(global.fetch).toHaveBeenCalledTimes(1);
    });
});
