// What one background handler's state can do to another's while both are in
// flight.
//
// The background used to read and write the module-level `state` singleton in
// src/shared/state.js — one object, shared by every handler in the worker,
// replaced wholesale by any loadState(). Two consequences, both covered here
// and both from https://git.eeqj.de/sneak/AutistMask/issues/324:
//
//   - A transaction attempt captured the chain id at its loadState() and then
//     read the ENDPOINT off the singleton several awaits later. A chain switch
//     committed in that window moved the endpoint under an artifact already
//     verified against the old chain, so it would have gone to the new chain's
//     node — the very thing the verification exists to prevent.
//
//   - backgroundRefresh() handed the singleton's wallets to refreshBalances(),
//     which mutates address objects in place across a multi-second network
//     round trip. Any concurrent handler that loaded state replaced those
//     objects, so the refreshed balances landed on detached ones and the save
//     that followed persisted the pre-refresh values — while still stamping
//     lastBalanceRefresh, suppressing the redo.
//
// Both use the real persistence path over a cloning storage stub. Nothing here
// asserts the absence of a loadState() call; each asserts the OUTCOME, so it
// holds against any implementation that gets the outcome right.

const { Wallet } = require("ethers");
const { networkById } = require("../src/shared/networks");
const { makeStorageStub } = require("./support/storageStub");

const SIGNER_KEY =
    "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
const signer = new Wallet(SIGNER_KEY);
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";

const CONNECTED_ORIGIN = "https://dapp.example";
const CONNECTED_HOSTNAME = "dapp.example";
const EXT_URL = "chrome-extension://autistmask/";

const MAINNET = networkById("mainnet");
const SEPOLIA = networkById("sepolia");

const NONCE = 7;
const REFRESHED_BALANCE = "1.5";

// The transaction the background populates, and the artifact signed from it.
// Its chain is a parameter because the whole subject here is a chain moving
// under work already committed to one.
function populated(chainId) {
    return {
        type: 2,
        chainId,
        nonce: NONCE,
        gasLimit: 100000n,
        maxFeePerGas: 2000000000n,
        maxPriorityFeePerGas: 1000000000n,
        to: RECIPIENT,
        value: 10000000000000000n,
        data: "0x",
    };
}

function storedProfile(networkId) {
    const net = networkById(networkId);
    return {
        hasWallet: true,
        wallets: [
            {
                name: "Wallet 1",
                type: "hd",
                xpub: "xpub-1",
                addresses: [
                    {
                        address: signer.address,
                        balance: "0.0",
                        tokenBalances: [],
                    },
                ],
            },
        ],
        activeAddress: signer.address,
        networkId,
        rpcUrl: net.defaultRpcUrl,
        blockscoutUrl: net.defaultBlockscoutUrl,
        allowedSites: { [signer.address]: [CONNECTED_HOSTNAME] },
        deniedSites: {},
        trackedTokens: [],
        lastBalanceRefresh: 0,
    };
}

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

function deferred() {
    let resolve;
    const promise = new Promise((res) => {
        resolve = res;
    });
    return { promise, resolve };
}

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

