// End-to-end harness: launches a real Chromium with the unpacked MV3
// build loaded, collects every uncaught page error and console.error, and
// exposes the popup flows the tests drive.
//
// This runs inside the pinned Playwright container; see script/test-e2e.
// It is deliberately NOT part of make check — REPO_POLICIES.md caps
// make test at 20 seconds and a browser suite does not fit.

"use strict";

const fs = require("fs");
const os = require("os");
const path = require("path");

const { chromium } = require("playwright-core");
const { installNetworkStubs, WORKER_PROBE_URL } = require("./network");

const REPO_ROOT = path.resolve(__dirname, "..", "..");
const EXT_PATH = path.join(REPO_ROOT, "dist", "chrome");

// Page errors that are known, tracked, and deliberately tolerated. Every
// entry must name the issue that will remove it. This list is the one
// concession in an otherwise zero-tolerance policy: an uncaught error is
// how this harness caught issue #150 in the first place.
//
// Empty, and worth keeping that way. Its only entry was the WASM
// CompileError libsodium provoked on every popup load, deleted with #182
// when both manifests started allowing WASM; the run that used to need it
// is now the run that proves the fix.
const ALLOWED_ERRORS = [];

function isAllowed(text) {
    return ALLOWED_ERRORS.some((a) => a.pattern.test(text));
}

// Collects every uncaught page error, console.error and unstubbed
// request, and hands each one to exactly one reporter.
//
// This deliberately has NO window API. It used to expose mark()/since()
// so a test could ask for "the errors since I started", and that shape
// produced a green run that proved nothing twice over: first the mark
// started after test 1, so everything recorded during launch was
// discarded, then the tail after the final test was never read at all. In
// both cases a record fell outside somebody's window and vanished, which
// is the precise failure this harness exists to prevent.
//
// So there is no window left to fall outside of. take() is the only
// reader and it always takes everything outstanding, so successive takes
// partition the entire record stream with no gaps, and the runner turns
// every record it reads into a failure.
//
// Observation ends when the browser context is closed. Nothing records
// after that — the route handler and the console listeners are gone with
// the context — so there is no post-teardown phase to collect, and this
// class deliberately offers no mechanism pretending to cover one.
//
// One narrow exception exists, and it is not a mute: expect(). A test that
// drives a failure path on purpose — a refused gas estimate, say — provokes
// the console.error the code is supposed to emit, and that error is the
// behaviour under test rather than an escape. Declaring it consumes exactly
// one matching record and no more, and an expectation nothing matched fails
// its test just as an unexpected error does. So it cannot be used to
// silence anything: it can only assert that a specific error happened.
class ErrorCollector {
    constructor() {
        this.entries = [];
        this.taken = 0;
        this.expectations = [];
    }

    // Declare a console.error this test is about to cause deliberately.
    // `label` names it in the failure message if it never arrives.
    expect(label, pattern) {
        this.expectations.push({ label, pattern, matched: false });
    }

    // Declared expectations that nothing matched, clearing the list so each
    // test starts with none outstanding.
    unmatchedExpectations() {
        const out = this.expectations
            .filter((e) => !e.matched)
            .map((e) => e.label);
        this.expectations = [];
        return out;
    }

    record(kind, text) {
        const line = kind + ": " + String(text).split("\n")[0];
        if (isAllowed(line)) return;
        const expected = this.expectations.find(
            (e) => !e.matched && e.pattern.test(line),
        );
        if (expected) {
            expected.matched = true;
            return;
        }
        this.entries.push(line);
    }

    // Everything recorded since the previous take(). Never yields a
    // record twice and never skips one.
    take() {
        const out = this.entries.slice(this.taken);
        this.taken = this.entries.length;
        return out;
    }
}

function attachErrorListeners(ctx, errors) {
    const attachPage = (page) => {
        page.on("pageerror", (err) => {
            errors.record("pageerror", err.message || String(err));
        });
        page.on("console", (msg) => {
            if (msg.type() === "error") {
                errors.record("console.error", msg.text());
            }
        });
    };
    ctx.pages().forEach(attachPage);
    ctx.on("page", attachPage);
    // Per-page listeners only: the context-level "weberror" event covers
    // the same page exceptions and would double-report them. Playwright
    // exposes no error EVENT for service workers, so an uncaught
    // exception in the background worker is not visible here — everything
    // this suite drives lives in the popup page. That is an error-channel
    // gap only: worker NETWORK traffic is intercepted and reported like
    // any other, and assertWorkerTrafficIntercepted() below fails the run
    // if it ever stops being.
}

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

// The most recently seen background worker, waiting for one if none has
// appeared yet. Most recent rather than first: Chrome stops an idle MV3
// worker and starts a fresh one on the next event, and a handle to a
// stopped worker cannot be evaluated in.
async function serviceWorker(ctx) {
    const workers = ctx.serviceWorkers();
    const latest = workers[workers.length - 1];
    if (latest) return latest;
    return ctx.waitForEvent("serviceworker", { timeout: 30000 });
}

