// Does the wallet survive being installed again on Firefox?
//
// This is the question behind
// https://git.eeqj.de/sneak/AutistMask/issues/310, and it is the one property
// that has to hold before real money goes into this extension. The only route
// that works on release Firefox is a TEMPORARY add-on, which is unloaded when
// the browser exits: daily use means adding it again from about:debugging on
// every browser start. If extension storage did not survive that, every start
// would present an empty wallet and the recovery phrase would be the only copy
// of the money.
//
// manifest/firefox.json declares a fixed browser_specific_settings.gecko.id,
// which is the right SHAPE for storage to survive — Firefox keys the storage
// area by add-on id — but shape is not observation, and nothing asserted it.
//
// Two different things are asked here, because they have different answers and
// conflating them would be the whole mistake:
//
//   RESTART      one profile, two browser runs, the add-on added temporarily
//                in each. This is what a user does every day, and the vault
//                has to survive it.
//   REMOVAL      an explicit uninstall, the way about:addons "Remove" works,
//                inside one browser run. Firefox destroys an add-on's storage
//                when it is uninstalled, and the observed result is recorded
//                here rather than wished away — for a wallet it means Remove
//                is irreversible except from the recovery phrase.
//
// The vault is not merely compared as bytes in the restart case. It is
// DECRYPTED with the original password through the real Show Recovery Phrase
// screen and the phrase is compared against the one wallet creation produced,
// because "the ciphertext is still in storage" and "the wallet still works"
// are different claims and only the second one is worth anything.
//
// Run through script/test-e2e-firefox, which builds the artifact and the
// pinned container. The one argument is what to install — an unpacked
// directory or an .xpi. With none, the packaged XPI in release/ is used, so
// this doubles as the check that the release artifact installs in a real
// Firefox.
//
//   node tests/e2e/firefox/reinstall.js release/autistmask-firefox-0.1.0.xpi
//
// A separate program from run.js rather than another step in it: every step
// there shares one browser session with one installed add-on, and this needs
// two browsers and three installs.

"use strict";

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

const { EXTENSION_ID, EXTENSION_UUID, start } = require("./driver");

const REPO_ROOT = path.resolve(__dirname, "..", "..", "..");
const PASSWORD = "e2e-harness-password";

const STEP_TIMEOUT_MS = 120000;

const checks = [];
let failed = 0;

function check(name, cond, detail) {
    checks.push(name);
    if (cond) {
        console.log("ok " + checks.length + " - " + name);
    } else {
        failed += 1;
        console.log("not ok " + checks.length + " - " + name);
        if (detail) console.log("  " + detail);
    }
}

// The moz-extension:// uuid Firefox currently serves this add-on from, read
// out of the pref that holds the mapping. Privileged scope, because that pref
// is not reachable from content.
//
// It has to be read after every install and never assumed. driver.js pins a
// uuid through extensions.webextensions.uuids at each session start, but an
// uninstall inside a running session drops that mapping and the next install
// mints a fresh one — and navigating to the stale origin does not fail, it
// HANGS until the session times out, which is how this was found.
async function extensionUuid(d) {
    const raw = await d.executeChrome(
        `return Services.prefs.getStringPref(
             "extensions.webextensions.uuids", "{}");`,
    );
    let map;
    try {
        map = JSON.parse(raw);
    } catch (e) {
        throw new Error(
            "extensions.webextensions.uuids is not JSON (" +
                e.message +
                "): " +
                raw,
        );
    }
    return map[EXTENSION_ID] || null;
}

async function openPopup(d) {
    const uuid = await extensionUuid(d);
    if (!uuid) {
        throw new Error(
            "no uuid for " +
                EXTENSION_ID +
                " in extensions.webextensions.uuids, so the popup has no " +
                "origin to be served from",
        );
    }
    await d.navigate("moz-extension://" + uuid + "/src/popup/index.html");
    // Whichever screen it lands on, wait for one of the two it can land on.
    // Waiting for #view-main directly would time out rather than say what
    // happened, and an empty storage partition is exactly the case where it
    // lands on the other one.
    await d.waitFor(
        "the popup to finish restoring",
        `const w = document.getElementById("view-welcome");
         const m = document.getElementById("view-main");
         if (!w || !m) return false;
         const shown = (el) => {
             const r = el.getBoundingClientRect();
             return r.width > 0 && r.height > 0;
         };
         return shown(w) || shown(m);`,
        [],
        STEP_TIMEOUT_MS,
    );
    return uuid;
}

