// A minimal WebDriver client for geckodriver, plus the privileged console
// reader the error assertions are built on. No npm dependencies: global
// fetch and child_process against geckodriver's HTTP API is less code than
// a driver library and keeps the harness at zero packages.
//
// Run through script/test-e2e-firefox, which builds dist/firefox/ and the
// pinned container around this. FIREFOX_BIN and GECKODRIVER locate the two
// binaries; the image sets both.

"use strict";

const { spawn } = require("child_process");
const net = require("net");

const FIREFOX_BIN = process.env.FIREFOX_BIN || "firefox";
const GECKODRIVER = process.env.GECKODRIVER || "geckodriver";

// The extension id declared in manifest/firefox.json, and the uuid the
// popup is served from. Firefox normally assigns that uuid randomly per
// profile, which would make the popup URL undiscoverable without querying
// privileged state; setting extensions.webextensions.uuids before launch
// pins it instead. This only works because the manifest declares a fixed
// browser_specific_settings.gecko.id — without one the mapping has no key.
const EXTENSION_ID = "autistmask@sneak.berlin";
const EXTENSION_UUID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
const EXTENSION_ORIGIN = "moz-extension://" + EXTENSION_UUID;

// The W3C web element identifier. Getting the last character wrong yields
// an element reference of "undefined" and a bewildering "element with the
// reference undefined is not known" from geckodriver, so findElement()
// below checks for the key rather than indexing blindly.
const WEB_ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf";

const SCRIPT_TIMEOUT_MS = 120000;
const DEFAULT_WAIT_MS = 20000;
const POLL_INTERVAL_MS = 100;

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

// An ephemeral port picked by the kernel, then handed to geckodriver.
// There is a race between closing this listener and geckodriver binding,
// but this host runs many sessions at once and a fixed 4444 is a
// guaranteed collision rather than a possible one.
function freePort() {
    return new Promise((resolve, reject) => {
        const srv = net.createServer();
        srv.on("error", reject);
        srv.listen(0, "127.0.0.1", () => {
            const { port } = srv.address();
            srv.close(() => resolve(port));
        });
    });
}

class WebDriverError extends Error {
    constructor(command, body) {
        const v = (body && body.value) || {};
        super(
            command +
                " failed: " +
                (v.error || "unknown error") +
                ": " +
                (v.message || JSON.stringify(body)),
        );
        this.name = "WebDriverError";
        this.error = v.error;
    }
}

class Driver {
    constructor(proc, base) {
        this.proc = proc;
        this.base = base;
        this.sessionId = null;
        this.context = "content";
    }

    async send(method, path, body) {
        const url = this.base + path;
        const res = await fetch(url, {
            method,
            headers: { "Content-Type": "application/json" },
            body: body === undefined ? undefined : JSON.stringify(body),
        });
        const text = await res.text();
        let parsed;
        try {
            parsed = JSON.parse(text);
        } catch {
            throw new Error(
                method + " " + path + ": non-JSON response: " + text,
            );
        }
        if (!res.ok) throw new WebDriverError(method + " " + path, parsed);
        return parsed.value;
    }

    session(method, path, body) {
        return this.send(method, "/session/" + this.sessionId + path, body);
    }

    // ------------------------------------------------------------ setup