// How long to wait for the probe request the worker is asked to make.
const WORKER_TRAFFIC_TIMEOUT_MS = 30000;

// ctx.route() only sees service-worker requests when Playwright runs with
// PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1, which script/test-e2e
// sets. Without it every fetch the background worker makes goes to the
// real internet and nothing says so. A harness whose isolation can lapse
// in silence is worthless, so this does not take the flag on trust: a
// request the worker itself issues has to show up in the route handler,
// or the suite refuses to run.
//
// The anchor is a probe the harness asks the worker for, not traffic the
// extension generates on its own. It used to be the phishing blocklist
// fetch src/background/index.js issued at startup; that fetch is gone —
// the blocklist is vendored at build time and the extension contacts
// nobody when it starts — so there is no longer any startup traffic to
// observe and the check generates its own.
//
// Evaluating in the worker straight after launch does not work, and that
// is not a stale observation: it was tried again here and failed with
// "Target page, context or browser has been closed" on the first run.
// Chrome stops the freshly registered worker as soon as it has nothing to
// do, and the extension no longer gives it anything to do — which is the
// same change that removed the old anchor. So the probe wakes the worker
// before it evaluates in it, by sending it a message from an extension
// page and waiting for the reply: delivering a message is what starts a
// stopped worker, and a worker that has just answered one is alive.
// The evaluated fetch is not awaited, so nothing in the worker is held
// open by the probe either.
async function wakeWorker(ctx) {
    const sw = await serviceWorker(ctx);
    const extensionId = new URL(sw.url()).host;
    const page = await ctx.newPage();
    try {
        await page.goto(
            "chrome-extension://" + extensionId + "/src/popup/index.html",
        );
        // eth_chainId is answered from local state: it wakes the worker
        // and changes nothing.
        await page.evaluate(
            () =>
                new Promise((resolve) => {
                    chrome.runtime.sendMessage(
                        {
                            type: "AUTISTMASK_RPC",
                            method: "eth_chainId",
                            params: [],
                        },
                        () => resolve(null),
                    );
                }),
        );
    } finally {
        await page.close();
    }
}

async function probeFromWorker(ctx, url) {
    let lastError = null;
    for (let attempt = 0; attempt < 5; attempt++) {
        try {
            await wakeWorker(ctx);
            const sw = await serviceWorker(ctx);
            await sw.evaluate((u) => {
                // Deliberately not awaited and never rejected: what is
                // being observed is that the request reaches the route
                // handler, and an unhandled rejection in the worker would
                // be collected as a suite error if it did not.
                fetch(u).catch(() => {});
            }, url);
            return;
        } catch (e) {
            lastError = e;
            await sleep(500);
        }
    }
    throw new Error(
        "could not ask the background worker to fetch " +
            url +
            ", so service-worker interception was never tested. Last " +
            "error: " +
            (lastError && lastError.message),
    );
}

async function assertWorkerTrafficIntercepted(ctx, stubs) {
    await probeFromWorker(ctx, WORKER_PROBE_URL);

    const seen = await stubs.waitForServiceWorkerTraffic(
        WORKER_TRAFFIC_TIMEOUT_MS,
    );
    if (seen) return seen;

    // State the observation, not a conclusion. This fires for at least
    // two quite different causes and the harness cannot tell them apart
    // from here, so guessing one of them in the message sends the reader
    // the wrong way.
    throw new Error(
        "observed no service-worker request in the route handler within " +
            WORKER_TRAFFIC_TIMEOUT_MS +
            "ms, although the background worker was asked to fetch " +
            WORKER_PROBE_URL +
            ". Two causes are plausible and this check cannot distinguish " +
            "them: (1) service-worker interception is not in effect, so " +
            "that request went to the real internet unobserved — the suite " +
            "must be run through script/test-e2e, which sets " +
            "PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1, and a " +
            "Playwright upgrade may have dropped or renamed that flag; " +
            "(2) the probe never ran, because the worker was torn down " +
            "between being handed over and being evaluated in. Either way " +
            "the fix is a replacement mechanism or an honest downgrade of " +
            "the isolation claims in tests/e2e/network.js and README.md — " +
            "not deleting this check",
    );
}

