// Which chain a dApp transaction is PREPARED for on a worker that has not
// loaded state.
//
// The MV3 service worker is terminated when idle — roughly 30 seconds, which
// is its normal condition — and revived by the page's own message. Nothing
// loads state at module scope, so handleSendTransaction() used to build its
// provider with `getProvider(await getRpcUrl())`: the endpoint came from
// storage and was right, and the static network hint was omitted, so
// src/shared/balances.js fell back to currentNetwork() — the unpopulated
// singleton — and answered mainnet. ethers then fixed `chainId` at 0x1.
//
// The transaction was not sent on the wrong chain: verifySignedTx() compares
// the artifact against the selected chain and refused it. So the guard held
// and the feature did not — a user on any non-mainnet network could not send
// from a dApp at all, and the error described the symptom
// (https://git.eeqj.de/sneak/AutistMask/issues/320).
//
// This drives the real balances module and the real approval preparation and
// verification. Only ethers' JsonRpcProvider is replaced, so the static
// network hint getProvider() computes is the hint the population sees.

const { Network, Wallet, Transaction } = 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 SEPOLIA = networkById("sepolia");
const MAINNET = networkById("mainnet");

const NONCE = 7;
const TX_HASH = "0xfeed";

const TX_PARAMS = {
    from: signer.address,
    to: RECIPIENT,
    value: "0x2386f26fc10000",
    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: [],
    };
}

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

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

// A worker whose only wallet state is what is in storage, with ethers'
// JsonRpcProvider replaced by a stub that answers out of the static network it
// was constructed with — which is exactly what a real staticNetwork provider
// does, and what makes the chain id on the approval screen observable here.
function loadColdWorker(networkId) {
    jest.resetModules();

    const constructed = [];
    const broadcast = [];

    jest.doMock("ethers", () => {
        const actual = jest.requireActual("ethers");
        class StubJsonRpcProvider {
            constructor(url, network) {
                this._network = network;
                constructed.push({ url, network });
            }
            async getNetwork() {
                return this._network;
            }
            async getTransactionCount() {
                return NONCE;
            }
            async estimateGas() {
                return 100000n;
            }
            async getFeeData() {
                return {
                    gasPrice: 2000000000n,
                    maxFeePerGas: 2000000000n,
                    maxPriorityFeePerGas: 1000000000n,
                };
            }
            async broadcastTransaction(raw) {
                broadcast.push(raw);
                return { hash: TX_HASH };
            }
        }
        return { ...actual, JsonRpcProvider: StubJsonRpcProvider };
    });
    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 storage = makeStorageStub({ autistmask: storedProfile(networkId) });

    let messageListener = null;
    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: (opts, cb) => {
                createdUrls.push(opts.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;
        messageListener(msg, sender, (r) => {
            result = r;
        });
        return () => result;
    }

    return {
        send,
        constructed,
        broadcast,
        fromPopup: { url: EXT_URL + "src/popup/index.html" },
        // The first message this worker ever sees, as the injected provider
        // sends it.
        sendTransaction: () =>
            send(
                {
                    type: "AUTISTMASK_RPC",
                    method: "eth_sendTransaction",
                    params: [TX_PARAMS],
                },
                { origin: CONNECTED_ORIGIN },
            ),
        approvalId: () => {
            const url = createdUrls[createdUrls.length - 1];
            return url
                ? new URL(url, EXT_URL).searchParams.get("approval")
                : null;
        },
    };
}

// What the approval window does: fetch the approval and sign the transaction
// it was handed, exactly as given.
function signApproved(approvedTx) {
    const tx = {};
    for (const [key, value] of Object.entries(approvedTx)) {
        if (key === "from") continue;
        tx[key] = value;
    }
    return signer.signTransaction(tx);
}

describe("a dApp transaction prepared by a worker that never loaded state", () => {
    test("a cold send on Sepolia reaches the approval screen and goes out", async () => {
        const bg = loadColdWorker("sepolia");

        const answer = bg.sendTransaction();
        await settle();

        // The provider was built for Sepolia, endpoint and static hint
        // together. Omitting the hint made this mainnet.
        expect(bg.constructed).toHaveLength(1);
        expect(bg.constructed[0].url).toBe(SEPOLIA.defaultRpcUrl);
        expect(bg.constructed[0].network.chainId).toBe(
            Network.from("sepolia").chainId,
        );

        // So the approval the user is shown is a Sepolia transaction.
        const id = bg.approvalId();
        expect(id).toBeTruthy();
        const approval = bg.send(
            { type: "AUTISTMASK_GET_APPROVAL", id },
            { url: bg.fromPopup.url },
        )();
        expect(approval.type).toBe("tx");
        expect(approval.approvedTx.chainId).toBe(SEPOLIA.chainId);

        // And it survives the wallet's own verification, which is where a
        // 0x1-stamped artifact was refused as "for a different network".
        const rawSignedTx = await signApproved(approval.approvedTx);
        const response = bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id,
                approved: true,
                rawSignedTx,
            },
            { url: bg.fromPopup.url },
        );
        await settle();

        expect(response()).toEqual({ txHash: TX_HASH });
        expect(bg.broadcast).toEqual([rawSignedTx]);
        expect(Number(Transaction.from(rawSignedTx).chainId)).toBe(
            Number(SEPOLIA.networkVersion),
        );
        expect(answer()).toEqual({ result: TX_HASH });
    });

    test("a cold send on mainnet is prepared for mainnet", async () => {
        // The stored value and the old fallback agree here, so this case
        // cannot catch the defect; it is what keeps the fix from being a swap.
        const bg = loadColdWorker("mainnet");

        bg.sendTransaction();
        await settle();

        expect(bg.constructed[0].url).toBe(MAINNET.defaultRpcUrl);
        const approval = bg.send(
            { type: "AUTISTMASK_GET_APPROVAL", id: bg.approvalId() },
            { url: bg.fromPopup.url },
        )();
        expect(approval.approvedTx.chainId).toBe(MAINNET.chainId);
    });
});
