// Browser-level network interception for the end-to-end suite.
//
// Every http(s) request the extension makes — from the popup page AND
// from the MV3 background service worker — is fulfilled from these
// fixtures, so the suite is deterministic and runs entirely offline. The
// probe that motivated this harness (see issue #181) observed live calls
// to Blockscout returning 401 inside the container, which would make any
// assertion about rendered transaction data worthless.
//
// Service-worker coverage is not free: ctx.route() only sees worker
// traffic when PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1 is set in
// the environment, which script/test-e2e does. Without it every fetch the
// MV3 background worker makes — the JSON-RPC calls behind every approval
// in this suite among them — goes to the real internet unobserved. That is
// not left to trust: waitForServiceWorkerTraffic() below backs the
// launch-time canary in harness.js, which fails the entire suite if worker
// requests stop being visible here.
//
// Anything not explicitly stubbed here is aborted AND reported to the
// error collector, so a newly added outbound call shows up as a test
// failure rather than as intermittent flakiness.

"use strict";

const { Transaction } = require("ethers");

// Fictional ERC-20 used to seed the transaction-detail test. The symbol
// must not collide with any entry in src/shared/tokenList.js, or
// isSpoofedSymbol() in src/shared/transactions.js drops the transfer as a
// symbol-spoofing attempt; holders_count must be >= 1000 or the default
// hideLowHolderTokens filter drops it. Either would make the test pass
// vacuously by never rendering a row at all.
const STUB_TOKEN = {
    address: "0xe2e0000000000000000000000000000000000e2e",
    symbol: "E2E",
    name: "End To End Test Token",
    decimals: "6",
    holders: "12345",
};

const STUB_COUNTERPARTY = "0xc0ffee0000000000000000000000000000c0ffee";

const STUB_TX_HASH =
    "0xe2e0000000000000000000000000000000000000000000000000000000000e2e";

const STUB_BLOCK_NUMBER = 21000000;

// The native ETH transfer, seeded by opts.seedNativeTransfer. Its own hash
// and an older block, so it is a second row rather than a leg of the token
// transfer: mergeTransactions() consolidates a native entry and a token
// transfer that share a hash into one row, which would leave nothing native
// to open. 0.25 ETH clears the 100000 gwei dust threshold the default
// filters apply, so the row is not silently dropped.
const STUB_NATIVE_TX_HASH =
    "0xe7e0000000000000000000000000000000000000000000000000000000000e7e";

const STUB_NATIVE_BLOCK_NUMBER = STUB_BLOCK_NUMBER - 1;

const STUB_NATIVE_VALUE_WEI = "250000000000000000";

// Fixed instant so timeAgo() output is stable across runs.
const STUB_TX_TIMESTAMP = "2026-01-02T03:04:05.000000Z";

const STUB_NATIVE_TX_TIMESTAMP = "2026-01-02T02:03:04.000000Z";

// A 32-byte zero word. Returned for every eth_call, which is what makes
// ethers' ENS reverse lookup resolve to "no resolver set" and return null
// instead of throwing. A throw would be logged by src/shared/ens.js via
// log.errorf(), i.e. console.error, which fails the run on its own.
const ZERO_WORD = "0x" + "0".repeat(64);

function hex(value) {
    return "0x" + BigInt(value).toString(16);
}

// A bigint as a 32-byte ABI word.
function word(value) {
    return "0x" + BigInt(value).toString(16).padStart(64, "0");
}

// -------------------------------------------------------- dApp fixture
//
// The origin the EIP-1193 test page is served from, and the page itself.
//
// It is a fixture like every other one in this file: the route handler
// fulfils the navigation from the string below, so the page never comes
// from a remote origin and nothing about the dApp round trips leaves the
// container. `.test` is reserved by RFC 6761 and has no owner to reach in
// the first place; the launch arguments map every host to NOTFOUND anyway.
//
// What the page deliberately does NOT do is load a provider. window.ethereum
// is put there by the shipped manifest's MAIN-world content script, exactly
// as it is on any http(s) page a user visits, so what these tests speak to
// is the real inpage provider and not a copy the harness wired up.
//
// DAPP_HTML below is exported and served verbatim by the Firefox suite too
// (tests/e2e/firefox/dapp.js), from a loopback origin rather than through a
// route handler. The two suites drive different browsers over different
// protocols, but the page they drive — the __dapp API, the message log — is
// one fixture, so an assertion written against it means the same thing on
// both.
const DAPP_ORIGIN = "https://dapp.e2e.test";
const DAPP_URL = DAPP_ORIGIN + "/";

