// End-to-end suite entrypoint. Run via script/test-e2e (which builds
// dist/chrome/ and starts the pinned container); running it directly
// requires a Chromium that playwright-core can find.
//
// A plain runner rather than jest on purpose: jest's default testMatch
// would pull these files into script/test, and browser tests do not fit
// inside the 20-second cap REPO_POLICIES.md puts on make test. Nothing
// here is named *.test.js for the same reason.

"use strict";

const {
    createWallet,
    launch,
    openAddressDetail,
    openPopup,
    visible,
} = require("./harness");
const { STUB_TOKEN, STUB_TX_HASH } = require("./network");

const TEST_TIMEOUT_MS = 120000;

// How long to keep collecting after the final test returns; see the
// trailing drain in main().
const TRAILING_WATCH_MS = 1500;

const tests = [];

function test(name, fn) {
    tests.push({ name, fn });
}

function assert(cond, message) {
    if (!cond) throw new Error(message);
}

function withTimeout(promise, name) {
    let timer;
    const timeout = new Promise((_, reject) => {
        timer = setTimeout(
            () =>
                reject(new Error("timed out after " + TEST_TIMEOUT_MS + "ms")),
            TEST_TIMEOUT_MS,
        );
    });
    return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}

// ----------------------------------------------------------------- tests

test("popup loads and reaches the welcome view", async (env) => {
    env.page = await openPopup(env.ctx, env.popupUrl);
    await visible(env.page, "#view-welcome");
    const title = await env.page.title();
    assert(title === "AutistMask", "unexpected popup title: " + title);
});

test("wallet creation through the UI reaches the main view", async (env) => {
    await createWallet(env.page);
    const addrCount = await env.page
        .locator("#wallet-list .btn-addr-info")
        .count();
    assert(addrCount > 0, "no addresses rendered in the wallet list");
});

test("add token screen opens from address detail (#150)", async (env) => {
    await openAddressDetail(env.page);
    await env.page.click("#btn-add-token");
    await visible(env.page, "#view-add-token");
    const quickPicks = await env.page
        .locator("#common-token-list .common-token")
        .count();
    assert(quickPicks > 0, "no common-token quick-pick buttons rendered");
});

test("transaction detail renders an ERC-20 transfer (#151)", async (env) => {
    // Serve the stubbed token transfer from here on, then reload so the
    // address detail screen refetches its transaction list.
    env.routeOpts.seedTokenTransfer = true;
    await env.page.reload();
    await openAddressDetail(env.page);

    await visible(env.page, "#tx-list .tx-row");
    const rowText = await env.page
        .locator("#tx-list .tx-row")
        .first()
        .innerText();
    assert(
        rowText.includes(STUB_TOKEN.symbol),
        "token transfer row missing symbol " +
            STUB_TOKEN.symbol +
            ", got: " +
            JSON.stringify(rowText),
    );

    await env.page.locator("#tx-list .tx-row").first().click();
    await visible(env.page, "#view-transaction");

    const hash = await env.page.locator("#tx-detail-hash").innerText();
    assert(
        hash.includes(STUB_TX_HASH),
        "transaction detail shows the wrong hash: " + hash,
    );

    // The token contract row is the field that crashes when
    // addressDotHtml is not imported: it renders only for transfers with
    // a contractAddress, which is every ERC-20 transfer.
    await visible(env.page, "#tx-detail-token-contract-section");
    const contract = env.page.locator("#tx-detail-token-contract");
    const contractText = await contract.innerText();
    assert(
        contractText.toLowerCase().includes(STUB_TOKEN.address),
        "token contract row missing the contract address, got: " +
            JSON.stringify(contractText),
    );
    const dots = await contract.locator('span[style*="border-radius"]').count();
    assert(dots > 0, "token contract row rendered without its colour dot");
});

// ---------------------------------------------------------------- runner

