// What a dApp is told when the wallet's stored profile cannot be read
// (https://git.eeqj.de/sneak/AutistMask/issues/311).
//
// The popup is not the only casualty of a bad blob. getActiveAddress()
// dereferences the stored wallet list on nearly every method, so against the
// build this file was added to, EVERY request from EVERY page came back as
// -32603 "AutistMask could not complete this request because of an internal
// error" — the code the wallet also answers when a signing attempt blows up,
// with nothing in it to tell the page or the user what is actually wrong or
// what to do about it.
//
// So what is pinned here is that the answer is SPECIFIC: its own code, and a
// message that says the saved data cannot be read, that nothing was signed or
// sent, and where to go to fix it.
//
// Same cold-worker shape as tests/coldWorkerChainId.test.js: the real state
// modules, over a storage stub, with no loadState() of the test's own — the
// handler has to reach storage by itself, as a worker revived by the page's
// own message does.

const CONNECTED_ORIGIN = "https://dapp.example";
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";

// The generic answer, quoted rather than imported: this file's whole point is
// that the state-unusable path stopped using it.
const GENERIC_INTERNAL_ERROR_CODE = -32603;

// EIP-1474's assigned non-standard codes, verbatim. The spec sets aside
// -32000..-32099 for implementation-defined server errors but hands out
// meanings for the first seven, so those are exactly the codes this condition
// may NOT take: a page reading -32001 is entitled to read "Resource not
// found". -32002 is in this table AND in use here, for a pending approval.
const EIP_1474_ASSIGNED = {
    "-32000": "Invalid input",
    "-32001": "Resource not found",
    "-32002": "Resource unavailable",
    "-32003": "Transaction rejected",
    "-32004": "Method not supported",
    "-32005": "Limit exceeded",
    "-32006": "JSON-RPC version not supported",
};

// The three blobs from the issue.
const CORRUPT_BLOBS = [
    {
        name: "wallets is a string",
        blob: { hasWallet: true, wallets: ADDRESS },
    },
    {
        name: "wallets is an array of garbage",
        blob: { hasWallet: true, wallets: [null, 42, "wallet"] },
    },
    {
        name: "future-schema blob (unknown fields, no version)",
        blob: {
            hasWallet: true,
            wallets: [{ id: "wallet-1", accounts: [{ addr: ADDRESS }] }],
            profileFormat: "am-2",
        },
    },
];

async function settle() {
    for (let i = 0; i < 50; i++) await Promise.resolve();
}

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

function loadColdWorker(stored) {
    jest.resetModules();

    jest.doMock("../src/shared/balances", () => ({
        getProvider: () => ({}),
        refreshBalances: jest.fn(async () => {}),
    }));
    jest.doMock("../src/shared/phishingDomains", () => ({
        isPhishingDomain: () => false,
    }));
    jest.doMock("../src/shared/alarms", () => ({
        BALANCE_REFRESH_ALARM: "balance",
        BALANCE_REFRESH_PERIOD_MINUTES: 1,
        ensureRecurringAlarms: jest.fn(async () => {}),
        registerAlarmHandlers: jest.fn(),
    }));

    const store = { autistmask: structuredClone(stored) };
    let messageListener = null;
    const set = jest.fn(async (items) => {
        store.autistmask = structuredClone(items.autistmask);
    });

    global.chrome = {
        storage: {
            local: {
                get: jest.fn(async () => structuredClone(store)),
                set,
            },
        },
        runtime: {
            getURL: (p) => "chrome-extension://autistmask/" + p,
            onMessage: {
                addListener: (fn) => {
                    messageListener = fn;
                },
            },
            onConnect: { addListener: () => {} },
            lastError: null,
        },
        windows: {
            getLastFocused: (cb) => cb(null),
            create: (options, cb) => cb({ id: 1 }),
            remove: (id, cb) => {
                if (cb) cb();
            },
            onRemoved: { addListener: () => {} },
        },
        tabs: {
            query: (queryInfo, cb) => cb([{ id: 1 }]),
            sendMessage: (tabId, message, cb) => {
                if (cb) cb();
            },
        },
        action: { setPopup: () => {} },
    };

    require("../src/background/index");

    async function rpc(method, params) {
        let result = null;
        messageListener(
            { type: "AUTISTMASK_RPC", method, params: params || [] },
            { origin: CONNECTED_ORIGIN },
            (r) => {
                result = r;
            },
        );
        await settle();
        return result;
    }

    return { rpc, persisted: () => store.autistmask, storageSet: set };
}

// Every method a page can reach that has to consult the profile.
const METHODS = [
    "eth_accounts",
    "eth_requestAccounts",
    "eth_chainId",
    "personal_sign",
    "eth_sendTransaction",
];

describe("a dApp call against a profile the wallet cannot read", () => {
    for (const { name, blob } of CORRUPT_BLOBS) {
        test(`${name}: a specific error, not the generic internal one`, async () => {
            const bg = loadColdWorker(blob);

            const answer = await bg.rpc("eth_accounts");

            expect(answer.error).toBeDefined();
            expect(answer.error.code).not.toBe(GENERIC_INTERNAL_ERROR_CODE);
            // The message has to say what is wrong, that nothing was sent,
            // and where to go. "Internal error" says none of the three.
            expect(answer.error.message).toMatch(/saved data/i);
            expect(answer.error.message).toMatch(/nothing was/i);
            expect(answer.error.message).toMatch(/AutistMask/);
        });
    }

    test("every method that consults the profile answers the same way", async () => {
        const bg = loadColdWorker(CORRUPT_BLOBS[0].blob);

        const codes = new Set();
        for (const method of METHODS) {
            const answer = await bg.rpc(method, ["0x00", ADDRESS]);
            expect(answer.error).toBeDefined();
            codes.add(answer.error.code);
        }

        // One code for the condition, whatever the method was.
        expect(codes.size).toBe(1);
        expect(codes.has(GENERIC_INTERNAL_ERROR_CODE)).toBe(false);

        // And it is a code EIP-1474 has not already given a meaning to, so a
        // page reading it by the spec's table is not told something false.
        const code = [...codes][0];
        expect(EIP_1474_ASSIGNED[String(code)]).toBeUndefined();
        expect(code).toBeLessThanOrEqual(-32007);
        expect(code).toBeGreaterThanOrEqual(-32099);
    });

    test("it does not write over the record it could not read", async () => {
        const bg = loadColdWorker(CORRUPT_BLOBS[1].blob);

        await bg.rpc("eth_accounts");
        await bg.rpc("eth_chainId");

        expect(bg.storageSet).not.toHaveBeenCalled();
        expect(bg.persisted()).toEqual(CORRUPT_BLOBS[1].blob);
    });
});