// The persisted record, straight out of extension storage, read from the popup
// page — the one moz-extension:// document this program opens and therefore
// the only place the storage API is reachable from.
async function readVault(d) {
    const outcome = await d.executeAsync(
        `const done = arguments[arguments.length - 1];
         const api = typeof browser !== "undefined" ? browser : chrome;
         Promise.resolve(api.storage.local.get("autistmask"))
             .then((r) => {
                 const s = r.autistmask;
                 if (!s) return done({ present: false });
                 const w = (s.wallets || [])[0];
                 if (!w) return done({ present: false });
                 done({
                     present: true,
                     wallets: s.wallets.length,
                     name: w.name,
                     xpub: w.xpub,
                     address: (w.addresses || []).map((a) => a.address)[0],
                     vault: JSON.stringify(w.encryptedSecret),
                 });
             })
             .catch((e) => done({ error: String((e && e.message) || e) }));`,
    );
    if (outcome && outcome.error) {
        throw new Error("could not read extension storage: " + outcome.error);
    }
    return outcome;
}

async function createWallet(d) {
    await d.click("#btn-welcome-add");
    await d.waitVisible("#view-add-wallet");
    await d.click("#btn-generate-phrase");
    await d.waitFor(
        "a generated recovery phrase of at least 12 words",
        `const el = document.getElementById("wallet-mnemonic");
         return !!el && el.value.trim().split(/\\s+/).length >= 12;`,
    );
    const phrase = (await d.value("#wallet-mnemonic")).trim();
    await d.fill("#add-wallet-password", PASSWORD);
    await d.fill("#add-wallet-password-confirm", PASSWORD);
    await d.click("#btn-add-wallet-confirm");
    // Argon2id under libsodium, for real.
    await d.waitVisible("#view-main", STEP_TIMEOUT_MS);
    return phrase;
}

// The phrase the vault decrypts to, obtained the way a user would: Settings,
// Show Recovery Phrase, the original password.
async function revealPhrase(d) {
    if (!(await d.isVisible("#view-settings"))) {
        await d.click("#btn-settings");
    }
    await d.waitVisible("#view-settings");
    await d.click("#settings-wallet-list .btn-show-phrase");
    await d.waitVisible("#view-show-phrase");
    await d.fill("#show-phrase-password", PASSWORD);
    await d.click("#btn-show-phrase-reveal");
    await d.waitVisible("#show-phrase-result", STEP_TIMEOUT_MS);
    return (await d.text("#show-phrase-value")).trim();
}

// What to install. An explicit argument wins; with none, the packaged XPI in
// release/ is used, and there is deliberately no fallback to dist/firefox: the
// point of running this against the artifact is that the artifact is what gets
// installed, and quietly testing something else instead would leave the XPI
// unexercised while the run stayed green.
function resolveArtifact() {
    if (process.argv[2]) return path.resolve(REPO_ROOT, process.argv[2]);

    const releaseDir = path.join(REPO_ROOT, "release");
    const xpis = fs.existsSync(releaseDir)
        ? fs.readdirSync(releaseDir).filter((f) => f.endsWith(".xpi"))
        : [];
    if (xpis.length !== 1) {
        throw new Error(
            "expected exactly one .xpi in release/, found " +
                xpis.length +
                " (" +
                xpis.join(", ") +
                "). Run make package, or name the artifact as the argument.",
        );
    }
    return path.join(releaseDir, xpis[0]);
}

