// 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 the phishing
// blocklist fetch that src/background/index.js issues at worker startup
// silently reaches raw.githubusercontent.com on the open internet, and
// src/shared/phishingDomains.js swallows the failure so nothing surfaces
// it. 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";

// 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;

// Fixed instant so timeAgo() output is stable across runs.
const STUB_TX_TIMESTAMP = "2026-01-02T03:04:05.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);

const RPC_RESULTS = {
    eth_chainId: "0x1",
    net_version: "1",
    eth_blockNumber: "0x1406f40",
    eth_getBalance: "0x0",
    eth_call: ZERO_WORD,
    eth_gasPrice: "0x3b9aca00",
    eth_estimateGas: "0x5208",
    eth_getTransactionCount: "0x0",
    eth_maxPriorityFeePerGas: "0x3b9aca00",
};

function tokenObject() {
    return {
        address_hash: STUB_TOKEN.address,
        address: STUB_TOKEN.address,
        symbol: 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) {
    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(),
        },
    ];
}

// Full details for STUB_TX_HASH. raw_input is "0x" so the calldata
// decoder short-circuits; the on-chain detail fields still populate.
function transactionDetails() {
    return {
        hash: STUB_TX_HASH,
        block_number: STUB_BLOCK_NUMBER,
        nonce: 7,
        gas_used: "51000",
        gas_price: "1000000000",
        fee: { value: "51000000000000" },
        raw_input: "0x",
        status: "ok",
    };
}

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 handleRpc(route, postData, 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 batch of them, is not
    // RPC at all and must be reported like any other unrecognised
    // outbound traffic rather than dereferenced. request.postData()
    // returns null both for a bodyless POST and for a body Playwright
    // cannot decode as UTF-8 (sendBeacon with a Blob, or any binary
    // payload), so this is not an empty-string special case: it rejects
    // every non-object payload, exactly as the catch above rejects every
    // unparseable one.
    if (
        payload === null ||
        typeof payload !== "object" ||
        !batch.every((req) => req !== null && typeof req === "object")
    ) {
        report("unstubbed request: POST " + route.request().url());
        return route.abort();
    }
    const replies = batch.map((req) => {
        const result = RPC_RESULTS[req.method];
        if (result === undefined) {
            report("unstubbed RPC method: " + req.method);
            return {
                jsonrpc: "2.0",
                id: req.id,
                error: { code: -32601, message: "unstubbed in e2e harness" },
            };
        }
        return { jsonrpc: "2.0", id: req.id, result };
    });
    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.
 * @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 phishing blocklist fetch
    // showing up with an [sw] tag is the proof that the worker really is
    // intercepted and that the raw.githubusercontent.com stub below is
    // live code rather than decoration.
    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(), report);
        }

        // Blockscout v2
        if (p.includes("/api/v2/")) {
            if (/\/addresses\/0x[0-9a-fA-F]{40}\/transactions$/.test(p)) {
                return jsonResponse(route, { items: [] });
            }
            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)
                            : [],
                });
            }
            if (/\/addresses\/0x[0-9a-fA-F]{40}\/token-balances$/.test(p)) {
                return jsonResponse(route, []);
            }
            if (p.endsWith("/transactions/" + STUB_TX_HASH)) {
                return jsonResponse(route, transactionDetails());
            }
        }

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

        // MetaMask phishing blocklist
        if (
            url.hostname === "raw.githubusercontent.com" ||
            p.endsWith("/eth-phishing-detect/main/src/config.json")
        ) {
            return jsonResponse(route, {
                version: 2,
                tolerance: 2,
                fuzzylist: [],
                whitelist: [],
                blacklist: [],
            });
        }

        // 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 background worker fetches the phishing blocklist at
         * startup, unconditionally, within about a second of the context
         * coming up — 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,
    STUB_TOKEN,
    STUB_TX_HASH,
};