async function launch(routeOpts) {
    if (!fs.existsSync(path.join(EXT_PATH, "manifest.json"))) {
        throw new Error(
            "no unpacked build at " +
                EXT_PATH +
                " — run make build before the e2e suite",
        );
    }

    const userDir = fs.mkdtempSync(path.join(os.tmpdir(), "autistmask-e2e-"));
    const ctx = await chromium.launchPersistentContext(userDir, {
        // channel: "chromium" is load-bearing. The default headless mode
        // uses the headless shell, which silently refuses to load
        // extensions: there is no error at all, the service worker simply
        // never appears. This cost real debugging time once already.
        channel: "chromium",
        headless: true,
        args: [
            "--disable-extensions-except=" + EXT_PATH,
            "--load-extension=" + EXT_PATH,
            // The container runs unprivileged; Chrome's sandbox needs
            // capabilities the harness deliberately does not grant it.
            "--no-sandbox",
            // Belt to the interception braces: nothing that slips past
            // the route handler can resolve a name, so a request that
            // escapes cannot actually reach the internet. Detection is
            // still assertWorkerTrafficIntercepted()'s job — this only
            // bounds the damage while a gap goes unnoticed. Playwright
            // fulfils routed requests without touching the resolver, and
            // it drives the browser over a pipe, so neither is affected.
            "--host-resolver-rules=MAP * ~NOTFOUND",
        ],
    });

    const cleanup = async () => {
        await ctx.close().catch(() => {});
        fs.rmSync(userDir, { recursive: true, force: true });
    };

    try {
        const errors = new ErrorCollector();
        attachErrorListeners(ctx, errors);
        routeOpts.report = (text) => errors.record("network", text);
        const stubs = await installNetworkStubs(ctx, routeOpts);

        await assertWorkerTrafficIntercepted(ctx, stubs);

        // The extension id is derived from the unpacked path, so it
        // changes and must never be hardcoded. It is the host part of the
        // service worker URL.
        const sw = await serviceWorker(ctx);
        const id = new URL(sw.url()).host;

        return {
            ctx,
            errors,
            extensionId: id,
            popupUrl: "chrome-extension://" + id + "/src/popup/index.html",
            close: cleanup,
        };
    } catch (e) {
        // Anything that fails after the browser is up has to tear it down
        // on the way out: an orphaned context keeps node alive forever,
        // turning a clean failure into a hung run.
        await cleanup();
        throw e;
    }
}

// ---------------------------------------------------------------- flows

const PASSWORD = "e2e-harness-password";

async function visible(page, selector, timeout = 15000) {
    await page.waitForSelector(selector, { state: "visible", timeout });
}

// An empty WebAssembly module: magic number and version header, no
// sections. Compiling it in the popup asks the one question that decides
// libsodium's backend — may this realm compile WebAssembly — of the real
// page under the real shipped manifest, which is the only place the
// answer can be observed. Kept independent of src/shared/vault.js on
// purpose: a bundle asked to grade itself proves less than an outside
// observation of the same realm.
const EMPTY_WASM_MODULE = [0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00];

async function pageCompilesWasm(page) {
    return page.evaluate(async (bytes) => {
        try {
            await WebAssembly.compile(new Uint8Array(bytes));
            return true;
        } catch {
            return false;
        }
    }, EMPTY_WASM_MODULE);
}

async function openPopup(ctx, popupUrl) {
    const page = await ctx.newPage();
    await page.goto(popupUrl);
    return page;
}

// Full wallet creation through the real UI: BIP-39 generation, libsodium
// vault encryption and extension storage persistence, for real.
//
// Returns the recovery phrase it generated. Tests that assert on a secret
// need the real value — checking for "some 12 words" would pass against the
// wrong wallet's phrase, and checking for nothing at all would pass against
// a screen that shows the phrase it was supposed to hide.
async function createWallet(page) {
    await page.click("#btn-welcome-add");
    await visible(page, "#view-add-wallet");
    await page.click("#btn-generate-phrase");
    await page.waitForFunction(() => {
        const el = document.getElementById("wallet-mnemonic");
        return el && el.value.trim().split(/\s+/).length >= 12;
    });
    const phrase = (await page.inputValue("#wallet-mnemonic")).trim();
    await page.fill("#add-wallet-password", PASSWORD);
    await page.fill("#add-wallet-password-confirm", PASSWORD);
    await page.click("#btn-add-wallet-confirm");
    await visible(page, "#view-main", 60000);
    return phrase;
}

// Reach the address detail screen of the FIRST address of the first wallet,
// from wherever the popup restored to. Clicking .address-row does not open
// it; the [info] button does.
//
// .first() rather than a bare selector because the suite adds a second
// wallet partway through, and every later test would otherwise die in
// Playwright's strict mode rather than on an assertion.
async function openAddressDetail(page) {
    const onAddress = await page.isVisible("#view-address");
    if (!onAddress) {
        await visible(page, "#view-main");
        await page.locator("#wallet-list .btn-addr-info").first().click();
    }
    await visible(page, "#view-address");
}

module.exports = {
    PASSWORD,
    createWallet,
    launch,
    openAddressDetail,
    openPopup,
    pageCompilesWasm,
    visible,
};