// The same page, served from a hostname that is on the vendored phishing
// blocklist, so the phishing warning can be driven end to end against the real
// list rather than a stub of it. It is a live entry at the pinned upstream
// commit; upstream prunes, so a re-vendoring run that retires it turns the
// phishing test red, and the fix is a current entry, not a weaker assertion.
const PHISHING_DAPP_ORIGIN = "https://myetheywallet.com";
const PHISHING_DAPP_URL = PHISHING_DAPP_ORIGIN + "/";

// A request the harness asks the background service worker to make, purely so
// that worker interception can be proved before any test runs. Nothing in the
// extension fetches at startup any more — the blocklist is vendored at build
// time — so the canary in harness.js has no product traffic to anchor on and
// generates its own. See assertWorkerTrafficIntercepted().
const WORKER_PROBE_ORIGIN = "https://worker-probe.e2e.test";
const WORKER_PROBE_URL = WORKER_PROBE_ORIGIN + "/canary";

// Requests are parked rather than awaited. An approval prompt only exists
// while its call is in flight, so a test that awaited the promise could
// never drive the popup that has to settle it; start() files the promise
// under a key and settle() collects it once the prompt has been dealt with.
//
// The rejection branch records the whole observable shape of the error as it
// arrives — name, message, and whether a `code` is present at all as distinct
// from its value. EIP-1193 says a user rejection is a ProviderRpcError
// carrying code 4001; what the page can actually see is recorded here rather
// than assumed, and asserted in run.js.
//
// The message log is the page's half of the boundary observation: every
// AUTISTMASK_* message that crosses between this page and the content
// script, in both directions, verbatim.
const DAPP_HTML = [
    "<!doctype html>",
    '<html lang="en">',
    "<head>",
    '<meta charset="utf-8">',
    "<title>AutistMask e2e dApp</title>",
    // Inline and empty: without it Chromium asks for /favicon.ico, which
    // the unstubbed-request guard would report as escaping traffic.
    '<link rel="icon" href="data:,">',
    "</head>",
    "<body>",
    "<h1>AutistMask e2e dApp</h1>",
    "<script>",
    "window.__dapp = {",
    "    messages: [],",
    "    calls: {},",
    "    start: function (key, method, params) {",
    "        window.__dapp.calls[key] = window.ethereum",
    "            .request({ method: method, params: params })",
    "            .then(",
    "                function (result) {",
    "                    return { settled: 'resolved', result: result };",
    "                },",
    "                function (error) {",
    "                    return {",
    "                        settled: 'rejected',",
    "                        message: String((error && error.message) || error),",
    "                        name: error ? error.name : undefined,",
    "                        hasCode: !!error && 'code' in Object(error),",
    "                        code: error ? error.code : undefined,",
    "                    };",
    "                },",
    "            );",
    "    },",
    "    settle: function (key) {",
    "        return window.__dapp.calls[key];",
    "    },",
    "};",
    "window.addEventListener('message', function (event) {",
    "    if (event.source !== window) return;",
    "    var d = event.data;",
    "    if (!d || typeof d.type !== 'string') return;",
    "    if (d.type.indexOf('AUTISTMASK') !== 0) return;",
    "    window.__dapp.messages.push(d);",
    "});",
    "</script>",
    "</body>",
    "</html>",
].join("\n");

// ------------------------------------------------------------ fee fixture
//
// The confirmation screen carries two different numbers for the same
// transaction and may gate on only one of them:
//
//   reserve  = gasLimit * maxFeePerGas — what a node requires to be
//              available for a type-2 transaction, and what the spend gate
//              must use.
//   estimate = gasLimit * gasPrice — what the transfer is expected to
//              actually cost. Display only.
//
// Issue #154 was the gate reading the smaller of the two. ethers derives
// maxFeePerGas as baseFeePerGas * 2 + maxPriorityFeePerGas, so the numbers
// below put the reserve at very nearly twice the estimate. That gap is the
// entire point of these values: it leaves room for a send that an
// estimate-based gate accepts and a reserve-based gate refuses, which is
// what lets the ConfirmTx tests tell the two apart at all. Collapse the gap
// — by dropping baseFeePerGas from the block below, say — and those tests
// go on passing while asserting nothing.
const GAS_LIMIT = 21000n;
const BASE_FEE_WEI = 100000000000n; // 100 gwei
const PRIORITY_FEE_WEI = 1000000000n; // 1 gwei
const GAS_PRICE_WEI = BASE_FEE_WEI + PRIORITY_FEE_WEI; // 101 gwei
const MAX_FEE_WEI = BASE_FEE_WEI * 2n + PRIORITY_FEE_WEI; // 201 gwei