// The background worker over a cloning storage stub, with the network and the
// clock stubbed out. `opts.refreshBalances` replaces the balance refresh so a
// test can hold one open across another handler's whole turn.
function loadWorker(networkId, opts) {
    const options = opts || {};
    jest.resetModules();

    // Every provider this worker constructs, in order, with the endpoint and
    // the network id it was given. The subject of the first test is which pair
    // reaches the broadcast.
    const providers = [];
    const broadcastTransaction = jest.fn(async () => ({ hash: "0xfeed" }));

    jest.doMock("../src/shared/balances", () => ({
        getProvider: (rpcUrl, networkId2) => {
            const provider = {
                rpcUrl,
                networkId: networkId2,
                broadcastTransaction,
                getNetwork: async () => ({
                    chainId: BigInt(networkById(networkId2).networkVersion),
                }),
                getTransactionCount: async () => NONCE,
                estimateGas: async () => 100000n,
                getFeeData: async () => ({
                    gasPrice: 2000000000n,
                    maxFeePerGas: 2000000000n,
                    maxPriorityFeePerGas: 1000000000n,
                }),
            };
            providers.push(provider);
            return provider;
        },
        refreshBalances:
            options.refreshBalances || jest.fn(async () => undefined),
    }));
    jest.doMock("../src/shared/phishingDomains", () => ({
        isPhishingDomain: () => false,
    }));
    let alarmHandlers = {};
    jest.doMock("../src/shared/alarms", () => ({
        BALANCE_REFRESH_ALARM: "balance",
        BALANCE_REFRESH_PERIOD_MINUTES: 1,
        ensureRecurringAlarms: jest.fn(async () => {}),
        registerAlarmHandlers: jest.fn((handlers) => {
            alarmHandlers = handlers;
        }),
    }));

    const storage = makeStorageStub({ autistmask: storedProfile(networkId) });
    // A hook the tests use to suspend one handler mid-flight, so the other one
    // runs entirely inside its window.
    let getHook = null;
    const realGet = storage.local.get;
    storage.local.get = jest.fn(async (key) => {
        if (getHook) await getHook();
        return realGet(key);
    });

    let messageListener = null;
    // Every popup URL the background opened. The approval id is in it, and
    // that is how the popup learns which approval it is answering.
    const createdUrls = [];
    global.chrome = {
        storage,
        runtime: {
            getURL: (path) => EXT_URL + path,
            onMessage: {
                addListener: (fn) => {
                    messageListener = fn;
                },
            },
            onConnect: { addListener: () => {} },
            lastError: null,
        },
        windows: {
            getLastFocused: (cb) => cb(null),
            create: (createOpts, cb) => {
                createdUrls.push(createOpts.url);
                cb({ id: createdUrls.length });
            },
            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");

    function send(msg, sender) {
        let result = null;
        const kept = messageListener(msg, sender, (r) => {
            result = r;
        });
        return { kept, result: () => result };
    }

    function rpc(method, params, origin) {
        return send(
            { type: "AUTISTMASK_RPC", method, params },
            { origin: origin || CONNECTED_ORIGIN },
        );
    }

    return {
        rpc,
        send,
        providers,
        broadcastTransaction,
        persisted: () => storage.read("autistmask"),
        setGetHook: (hook) => {
            getHook = hook;
        },
        fromPopup: { url: EXT_URL + "src/popup/index.html" },
        fireBalanceAlarm: () => alarmHandlers.balance(),
        lastApprovalId: () => {
            const url = createdUrls[createdUrls.length - 1];
            if (!url) return null;
            return new URL(url, EXT_URL).searchParams.get("approval");
        },
    };
}

describe("a chain switch under a transaction already committed to a chain", () => {
    // Item 4 of https://git.eeqj.de/sneak/AutistMask/issues/324.
    //
    // The artifact is verified against the chain read at the top of the
    // attempt. Whatever endpoint it is then broadcast to has to be that same
    // chain's — otherwise the wallet checks a transaction against Sepolia and
    // sends it to a mainnet node. A connected site can switch the chain at any
    // moment, including this one.
    test("the artifact is broadcast to the endpoint of the chain it was verified against", async () => {
        const bg = loadWorker("sepolia");

        // Raise the approval, then find its id from the popup's own fetch.
        bg.rpc("eth_sendTransaction", [
            {
                from: signer.address,
                to: RECIPIENT,
                value: "0x2386f26fc10000",
                data: "0x",
            },
        ]);
        await settle();

        const id = bg.lastApprovalId();
        expect(id).toBeTruthy();

        // The popup signs what it was shown: Sepolia.
        const rawSignedTx = await signer.signTransaction(
            populated(Number(SEPOLIA.networkVersion)),
        );

        // A connected site switches the chain while the attempt is running,
        // and the switch is committed to storage in full before the attempt
        // goes any further.
        //
        // It is fired from inside the attempt's SECOND state read, because
        // that is where the window used to be: the chain id was captured at
        // the first read and the endpoint was taken off the singleton several
        // awaits later, so a switch landing between them moved the endpoint
        // under an artifact already verified against the old chain. An
        // implementation that takes both from one read has no second read for
        // this to fire on, and the switch below runs after the attempt is
        // done instead — which is the point.
        let reads = 0;
        let switched = null;
        const doSwitch = async () => {
            switched = bg.rpc("wallet_switchEthereumChain", [
                { chainId: MAINNET.chainId },
            ]);
            await settle();
        };
        bg.setGetHook(async () => {
            reads++;
            if (reads !== 2) return;
            bg.setGetHook(null);
            await doSwitch();
        });

        const attempt = bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id,
                approved: true,
                rawSignedTx,
            },
            { url: bg.fromPopup.url },
        );
        await settle();

        bg.setGetHook(null);
        if (!switched) await doSwitch();
        expect(switched.result()).toEqual({ result: null });
        expect(bg.persisted().networkId).toBe("mainnet");
        await settle();

        // It went out, and it went out to Sepolia's node — the chain the
        // artifact was verified against. Reading the endpoint separately from
        // the chain id put mainnet's here.
        expect(attempt.result()).toEqual({ txHash: "0xfeed" });
        expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
        const used = bg.providers[bg.providers.length - 1];
        expect(used.rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
        expect(used.networkId).toBe("sepolia");
    });
});

describe("a balance refresh under another handler's state read", () => {
    // Item 5 of https://git.eeqj.de/sneak/AutistMask/issues/324.
    //
    // The trigger is a same-chain wallet_switchEthereumChain from a connected
    // site: it answers { result: null } and changes nothing, so the ONLY thing
    // it can do to the refresh is what its state read does. On the singleton
    // that read replaced state.wallets, detaching the objects the refresh was
    // mutating.
    test("a chain read arriving mid-refresh does not discard the refresh", async () => {
        const roundTrip = deferred();
        const reachedNetwork = deferred();

        const bg = loadWorker("sepolia", {
            refreshBalances: async (wallets) => {
                reachedNetwork.resolve();
                await roundTrip.promise;
                // In place, on the objects handed in — as balances.js does.
                wallets[0].addresses[0].balance = REFRESHED_BALANCE;
            },
        });

        const refresh = bg.fireBalanceAlarm();
        await reachedNetwork.promise;

        const answered = bg.rpc("wallet_switchEthereumChain", [
            { chainId: SEPOLIA.chainId },
        ]);
        await settle();
        expect(answered.result()).toEqual({ result: null });

        roundTrip.resolve();
        await refresh;

        expect(bg.persisted().wallets[0].addresses[0].balance).toBe(
            REFRESHED_BALANCE,
        );
        expect(bg.persisted().lastBalanceRefresh).toBeGreaterThan(0);
    });

    // The other half of "does not publish a shared object": a wallet added
    // while the refresh was in flight must survive the refresh's own write.
    test("a wallet added mid-refresh survives the refresh's write", async () => {
        const roundTrip = deferred();
        const reachedNetwork = deferred();

        const bg = loadWorker("sepolia", {
            refreshBalances: async (wallets) => {
                reachedNetwork.resolve();
                await roundTrip.promise;
                wallets[0].addresses[0].balance = REFRESHED_BALANCE;
            },
        });

        const refresh = bg.fireBalanceAlarm();
        await reachedNetwork.promise;

        // Another extension page adds a wallet while the round trip is out.
        const during = bg.persisted();
        during.wallets.push({
            name: "Wallet 2",
            type: "hd",
            xpub: "xpub-2",
            addresses: [
                { address: RECIPIENT, balance: "0.0", tokenBalances: [] },
            ],
        });
        global.chrome.storage.write("autistmask", during);

        roundTrip.resolve();
        await refresh;

        const after = bg.persisted();
        expect(after.wallets).toHaveLength(2);
        expect(after.wallets[0].addresses[0].balance).toBe(REFRESHED_BALANCE);
    });
});