    // `profileDir` reuses an existing profile directory in place instead of
    // letting geckodriver make a throwaway one. That is the only way to ask
    // what survives a browser RESTART, which for a Firefox add-on that can
    // only be installed temporarily is the question that decides whether the
    // extension is usable at all: a temporary add-on is unloaded when Firefox
    // exits, so every session begins by adding it again.
    async newSession(profileDir) {
        const prefs = {
            // See EXTENSION_UUID above. The pref is a string pref whose
            // value is itself JSON.
            "extensions.webextensions.uuids": JSON.stringify({
                [EXTENSION_ID]: EXTENSION_UUID,
            }),
            // The container has loopback and nothing else. Firefox's own
            // link-status detection can read that as "offline" and then
            // refuse every request, including the ones to the loopback dApp
            // origin the suite serves; this takes the decision away from it.
            "network.manage-offline-status": false,
            // Force the site-connection prompt down its windows.create()
            // fallback.
            //
            // src/background/index.js prefers the toolbar-anchored popup for
            // that one approval and opens a real window only when
            // openPopup() refuses. A panel is not a top-level browsing
            // context, so WebDriver cannot see it, list it or click in it —
            // the same blind spot the Chrome harness documents. Leaving this
            // at its default would make which path runs depend on whether a
            // headless Firefox counts as having had a user gesture, which is
            // not a thing to leave to chance in a suite that has to be able
            // to fail. The window path is shipped code and the same approval
            // id, so what is driven is real; what is NOT covered either way
            // is the panel presentation itself.
            "extensions.openPopupWithoutUserGesture.enabled": false,
        };

        const args = [
            "-headless",
            // Mandatory on Firefox 153: without it, navigating to
            // moz-extension:// and running chrome-context script both
            // fail with "unsupported operation".
            //
            // It grants the driver FULL CHROME PRIVILEGES over this
            // browser. Acceptable only because the browser is a
            // throwaway in a CI container; never point a session with
            // this flag at anything you care about.
            "-remote-allow-system-access",
        ];
        if (profileDir) args.push("-profile", profileDir);

        const value = await this.send("POST", "/session", {
            capabilities: {
                alwaysMatch: {
                    browserName: "firefox",
                    "moz:firefoxOptions": {
                        binary: FIREFOX_BIN,
                        args,
                        prefs,
                    },
                },
            },
        });
        this.sessionId = value.sessionId;
        await this.session("POST", "/timeouts", { script: SCRIPT_TIMEOUT_MS });
        return value;
    }

    // Installs the MV2 build, either from an unpacked directory or from an
    // XPI file. temporary:true bypasses signature checks — which is the only
    // way an UNSIGNED xpi installs at all, and the reason README.md says
    // release Firefox will refuse the artifact this repo produces — and the
    // add-on dies with the profile.
    //
    // Returns the add-on id Firefox assigned, which is
    // browser_specific_settings.gecko.id from the manifest and is what
    // uninstallAddon() takes.
    async installAddon(pathToAddon) {
        return this.session("POST", "/moz/addon/install", {
            path: pathToAddon,
            temporary: true,
        });
    }

    // Removes an installed add-on, the way clicking Remove in about:addons
    // does. tests/e2e/firefox/reinstall.js uses it to ask the one question
    // that decides whether this extension can be used at all on Firefox: does
    // the vault survive being removed and added again.
    async uninstallAddon(id) {
        return this.session("POST", "/moz/addon/uninstall", { id });
    }

    // Classic navigation on purpose. BiDi's browsingContext.navigate
    // refuses moz-extension:// URLs outright.
    async navigate(url) {
        await this.session("POST", "/url", { url });
    }

    async quit() {
        if (this.sessionId) {
            await this.session("DELETE", "").catch(() => {});
            this.sessionId = null;
        }
        this.proc.kill("SIGTERM");
    }

    // ---------------------------------------------------------- scripts

    async setContext(context) {
        if (this.context === context) return;
        await this.session("POST", "/moz/context", { context });
        this.context = context;
    }

    async execute(script, args = []) {
        await this.setContext("content");
        return this.session("POST", "/execute/sync", { script, args });
    }

    // The asynchronous form: the script is handed a resolve callback as its
    // last argument and the call settles when that is invoked. Everything
    // interesting about an extension page is promise-shaped — storage reads,
    // the provider's own request() — and /execute/sync cannot wait for any
    // of it.
    async executeAsync(script, args = []) {
        await this.setContext("content");
        return this.session("POST", "/execute/async", { script, args });
    }

    // Runs in the privileged chrome scope, where Services and Ci exist.
    async executeChrome(script, args = []) {
        await this.setContext("chrome");
        try {
            return await this.session("POST", "/execute/sync", {
                script,
                args,
            });
        } finally {
            await this.setContext("content");
        }
    }

    // ------------------------------------------------------- page waits

    // Polls a content-context expression until it returns truthy. Every
    // wait in the suite goes through here so a timeout always says which
    // condition it was waiting on rather than "timed out".
    async waitFor(what, script, args = [], timeout = DEFAULT_WAIT_MS) {
        const deadline = Date.now() + timeout;
        // Assigned on every path through the loop body before it is read, so
        // there is no initializer to give it.
        let last;
        for (;;) {
            try {
                const v = await this.execute(script, args);
                if (v) return v;
                last = null;
            } catch (e) {
                // A navigation or view swap in flight makes execute
                // throw; that is a not-yet, not a failure, until the
                // deadline says otherwise.
                last = e.message;
            }
            if (Date.now() >= deadline) {
                throw new Error(
                    "timed out after " +
                        timeout +
                        "ms waiting for " +
                        what +
                        (last ? " (last error: " + last + ")" : ""),
                );
            }
            await sleep(POLL_INTERVAL_MS);
        }
    }