const FEE_ESTIMATE_WEI = GAS_LIMIT * GAS_PRICE_WEI; // 0.002121 ETH
const FEE_RESERVE_WEI = GAS_LIMIT * MAX_FEE_WEI; // 0.004221 ETH

const RPC_RESULTS = {
    eth_chainId: "0x1",
    net_version: "1",
    eth_blockNumber: "0x1406f40",
    eth_getBalance: "0x0",
    eth_call: ZERO_WORD,
    eth_getCode: "0x",
    eth_gasPrice: hex(GAS_PRICE_WEI),
    eth_estimateGas: hex(GAS_LIMIT),
    eth_getTransactionCount: "0x0",
    eth_maxPriorityFeePerGas: hex(PRIORITY_FEE_WEI),
};

// The "latest" block, which ethers' getFeeData() reads baseFeePerGas from
// to derive maxFeePerGas. Without it every fee is a legacy gasPrice, the
// reserve and the estimate collapse to the same number, and the gate tests
// stop being able to distinguish them.
function latestBlock() {
    return {
        hash: "0x" + "11".repeat(32),
        parentHash: "0x" + "22".repeat(32),
        number: hex(STUB_BLOCK_NUMBER),
        timestamp: hex(1767326645),
        nonce: "0x0000000000000000",
        difficulty: "0x0",
        gasLimit: "0x1c9c380",
        gasUsed: "0xf4240",
        miner: STUB_COUNTERPARTY,
        extraData: "0x",
        baseFeePerGas: hex(BASE_FEE_WEI),
        transactions: [],
    };
}

// keccak("decimals()")[0:4].
const SELECTOR_DECIMALS = "0x313ce567";

// Every eth_call still answers with a zero word except decimals() on the
// stub token, which the wallet reads back at signing time to compare with
// the scale the confirmation screen rendered (issue #305).
//
// opts.tokenDecimalsOverride is the lying contract: set it and decimals()
// answers something other than the value this same fixture reports through
// Blockscout, which is exactly the disagreement the wallet must refuse to
// sign over. It is read at request time, so a test flips it on the options
// object the route was registered with — after the confirmation screen has
// been built — without re-registering anything.
function ethCallResult(req, opts) {
    const call = Array.isArray(req.params) ? req.params[0] : null;
    if (!call || typeof call !== "object") return ZERO_WORD;
    const data = String(call.data || call.input || "").toLowerCase();
    const to = String(call.to || "").toLowerCase();
    if (data.startsWith(SELECTOR_DECIMALS) && to === STUB_TOKEN.address) {
        return word(opts.tokenDecimalsOverride || STUB_TOKEN.decimals);
    }
    return ZERO_WORD;
}

// opts.tokenSymbolOverride is the hostile contract: set it and the explorer
// reports that string as the token's symbol, exactly as it would for a token
// whose symbol() returns markup. Read at request time, like every other
// fixture switch, so a test can flip it and reopen the popup.
function tokenObject(opts) {
    return {
        address_hash: STUB_TOKEN.address,
        address: STUB_TOKEN.address,
        symbol: (opts && opts.tokenSymbolOverride) || STUB_TOKEN.symbol,
        name: STUB_TOKEN.name,
        decimals: STUB_TOKEN.decimals,
        holders_count: STUB_TOKEN.holders,
        type: "ERC-20",
    };
}

// One received ERC-20 transfer of 1.5 E2E to the address under test.
function tokenTransferItems(address, opts) {
    return [
        {
            transaction_hash: STUB_TX_HASH,
            block_number: STUB_BLOCK_NUMBER,
            timestamp: STUB_TX_TIMESTAMP,
            from: { hash: STUB_COUNTERPARTY },
            to: { hash: address },
            total: { decimals: STUB_TOKEN.decimals, value: "1500000" },
            token: tokenObject(opts),
        },
    ];
}