async function main() {
    let artifact;
    try {
        artifact = resolveArtifact();
    } catch (e) {
        console.error("e2e-firefox-reinstall: " + e.message);
        process.exitCode = 1;
        return;
    }
    if (!fs.existsSync(artifact)) {
        console.error(
            "e2e-firefox-reinstall: nothing to install at " + artifact,
        );
        process.exitCode = 1;
        return;
    }
    console.log("# installing: " + artifact);

    // One profile, reused by both browser runs below. geckodriver would
    // otherwise make a throwaway one per session, and "survives a restart"
    // cannot be asked of a profile that does not.
    const profile = fs.mkdtempSync(
        path.join(os.tmpdir(), "autistmask-reinstall-profile-"),
    );

    let phrase = null;
    let before = null;
    let first;

    // --- run one: install, create a wallet, close the browser --------------
    try {
        first = await start();
        await first.newSession(profile);
    } catch (e) {
        // A browser we cannot start is a failure of this program, never an
        // absent one.
        console.error("e2e-firefox-reinstall: cannot run: " + e.message);
        if (first) await first.quit().catch(() => {});
        process.exitCode = 1;
        return;
    }

    try {
        const firstId = await first.installAddon(artifact);
        check(
            "the artifact installs and reports the manifest's gecko id",
            firstId === EXTENSION_ID,
            "installed add-on id is " +
                JSON.stringify(firstId) +
                ", expected " +
                JSON.stringify(EXTENSION_ID) +
                ". Without a stable id Firefox has no key to hang the " +
                "storage area on, and nothing below can hold.",
        );

        const firstUuid = await openPopup(first);
        await first.waitVisible("#view-welcome", STEP_TIMEOUT_MS);
        phrase = await createWallet(first);
        before = await readVault(first);
        check(
            "a wallet created through the UI is in extension storage",
            before.present && before.wallets === 1 && !!before.vault,
            JSON.stringify(before),
        );
        console.log(
            "# run 1 moz-extension uuid: " +
                firstUuid +
                (firstUuid === EXTENSION_UUID ? " (the pinned one)" : ""),
        );
    } catch (e) {
        failed += 1;
        console.log("# ERROR in run 1: " + (e && e.stack ? e.stack : e));
    } finally {
        await first.quit().catch(() => {});
    }

    // --- run two: same profile, add-on added again -------------------------
    //
    // This is the restart. The temporary add-on died with the previous
    // browser; the profile, and whatever Firefox kept in it, did not.
    let second;
    try {
        second = await start();
        await second.newSession(profile);
    } catch (e) {
        console.error(
            "e2e-firefox-reinstall: cannot restart the browser: " + e.message,
        );
        if (second) await second.quit().catch(() => {});
        process.exitCode = 1;
        return;
    }

    try {
        const secondId = await second.installAddon(artifact);
        check(
            "the add-on installs again after a browser restart with the " +
                "same id",
            secondId === EXTENSION_ID,
            "re-installed id is " + JSON.stringify(secondId),
        );

        const secondUuid = await openPopup(second);
        // The origin the popup is served from is not the thing that carries
        // the wallet, and the uuid is read live for exactly that reason. In
        // this run it comes back as the pinned one, because driver.js writes
        // extensions.webextensions.uuids into the profile at every session
        // start; an uninstall inside a running session drops the mapping and
        // the next install mints a fresh uuid instead. Either way the storage
        // area is keyed on the add-on id, never on this.
        console.log(
            "# run 2 moz-extension uuid: " +
                secondUuid +
                (secondUuid === EXTENSION_UUID
                    ? " (the pinned one, re-applied at session start)"
                    : " (freshly minted)"),
        );

        const onWelcome = await second.isVisible("#view-welcome");
        check(
            "after a restart and re-add, the extension does not come up as " +
                "a fresh install",
            !onWelcome,
            "the popup shows the welcome screen, which is what an empty " +
                "storage partition looks like: the wallet is gone and only " +
                "the recovery phrase would get it back.",
        );

        const after = await readVault(second);
        check(
            "the vault, xpub and first address survive the restart unchanged",
            after.present &&
                after.wallets === before.wallets &&
                after.vault === before.vault &&
                after.xpub === before.xpub &&
                after.address === before.address,
            "before: " +
                JSON.stringify(before) +
                " after: " +
                JSON.stringify(after),
        );

        if (after.present) {
            const revealed = await revealPhrase(second);
            check(
                "the vault still decrypts with the original password to the " +
                    "original recovery phrase",
                revealed === phrase,
                "the recovery phrase read back after the restart is not the " +
                    "one the wallet was created with",
            );
        } else {
            check(
                "the vault still decrypts with the original password to the " +
                    "original recovery phrase",
                false,
                "there was no vault left to decrypt",
            );
        }

        // --- an explicit removal, in the same browser run ------------------
        //
        // Leave the popup FIRST. Removing the add-on destroys every document
        // it serves, and the popup is this session's only window: uninstalling
        // while it is on screen discards the browsing context, and every
        // subsequent WebDriver command fails with "no such window" rather than
        // with anything about the add-on. Observed, not anticipated.
        await second.navigate("about:blank");
        await second.uninstallAddon(EXTENSION_ID);
        await second.installAddon(artifact);
        await openPopup(second);

        const afterRemoval = await readVault(second);
        console.log(
            "# after an explicit uninstall: " + JSON.stringify(afterRemoval),
        );
        // Recorded as observed behaviour, not as something this repo wants.
        // Firefox destroys an add-on's storage when it is uninstalled, and
        // that is correct of a browser — it is stated here, and in README.md,
        // because for a WALLET it means about:addons "Remove" is irreversible
        // except from the recovery phrase. If a Firefox ever stops doing it
        // this fails and the claim gets rewritten from a new observation.
        check(
            "an explicit uninstall DESTROYS the vault (observed Firefox " +
                "behaviour: Remove is irreversible, unlike a restart)",
            afterRemoval.present === false,
            "the vault survived an explicit uninstall: " +
                JSON.stringify(afterRemoval),
        );
    } catch (e) {
        failed += 1;
        console.log("# ERROR in run 2: " + (e && e.stack ? e.stack : e));
    } finally {
        await second.quit().catch(() => {});
        fs.rmSync(profile, { recursive: true, force: true });
    }

    console.log("1.." + checks.length);
    if (checks.length === 0) {
        console.log("# FAILED: this program asserted nothing");
        process.exitCode = 1;
        return;
    }
    console.log(
        "# " +
            (checks.length - failed) +
            "/" +
            checks.length +
            " checks passed",
    );
    if (failed > 0) {
        console.log("# FAILED");
        process.exitCode = 1;
    }
}

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