    // Shown means shown: in the popup a view is switched by toggling a
    // "hidden" class, and an element that is present but collapsed is not
    // the thing a test means by visible.
    async waitVisible(selector, timeout = DEFAULT_WAIT_MS) {
        return this.waitFor(
            "selector " + selector + " to be visible",
            `const el = document.querySelector(arguments[0]);
             if (!el) return false;
             const r = el.getBoundingClientRect();
             return r.width > 0 && r.height > 0;`,
            [selector],
            timeout,
        );
    }

    async isVisible(selector) {
        return this.execute(
            `const el = document.querySelector(arguments[0]);
             if (!el) return false;
             const r = el.getBoundingClientRect();
             return r.width > 0 && r.height > 0;`,
            [selector],
        );
    }

    async count(selector) {
        return this.execute(
            "return document.querySelectorAll(arguments[0]).length;",
            [selector],
        );
    }

    async text(selector) {
        return this.execute(
            `const el = document.querySelector(arguments[0]);
             return el ? el.textContent : null;`,
            [selector],
        );
    }

    async title() {
        return this.session("GET", "/title");
    }

    // The id of the view element currently on top, which is what a
    // failing step needs to report: "the screen did not change" is only
    // useful if it says which screen it stayed on.
    async currentView() {
        return this.execute(
            `const views = document.querySelectorAll('[id^="view-"]');
             for (const v of views) {
                 const r = v.getBoundingClientRect();
                 if (r.width > 0 && r.height > 0) return v.id;
             }
             return null;`,
        );
    }

    // ----------------------------------------------------- interactions

    async findElement(selector) {
        const value = await this.session("POST", "/element", {
            using: "css selector",
            value: selector,
        });
        const ref = value && value[WEB_ELEMENT_KEY];
        if (typeof ref !== "string") {
            throw new Error(
                "no " +
                    WEB_ELEMENT_KEY +
                    " in the element response for " +
                    selector +
                    ": " +
                    JSON.stringify(value),
            );
        }
        return ref;
    }

    // Real WebDriver clicks and real key events rather than in-page
    // .click() and value assignment: the popup's handlers are wired to
    // events, and synthesising them from inside the page would test the
    // harness's idea of the UI instead of the UI.
    async click(selector) {
        await this.waitVisible(selector);
        const id = await this.findElement(selector);
        await this.session("POST", "/element/" + id + "/click", {});
    }

    async fill(selector, value) {
        await this.waitVisible(selector);
        const id = await this.findElement(selector);
        await this.session("POST", "/element/" + id + "/clear", {});
        await this.session("POST", "/element/" + id + "/value", {
            text: String(value),
        });
    }

    async value(selector) {
        return this.execute(
            `const el = document.querySelector(arguments[0]);
             return el ? el.value : null;`,
            [selector],
        );
    }

    // ------------------------------------------------------------ windows
    //
    // The approval prompts this suite drives are separate top-level windows
    // the extension opens itself, so every one of them is a window handle
    // here and the suite has to move between them explicitly.

    async windowHandles() {
        return this.session("GET", "/window/handles");
    }

    async currentWindow() {
        return this.session("GET", "/window");
    }

    async switchToWindow(handle) {
        await this.setContext("content");
        await this.session("POST", "/window", { handle });
    }

    async newWindow(type = "window") {
        await this.setContext("content");
        const value = await this.session("POST", "/window/new", { type });
        return value.handle;
    }

    // Closes the current window and leaves the session on `fallback`, because
    // a session whose current window is gone fails every subsequent command
    // with "no such window" rather than with anything diagnosable.
    async closeWindow(fallback) {
        await this.setContext("content");
        await this.session("DELETE", "/window");
        if (fallback) await this.switchToWindow(fallback);
    }

    async url() {
        return this.session("GET", "/url");
    }

    // The handle of the first window whose URL matches, or null. Restores the
    // window that was current before the search either way: a probe that
    // silently relocates the session is a trap for the step after it.
    async findWindow(predicate) {
        const origin = await this.currentWindow();
        try {
            for (const handle of await this.windowHandles()) {
                await this.switchToWindow(handle);
                if (predicate(await this.url())) return handle;
            }
            return null;
        } finally {
            // Tolerated: the window the search started from may have been the
            // one that just closed, and a throw in here would replace the
            // real result with "no such window".
            await this.switchToWindow(origin).catch(() => {});
        }
    }
}