// One received native ETH transfer, in the shape src/shared/transactions.js
// parses. to.is_contract is false and there is no method, so parseTx() keeps
// it a plain transfer rather than a contract call — which is what makes the
// detail screen classify it "Native ETH Transfer" and leave the token
// contract row hidden.
function nativeTransactionItems(address) {
    return [
        {
            hash: STUB_NATIVE_TX_HASH,
            block_number: STUB_NATIVE_BLOCK_NUMBER,
            timestamp: STUB_NATIVE_TX_TIMESTAMP,
            from: { hash: STUB_COUNTERPARTY },
            to: { hash: address, is_contract: false },
            value: STUB_NATIVE_VALUE_WEI,
            status: "ok",
        },
    ];
}

// A holding of 1.5 E2E, in the shape src/shared/balances.js parses. Serving
// this is what puts an ERC-20 in the send screen's token dropdown, which is
// the only way the confirmation screen's ERC-20 path can be reached.
function tokenBalanceItems(opts) {
    return [
        {
            value: "1500000",
            token: tokenObject(opts),
        },
    ];
}

// Full details for either seeded transaction — the detail screen fetches
// them for whichever row was opened, and an unstubbed hash would be
// reported as escaping traffic. raw_input is "0x" so the calldata decoder
// short-circuits; the on-chain detail fields still populate.
function transactionDetails(hash) {
    return {
        hash: hash,
        block_number:
            hash === STUB_NATIVE_TX_HASH
                ? STUB_NATIVE_BLOCK_NUMBER
                : STUB_BLOCK_NUMBER,
        nonce: 7,
        gas_used: "51000",
        gas_price: "1000000000",
        fee: { value: "51000000000000" },
        raw_input: "0x",
        status: "ok",
    };
}

// The receipt for a transaction this run broadcast.
//
// eth_getTransactionReceipt otherwise answers null — "not mined yet", which is
// what a node says about a transaction it has only just accepted, and what the
// wait screen has to keep polling through. opts.seedReceipt confirms it
// instead, which is how a test that drives the popup's own send to a broadcast
// gets off the wait screen: the wait resolves to the success view, which has a
// Done button, rather than polling for a receipt for the rest of the suite.
//
// Every field ethers' receipt formatter requires is present. A receipt it
// cannot parse throws inside the poll, which the wallet reports through
// log.errorf — i.e. console.error — and the harness fails the run on, so a
// half-populated fixture here would surface as an unrelated-looking failure.
function transactionReceipt(hash) {
    return {
        transactionHash: hash,
        transactionIndex: "0x0",
        blockHash: "0x" + "33".repeat(32),
        blockNumber: hex(STUB_BLOCK_NUMBER),
        from: STUB_COUNTERPARTY,
        to: STUB_TOKEN.address,
        cumulativeGasUsed: hex(GAS_LIMIT),
        gasUsed: hex(GAS_LIMIT),
        effectiveGasPrice: hex(GAS_PRICE_WEI),
        contractAddress: null,
        logs: [],
        logsBloom: "0x" + "00".repeat(256),
        status: "0x1",
        type: "0x2",
    };
}

function jsonResponse(route, body) {
    return route.fulfill({
        status: 200,
        contentType: "application/json",
        body: JSON.stringify(body),
    });
}