async function main() {
    // A suite that runs nothing must never report success. If a refactor
    // drops the registrations above, or a require() of this file stops
    // reaching them, the only honest outcome is a red run — reporting
    // "0/0 passed" and exiting 0 is the same vacuous-check failure this
    // whole harness exists to prevent.
    if (tests.length === 0) {
        console.log("1..0");
        console.log("# FAILED: the e2e suite registered no tests");
        process.exitCode = 1;
        return;
    }

    const routeOpts = { seedTokenTransfer: false };

    let session;
    try {
        session = await launch(routeOpts);
    } catch (e) {
        // Never skip and report success: a browser we cannot start, or
        // one whose network interception is not in force, is a failure of
        // the suite, not an absent one.
        console.error("e2e: cannot run the suite: " + e.message);
        process.exitCode = 1;
        return;
    }

    console.log("# extension id: " + session.extensionId);
    console.log("1.." + tests.length);

    const env = {
        ctx: session.ctx,
        popupUrl: session.popupUrl,
        routeOpts,
        page: null,
    };

    // Attribution of collected errors is total. session.errors has no
    // window API at all: take() always drains everything outstanding, so
    // successive takes partition the whole stream, and the phases below
    // cover the entire life of the run. Nothing the collector holds can
    // go unread.
    //
    //   launch .. end of test 1     -> test 1 (so the worker's startup
    //                                  fetches land on a test, not
    //                                  nowhere)
    //   end of test k .. end of k+1 -> test k+1
    //   last test .. teardown       -> the suite, via the trailing drain
    //
    // Those three phases cover the entire life of the browser context.
    // There is no fourth: once the context is closed nothing can record,
    // because the route handler and the console listeners died with it.
    // Traffic that a test defers past the trailing drain is therefore
    // never observed at all — a real limit of this design, stated in the
    // README, and not one any post-teardown hook could close.
    //
    // Two green-but-vacuous runs on this harness were the same shape: a
    // record falling outside somebody's window and being dropped. First
    // the mark started after test 1, discarding launch-time records;
    // then the tail after the last test was never read. Patching a
    // second boundary would have invited a third, so the window concept
    // is gone rather than fixed.
    let failed = 0;
    let n = 0;
    for (const t of tests) {
        n += 1;
        let failure = null;
        try {
            await withTimeout(t.fn(env), t.name);
        } catch (e) {
            failure = e.message;
        }

        // Any uncaught page error, console.error or unstubbed request
        // fails the test that provoked it, whether or not its assertions
        // passed. This is the mechanism that caught #150.
        const newErrors = session.errors.take();
        if (!failure && newErrors.length > 0) {
            failure = "uncaught browser errors during this test";
        }

        if (failure) {
            failed += 1;
            console.log("not ok " + n + " - " + t.name);
            console.log("  " + failure);
            for (const line of newErrors) {
                console.log("  " + line);
            }
        } else {
            console.log("ok " + n + " - " + t.name);
        }
    }

    // Keep watching after the last test returns, before tearing the
    // browser down. A request a test fires without awaiting is still in
    // flight when its function resolves; measured here it reaches the
    // route handler about 10ms later, but closing the context does not
    // wait for it — with no window at all the request dies unobserved
    // and the run goes green, which is exactly how escaping traffic
    // stays invisible.
    //
    // A fixed bounded window rather than a quiescence poll on purpose:
    // the collector being quiet is not evidence, because a request that
    // has not been dispatched yet has recorded nothing to be quiet
    // about. Playwright offers no "is anything in flight" question to
    // ask either — the route handler is the only observation point — so
    // a grace period is the mechanism available, and this one is ~150x
    // the measured latency for 1.5s on a ~25s suite.
    await new Promise((resolve) => setTimeout(resolve, TRAILING_WATCH_MS));

    await session.close();

    // The tail. These cannot be blamed on any single test, so they are
    // reported against the suite rather than guessed at — but they are
    // reported, and they fail the run.
    const trailing = session.errors.take();

    console.log(
        "# " + (tests.length - failed) + "/" + tests.length + " tests passed",
    );

    if (trailing.length > 0) {
        console.log(
            "# " +
                trailing.length +
                " browser error(s) recorded after the last test finished, " +
                "not attributable to any single test:",
        );
        for (const line of trailing) {
            console.log("#   " + line);
        }
    }

    if (failed > 0 || trailing.length > 0) {
        console.log("# FAILED");
        process.exitCode = 1;
    }
}

main().catch((e) => {
    console.error("e2e: " + (e && e.stack ? e.stack : e));
    process.exitCode = 1;
});