// ------------------------------------------------------- error capture

// Uncaught errors from extension code, read out of the privileged console
// service.
//
// This is not the obvious mechanism, and the obvious one does not work:
// WebDriver BiDi's log.entryAdded delivers NOTHING for extension pages.
// Verified on Firefox 142 and 153 against a same-session control — a plain
// http:// page yields uncaught errors with stack traces, the
// moz-extension:// popup yields zero events, because the remote agent
// excludes extension browsing contexts from BiDi observation. A harness
// built on Playwright-BiDi or Puppeteer-BiDi therefore sees nothing and
// reports success. Do not "simplify" this back to BiDi.
//
// nsIConsoleService is not per-page: it also carries errors from the
// background page, which BiDi would not have covered even if it worked.
// Background-page capture is verified by probe — a throw at the top of
// src/background/index.js, which kills the background page outright, fails
// the run.
//
// Content scripts ARE now exercised: tests/e2e/firefox/dapp.js serves a page
// from loopback, which survives --network none, and the suite drives the
// EIP-1193 round trips through the content script injected into it. What is
// still unproven is the CAPTURE, not the execution — no probe has forced a
// throw from inside a content script and watched it fail the run, so an
// uncaught content-script error arriving by this route remains an
// expectation rather than a demonstrated fact. Do not claim otherwise.
//
// Warnings are excluded so the semantics match Playwright's pageerror:
// uncaught errors only.
//
// The read and the clear are ONE chrome script on purpose. Splitting them
// into two round trips leaves a blind window between them in which an
// error is logged into a buffer that is about to be discarded, and is
// destroyed unread rather than deferred to the next drain. That was not
// theoretical: with a separate reset() call, a probe of 100 sequenced
// throws at 20ms spacing lost one of them outright.
const DRAIN_ERRORS_SCRIPT = `
    const origin = arguments[0];
    const out = [];
    for (const raw of Services.console.getMessageArray() || []) {
        let e;
        try {
            e = raw.QueryInterface(Ci.nsIScriptError);
        } catch (_) {
            continue;
        }
        if (e.flags & Ci.nsIScriptError.warningFlag) continue;
        const src = e.sourceName || "";
        if (!src.startsWith(origin)) continue;
        out.push({
            msg: e.errorMessage,
            src: src,
            line: e.lineNumber,
            cat: e.category,
        });
    }
    Services.console.reset();
    return out;
`;

class ConsoleErrors {
    constructor(driver, originPrefix) {
        this.driver = driver;
        this.originPrefix = originPrefix;
    }

    // Everything logged since the last take, read and cleared atomically
    // in a single chrome round trip. Poll-based, so an error is attributed
    // to the step that was running when it was drained, not to the moment
    // inside that step at which it happened — see the limitation note in
    // run.js. An error that arrives mid-drain is not lost — it makes this
    // batch or the next one — but the console service ring buffer holds
    // only 250 messages, so more than that between two takes evicts the
    // oldest unread. A clean run peaks at 4.
    async take() {
        const found = await this.driver.executeChrome(DRAIN_ERRORS_SCRIPT, [
            this.originPrefix,
        ]);
        return found || [];
    }
}

// ------------------------------------------------------------- startup

async function waitForDriverReady(base, timeoutMs) {
    const deadline = Date.now() + timeoutMs;
    for (;;) {
        try {
            const res = await fetch(base + "/status");
            if (res.ok) {
                const body = await res.json();
                if (body && body.value && body.value.ready !== false) return;
            }
        } catch {
            // not listening yet
        }
        if (Date.now() >= deadline) {
            throw new Error(
                "geckodriver did not become ready within " + timeoutMs + "ms",
            );
        }
        await sleep(POLL_INTERVAL_MS);
    }
}

async function start() {
    const port = await freePort();
    const proc = spawn(
        GECKODRIVER,
        ["--port", String(port), "--host", "127.0.0.1"],
        { stdio: ["ignore", "inherit", "inherit"] },
    );
    proc.on("error", (e) => {
        console.error("geckodriver failed to spawn: " + e.message);
    });

    const base = "http://127.0.0.1:" + port;
    try {
        await waitForDriverReady(base, 30000);
    } catch (e) {
        proc.kill("SIGKILL");
        throw e;
    }
    return new Driver(proc, base);
}

module.exports = {
    ConsoleErrors,
    Driver,
    EXTENSION_ID,
    EXTENSION_ORIGIN,
    EXTENSION_UUID,
    start,
    sleep,
};