// Extract the address from a Blockscout /addresses/<addr>/... path.
function blockscoutAddress(pathname) {
    const m = pathname.match(/\/addresses\/(0x[0-9a-fA-F]{40})\//);
    return m ? m[1] : null;
}

function sleep(ms) {
    return new Promise((resolve) => setTimeout(resolve, ms));
}

// How long a deliberately held reply is allowed to stay held, and how often
// the release flag is re-read while it is.
const HOLD_POLL_MS = 25;
const HOLD_MAX_MS = 30000;

// Hold a gas estimate open for as long as the test asks.
//
// opts.holdGasEstimate is read here rather than captured, so a test flips it
// on the same options object the route was registered with — the same
// pattern as seedTokenTransfer. This is the only way to observe the
// confirmation screen while its estimate is genuinely in flight; sampling
// the screen and hoping to win a race against the network would assert
// nothing on a slow machine.
//
// It never gives up quietly. A hold that outlives the bound is reported like
// any other harness fault, because a "pending" state that stopped being
// pending on its own is a green assertion about the wrong screen.
async function awaitRelease(opts, report) {
    const started = Date.now();
    while (opts.holdGasEstimate) {
        if (Date.now() - started > HOLD_MAX_MS) {
            report(
                "held gas estimate was never released after " +
                    HOLD_MAX_MS +
                    "ms",
            );
            return;
        }
        await sleep(HOLD_POLL_MS);
    }
}

// One JSON-RPC reply. Methods whose answer depends on a fixture a test has
// set, or on the call itself, are resolved here; every other method is a
// constant in RPC_RESULTS.
function rpcReply(req, opts, report) {
    const envelope = { jsonrpc: "2.0", id: req.id };

    if (req.method === "eth_getBalance") {
        return Object.assign(envelope, {
            result: opts.ethBalanceWei || RPC_RESULTS.eth_getBalance,
        });
    }
    if (req.method === "eth_call") {
        return Object.assign(envelope, { result: ethCallResult(req, opts) });
    }
    if (req.method === "eth_getTransactionReceipt") {
        const hash = Array.isArray(req.params) ? req.params[0] : null;
        return Object.assign(envelope, {
            result: opts.seedReceipt && hash ? transactionReceipt(hash) : null,
        });
    }
    if (req.method === "eth_getBlockByNumber") {
        return Object.assign(envelope, { result: latestBlock() });
    }
    // The end of the dApp transaction round trip: the raw signed transaction
    // the background hands to the node. It is recorded verbatim so a test can
    // recover the signer from the exact bytes that were broadcast, rather than
    // from anything the extension reported about them.
    //
    // The reply must be the transaction's real hash. ethers compares the hash
    // the node returns against the one it computes itself and throws on a
    // mismatch, so a constant here would fail the broadcast for a reason that
    // has nothing to do with what is being tested.
    if (req.method === "eth_sendRawTransaction") {
        const raw = Array.isArray(req.params) ? req.params[0] : null;
        let parsed;
        try {
            parsed = Transaction.from(raw);
        } catch {
            report("eth_sendRawTransaction with an undecodable transaction");
            return Object.assign(envelope, {
                error: { code: -32000, message: "undecodable transaction" },
            });
        }
        if (Array.isArray(opts.broadcastTransactions)) {
            opts.broadcastTransactions.push(raw);
        }
        return Object.assign(envelope, { result: parsed.hash });
    }
    if (req.method === "eth_estimateGas" && opts.failGasEstimate) {
        // A refusal the node itself would produce, not a transport error:
        // this is the shape the confirmation screen has to turn into
        // "Unable to estimate" rather than into a fee of zero.
        return Object.assign(envelope, {
            error: {
                code: -32000,
                message: "e2e fixture: gas required exceeds allowance",
            },
        });
    }

    const result = RPC_RESULTS[req.method];
    if (result === undefined) {
        report("unstubbed RPC method: " + req.method);
        return Object.assign(envelope, {
            error: { code: -32601, message: "unstubbed in e2e harness" },
        });
    }
    return Object.assign(envelope, { result });
}

async function handleRpc(route, postData, opts, report) {
    let payload;
    try {
        payload = JSON.parse(postData || "null");
    } catch {
        report("unstubbed RPC: unparseable body " + String(postData));
        return route.abort();
    }
    // ethers batches by default, so the body may be an array.
    const batch = Array.isArray(payload) ? payload : [payload];

    // Anything that is not a JSON-RPC object, or a NON-EMPTY batch of
    // them, is not RPC at all and must be reported like any other
    // unrecognised outbound traffic rather than dereferenced.
    //
    // The length check is not decoration: every() is vacuously true on an
    // empty array, so without it a POST with body [] was answered 200 []
    // and escaped the guard entirely (issue #187). No real batch is empty,
    // so nothing legitimate is caught by it.
    //
    // Two distinct paths land a non-RPC body here, and neither is an
    // empty-string special case. playwright-core's postData() is
    // `buffer.toString("utf-8") || null`, so an absent or empty body
    // decodes to null, JSON.parse("null") yields null, and the type guard
    // below reports it. A binary body is instead decoded LOSSILY into
    // mojibake — not null — which is not valid JSON, so the catch above
    // reports that one. Both end up reported; only the route differs.
    if (
        payload === null ||
        typeof payload !== "object" ||
        batch.length === 0 ||
        !batch.every((req) => req !== null && typeof req === "object")
    ) {
        report("unstubbed request: POST " + route.request().url());
        return route.abort();
    }
    if (batch.some((req) => req.method === "eth_estimateGas")) {
        await awaitRelease(opts, report);
    }

    const replies = batch.map((req) => rpcReply(req, opts, report));
    return jsonResponse(route, Array.isArray(payload) ? replies : replies[0]);
}

const TRACE_TRUE = ["1", "true", "yes", "on"];
const TRACE_FALSE = ["", "0", "false", "no", "off"];

// Whether E2E_TRACE_NETWORK asks for the request trace.
//
// A set-but-unrecognised value is a hard error rather than a quiet
// "off": E2E_TRACE_NETWORK=true asking for a trace and getting silence
// is the operator being lied to about what the harness is doing, which
// is the whole failure mode this suite exists to eliminate. Refusing to
// guess costs one line and one obvious error message.
function traceEnabled(raw) {
    if (raw === undefined || raw === null) return false;
    const v = String(raw).trim().toLowerCase();
    if (TRACE_TRUE.includes(v)) return true;
    if (TRACE_FALSE.includes(v)) return false;
    throw new Error(
        "E2E_TRACE_NETWORK is set to " +
            JSON.stringify(String(raw)) +
            ", which is not a recognised on/off value. Use one of " +
            TRACE_TRUE.join(", ") +
            " to enable the request trace, or one of " +
            TRACE_FALSE.slice(1).join(", ") +
            " to disable it. Refusing to guess: a diagnostic that silently " +
            "does nothing is worse than one that is not there",
    );
}

/**
 * Route every http(s) request through local fixtures.
 *
 * @param {import("playwright-core").BrowserContext} ctx
 * @param {object} opts
 * @param {(text: string) => void} opts.report  called for unstubbed traffic
 * @param {boolean} [opts.seedTokenTransfer]  serve the stubbed ERC-20
 *   transfer. Read at request time, so a test can flip it on the same
 *   options object without re-registering the route.
 * @param {boolean} [opts.seedNativeTransfer]  serve the stubbed native ETH
 *   transfer, read at request time like seedTokenTransfer. Without it the
 *   normal-transactions endpoint answers with an empty list, so there is no
 *   non-ERC-20 row to open.
 * @param {boolean} [opts.seedTokenBalance]  serve the stubbed ERC-20
 *   holding, which is what makes the token reachable from the send screen.
 * @param {string} [opts.ethBalanceWei]  hex wei answered to eth_getBalance;
 *   defaults to zero, which is what every test that predates the funded
 *   fixture expects.
 * @param {boolean} [opts.failGasEstimate]  answer eth_estimateGas with a
 *   node-side refusal.
 * @param {boolean} [opts.holdGasEstimate]  hold every batch containing an
 *   eth_estimateGas until this is cleared again.
 * @param {string[]} [opts.broadcastTransactions]  every raw signed
 *   transaction handed to eth_sendRawTransaction, appended in order.
 * @param {string} [opts.tokenDecimalsOverride]  what decimals() answers for
 *   the stub token, in place of the value Blockscout reports for it. This is
 *   the token that lies about its scale; read at request time.
 * @param {string} [opts.tokenSymbolOverride]  what the explorer reports as
 *   the stub token's symbol, in place of "E2E". This is the token whose
 *   symbol is markup; read at request time.
 * @param {boolean} [opts.seedReceipt]  answer eth_getTransactionReceipt with a
 *   confirmed receipt instead of null, so a wait screen resolves.
 * @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) =>
 *   Promise<string|null>}>}
 */
async function installNetworkStubs(ctx, opts) {
    const report = opts.report;

    // First request seen that originated in a service worker, and the
    // resolver waiting for it. This is what proves worker interception is
    // actually in force; see waitForServiceWorkerTraffic below.
    let firstWorkerRequest = null;
    let announceWorkerRequest = null;

    // E2E_TRACE_NETWORK=1 prints every request that reaches this handler,
    // tagged [sw] when it originated in the background service worker.
    // It exists so the isolation claim above can be re-checked by anyone
    // in one command, without editing files: the canary probe and then
    // every JSON-RPC call behind an approval showing up with an [sw] tag
    // is the proof that the worker really is intercepted.
    const trace = traceEnabled(process.env.E2E_TRACE_NETWORK);

    // Regex rather than a glob so chrome-extension:// resource loads are
    // never touched — routing those would break the popup itself.
    await ctx.route(/^https?:\/\//, async (route) => {
        const req = route.request();
        const url = new URL(req.url());
        const p = url.pathname;
        const fromWorker = !!req.serviceWorker();

        if (fromWorker && !firstWorkerRequest) {
            firstWorkerRequest = req.method() + " " + req.url();
            if (announceWorkerRequest)
                announceWorkerRequest(firstWorkerRequest);
        }

        if (trace) {
            const origin = fromWorker ? "[sw] " : "[page] ";
            console.log("# routed " + origin + req.method() + " " + req.url());
        }

        // JSON-RPC endpoint (any host): a POST with a JSON-RPC body.
        if (req.method() === "POST") {
            return handleRpc(route, req.postData(), opts, report);
        }

        // The local EIP-1193 test page. Served from here so the dApp round
        // trips run against a real http(s) origin — which is what makes the
        // shipped content scripts inject at all — without any remote origin
        // being involved.
        if (
            (url.origin === DAPP_ORIGIN ||
                url.origin === PHISHING_DAPP_ORIGIN) &&
            p === "/"
        ) {
            return route.fulfill({
                status: 200,
                contentType: "text/html; charset=utf-8",
                body: DAPP_HTML,
            });
        }

        // Blockscout v2
        if (p.includes("/api/v2/")) {
            if (/\/addresses\/0x[0-9a-fA-F]{40}\/transactions$/.test(p)) {
                const addr = blockscoutAddress(p);
                return jsonResponse(route, {
                    items:
                        opts.seedNativeTransfer && addr
                            ? nativeTransactionItems(addr)
                            : [],
                });
            }
            if (/\/addresses\/0x[0-9a-fA-F]{40}\/token-transfers$/.test(p)) {
                const addr = blockscoutAddress(p);
                return jsonResponse(route, {
                    items:
                        opts.seedTokenTransfer && addr
                            ? tokenTransferItems(addr, opts)
                            : [],
                });
            }
            if (/\/addresses\/0x[0-9a-fA-F]{40}\/token-balances$/.test(p)) {
                return jsonResponse(
                    route,
                    opts.seedTokenBalance ? tokenBalanceItems(opts) : [],
                );
            }
            for (const hash of [STUB_TX_HASH, STUB_NATIVE_TX_HASH]) {
                if (p.endsWith("/transactions/" + hash)) {
                    return jsonResponse(route, transactionDetails(hash));
                }
            }
        }

        // CoinDesk price tick
        if (url.hostname.endsWith("coindesk.com")) {
            return jsonResponse(route, { Data: {} });
        }

        // The interception canary's own request. Answered with nothing: what
        // is being observed is that it arrived here at all.
        if (url.href === WORKER_PROBE_URL) {
            return route.fulfill({ status: 204, body: "" });
        }

        // Best-effort Etherscan address labels: served as an empty page.
        if (url.hostname.endsWith("etherscan.io")) {
            return route.fulfill({
                status: 200,
                contentType: "text/html",
                body: "<html><body></body></html>",
            });
        }

        report("unstubbed request: " + req.method() + " " + req.url());
        return route.abort();
    });

    return {
        /**
         * Resolve with the first service-worker-originated request this
         * handler saw, or null if none arrives within `ms`.
         *
         * The caller asks the worker for one request of its own (see
         * WORKER_PROBE_URL) and then waits here, so under working
         * interception this resolves almost immediately. Nothing arriving
         * means worker traffic is bypassing the handler entirely and going
         * to the real internet, which the caller turns into a hard failure
         * of the whole suite.
         */
        waitForServiceWorkerTraffic(ms) {
            if (firstWorkerRequest) return Promise.resolve(firstWorkerRequest);
            return new Promise((resolve) => {
                const timer = setTimeout(() => {
                    announceWorkerRequest = null;
                    resolve(null);
                }, ms);
                announceWorkerRequest = (req) => {
                    clearTimeout(timer);
                    announceWorkerRequest = null;
                    resolve(req);
                };
            });
        },
    };
}

module.exports = {
    installNetworkStubs,
    DAPP_HTML,
    DAPP_ORIGIN,
    DAPP_URL,
    PHISHING_DAPP_ORIGIN,
    PHISHING_DAPP_URL,
    WORKER_PROBE_URL,
    FEE_ESTIMATE_WEI,
    FEE_RESERVE_WEI,
    STUB_COUNTERPARTY,
    STUB_NATIVE_TX_HASH,
    STUB_NATIVE_VALUE_WEI,
    STUB_TOKEN,
    STUB_TX_HASH,
};
