// A loopback dApp origin and stub Ethereum node for the Firefox suite.
//
// The Firefox container runs with --network none, and the harness note in
// driver.js records the consequence: with no http:// origin in reach, no
// content script was ever injected, so content-script behaviour was
// UNVERIFIED and the dApp flows could not be driven at all.
//
// --network none removes every interface except loopback, and loopback is
// enough. This serves the page and the JSON-RPC endpoint from 127.0.0.1
// inside the same container Firefox runs in, so the dApp round trips execute
// against a real http:// origin and the run stays as offline as it was: the
// only reachable peer is this process.
//
// The page itself is not written twice. DAPP_HTML comes from the Chrome
// suite's fixture, so both harnesses drive the same __dapp API and the same
// message log.
//
// Unlike driver.js this file does use ethers, and it has to: the node has to
// answer eth_sendRawTransaction with the hash ethers computes for the
// artifact it was handed, or provider.broadcastTransaction() refuses the
// answer, and the suite recovers signatures itself rather than believing the
// extension's own verdict.

"use strict";

const http = require("http");

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

const { DAPP_HTML } = require("../network");

// The same fee shape the Chrome suite uses, for the same reason: it has to
// pass the ceilings in src/shared/approvalVerify.js and it has to leave the
// reserve and the estimate distinguishable.
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;

const STUB_BLOCK_NUMBER = 21000000;

// A 32-byte zero word, returned for every eth_call. It is what makes ethers'
// ENS reverse lookup resolve to "no resolver set" instead of throwing, and a
// throw there reaches the console through src/shared/log.js, which fails the
// run on its own.
const ZERO_WORD = "0x" + "0".repeat(64);

// One ETH, so the popup's balance lines render something and the wallet does
// not look empty on the approval screen.
const STUB_BALANCE_WEI = 10n ** 18n;

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

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: "0xc0ffee0000000000000000000000000000c0ffee",
        extraData: "0x",
        baseFeePerGas: hex(BASE_FEE_WEI),
        transactions: [],
    };
}

const RPC_RESULTS = {
    eth_chainId: "0x1",
    net_version: "1",
    eth_blockNumber: hex(STUB_BLOCK_NUMBER),
    eth_getBalance: hex(STUB_BALANCE_WEI),
    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),
    // "accepted but not mined", which is what a node says about a transaction
    // it has only just taken. The wait screen the approval hands off to polls
    // this for the rest of the run.
    eth_getTransactionReceipt: null,
    web3_clientVersion: "autistmask-e2e-firefox/0",
};

// Answer one JSON-RPC call. `broadcast` collects every raw transaction that
// reached this node, which is what the transaction assertions are made
// against — the artifact as the node saw it, never as the extension described
// it.
function rpcResult(req, state) {
    const method = req.method;

    if (method === "eth_sendRawTransaction") {
        const raw = req.params && req.params[0];
        state.broadcast.push(raw);
        // ethers checks the hash it is given against the hash it computes for
        // the artifact it sent, so this cannot be a fixed string.
        return Transaction.from(raw).hash;
    }

    if (method === "eth_getBlockByNumber" || method === "eth_getBlockByHash") {
        return latestBlock();
    }

    if (Object.prototype.hasOwnProperty.call(RPC_RESULTS, method)) {
        return RPC_RESULTS[method];
    }

    // Never a silent default. An unstubbed method answered with null looks
    // like a working node returning nothing, and the assertion downstream
    // fails somewhere unrelated.
    state.unstubbed.push(method);
    throw new Error("no fixture for JSON-RPC method " + method);
}

function readBody(req) {
    return new Promise((resolve, reject) => {
        let body = "";
        req.on("data", (chunk) => {
            body += chunk;
        });
        req.on("end", () => resolve(body));
        req.on("error", reject);
    });
}

function handleRpcBody(body, state) {
    const parsed = JSON.parse(body);
    const answer = (req) => {
        try {
            return {
                jsonrpc: "2.0",
                id: req.id,
                result: rpcResult(req, state),
            };
        } catch (e) {
            return {
                jsonrpc: "2.0",
                id: req.id,
                error: { code: -32601, message: e.message },
            };
        }
    };
    return Array.isArray(parsed) ? parsed.map(answer) : answer(parsed);
}

/**
 * Serve the dApp page and the stub node on loopback.
 *
 * @returns {Promise<Object>} the running fixture: `url` and `origin` of the
 * page, `rpcUrl` for the extension's rpcUrl setting, `broadcast` (the raw
 * transactions the node received, in order), `unstubbed` (JSON-RPC methods
 * nothing answered) and `close()`.
 */
async function startDappServer() {
    const state = { broadcast: [], unstubbed: [], requests: [] };

    const server = http.createServer((req, res) => {
        const url = new URL(req.url, "http://127.0.0.1");
        state.requests.push(req.method + " " + url.pathname);

        if (url.pathname === "/rpc" && req.method === "POST") {
            readBody(req)
                .then((body) => {
                    const payload = JSON.stringify(handleRpcBody(body, state));
                    res.writeHead(200, {
                        "Content-Type": "application/json",
                        // The extension fetches this from its background
                        // page, whose origin is moz-extension://. Without CORS
                        // the fetch fails and every transaction assertion
                        // fails for a reason that has nothing to do with the
                        // wallet.
                        "Access-Control-Allow-Origin": "*",
                    });
                    res.end(payload);
                })
                .catch((e) => {
                    res.writeHead(500, { "Content-Type": "text/plain" });
                    res.end(String(e && e.message));
                });
            return;
        }

        if (url.pathname === "/") {
            res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
            res.end(DAPP_HTML);
            return;
        }

        // An empty favicon rather than a 404: a 404 is a page error in
        // Firefox's console under some settings, and the suite fails the run
        // on those.
        if (url.pathname === "/favicon.ico") {
            res.writeHead(200, { "Content-Type": "image/x-icon" });
            res.end("");
            return;
        }

        res.writeHead(404, { "Content-Type": "text/plain" });
        res.end("not found");
    });

    await new Promise((resolve, reject) => {
        server.on("error", reject);
        // Port 0: this host runs many sessions at once, and a fixed port is a
        // guaranteed collision rather than a possible one.
        server.listen(0, "127.0.0.1", resolve);
    });

    const { port } = server.address();
    const origin = "http://127.0.0.1:" + port;

    return {
        origin,
        url: origin + "/",
        rpcUrl: origin + "/rpc",
        broadcast: state.broadcast,
        unstubbed: state.unstubbed,
        requests: state.requests,
        close: () =>
            new Promise((resolve) => {
                server.closeAllConnections();
                server.close(() => resolve());
            }),
    };
}

module.exports = {
    GAS_LIMIT,
    GAS_PRICE_WEI,
    STUB_BALANCE_WEI,
    startDappServer,
};
