// One wording for one condition (issues #172 and #265).
//
// Every screen that asks for the password decrypts the vault itself, and
// each one used to write its own sentence for the same failure: the send
// confirmation and the delete-wallet confirmation said "Wrong password."
// (a fragment, which RULES.md Language & Labeling forbids), the reveal
// screens said "That password is not correct.", and the two dApp approval
// paths said "That password is incorrect." A user hitting two of those
// minutes apart had no way to tell whether the wallet meant the same
// thing.
//
// This scans the source rather than driving six views, because the
// invariant is about the set of call sites and not about any one of them:
// a seventh screen that decrypts the vault has to join the set, and a
// DOM test per view cannot notice one that was never written.
//
// The assertions are per CALL SITE, not per file. approval.js decrypts in
// two places and is where the divergence came from; a per-file check that
// only asks whether the canonical sentence appears somewhere in the file
// passes while one of those two says something else entirely. So each
// call site is read back to its own catch handler and the prose that
// handler shows the user must be the canonical sentence and nothing else
// — which fails on a novel wording, not only on a known-superseded one.
//
// The empty-password condition (#265) is pinned the same way and off the
// same call sites: the private key export screen said "Password is
// required." where the other five said "Please enter your password." Each
// decrypt's password variable is walked back to the guard that rejects it
// when blank, and the prose that guard shows must be the canonical
// sentence. Anchoring on the decrypt keeps the wallet-creation screen out
// of the set: an empty field there is a password being CHOSEN, a
// different condition with its own wording.

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

const SRC = path.join(__dirname, "..", "src");

const CANONICAL = "That password is incorrect. Please try again.";
const CANONICAL_EMPTY = "Please enter your password.";

// Wordings this repo has actually shipped for the same condition. This is
// a secondary, whole-file sweep for stragglers outside a decrypt handler;
// divergence at a call site is caught by the exact-match assertion, which
// needs no list of phrasings to guess at.
const SUPERSEDED = [
    "Wrong password.",
    "That password is not correct. Please try again.",
    "Password is required.",
];

function jsFilesUnder(dir) {
    return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
        const full = path.join(dir, entry.name);
        if (entry.isDirectory()) return jsFilesUnder(full);
        return entry.name.endsWith(".js") ? [full] : [];
    });
}

// Blank out the interior of every comment and string literal, keeping the
// offsets and line breaks, so braces can be counted without a quote or a
// commented-out block throwing the count off. The literals are returned
// alongside with the offset of their opening quote, which is how a
// message is later attributed to the handler it sits in.
function scan(source) {
    const masked = source.split("");
    const strings = [];
    const blank = (from, to) => {
        for (let k = from; k < to; k++) if (masked[k] !== "\n") masked[k] = " ";
    };
    let i = 0;
    while (i < source.length) {
        const two = source.slice(i, i + 2);
        if (two === "//") {
            const nl = source.indexOf("\n", i);
            const stop = nl === -1 ? source.length : nl;
            blank(i, stop);
            i = stop;
        } else if (two === "/*") {
            const close = source.indexOf("*/", i + 2);
            const stop = close === -1 ? source.length : close + 2;
            blank(i, stop);
            i = stop;
        } else if (
            source[i] === '"' ||
            source[i] === "'" ||
            source[i] === "`"
        ) {
            const quote = source[i];
            let j = i + 1;
            let value = "";
            while (j < source.length && source[j] !== quote) {
                if (source[j] === "\\") {
                    value += source[j + 1];
                    j += 2;
                    continue;
                }
                value += source[j];
                j += 1;
            }
            blank(i + 1, j);
            strings.push({ offset: i, value });
            i = j + 1;
        } else {
            i += 1;
        }
    }
    return { masked: masked.join(""), strings };
}

// Offset of the `{` that opens the block containing `at`, or -1.
function enclosingBlockStart(masked, at) {
    let depth = 0;
    for (let i = at; i >= 0; i--) {
        if (masked[i] === "}") depth += 1;
        else if (masked[i] === "{") {
            if (depth === 0) return i;
            depth -= 1;
        }
    }
    return -1;
}

// Offset just past the `}` matching the `{` at `open`.
function blockEnd(masked, open) {
    let depth = 0;
    for (let i = open; i < masked.length; i++) {
        if (masked[i] === "{") depth += 1;
        else if (masked[i] === "}") {
            depth -= 1;
            if (depth === 0) return i + 1;
        }
    }
    throw new Error("unterminated block");
}

// The catch handler guarding a given decryptWithPassword call: walk out to
// the try block the call sits in, then take the catch that follows it.
function handlerSpan(masked, callOffset, label) {
    const tryOpen = enclosingBlockStart(masked, callOffset);
    if (tryOpen === -1 || !/\btry\s*$/.test(masked.slice(0, tryOpen)))
        throw new Error(`${label}: the decrypt is not inside a try block`);
    const rest = masked.slice(blockEnd(masked, tryOpen));
    const catchMatch = /^\s*catch\s*(\([^)]*\)\s*)?\{/.exec(rest);
    if (!catchMatch)
        throw new Error(`${label}: the decrypt's try block has no catch`);
    const catchOpen = blockEnd(masked, tryOpen) + catchMatch[0].length - 1;
    return [catchOpen, blockEnd(masked, catchOpen)];
}

// The prose the handler puts in front of the user. Element ids, class
// names and visibility keywords are single words; a sentence has a space
// in it, and that is the whole distinction needed here.
function handlerMessages(file, callOffset, label) {
    const { masked, strings } = scan(fs.readFileSync(file, "utf8"));
    const [from, to] = handlerSpan(masked, callOffset, label);
    return strings
        .filter((s) => s.offset >= from && s.offset < to)
        .map((s) => s.value)
        .filter((v) => v.includes(" "));
}

// The identifier a decrypt call passes as its password, which is what the
// empty-field guard for that screen tests.
function passwordArg(masked, callOffset, label) {
    const open = callOffset + "decryptWithPassword".length;
    const args = [];
    let depth = 0;
    let start = open + 1;
    for (let i = open; i < masked.length; i++) {
        const c = masked[i];
        if (c === "(" || c === "[" || c === "{") depth += 1;
        else if (c === ")" || c === "]" || c === "}") {
            depth -= 1;
            if (depth === 0) {
                args.push(masked.slice(start, i));
                break;
            }
        } else if (c === "," && depth === 1) {
            args.push(masked.slice(start, i));
            start = i + 1;
        }
    }
    const arg = (args[1] ?? "").trim();
    if (!/^[A-Za-z_$][\w$]*$/.test(arg))
        throw new Error(`${label}: password argument is not a name: ${arg}`);
    return arg;
}

// Innermost block enclosing the decrypt that also declares its password
// variable — the handler the screen's submit button runs, which is where
// the empty-field guard lives.
function declaringBlock(masked, callOffset, ident, label) {
    const declared = new RegExp(`\\b(?:const|let|var)\\s+${ident}\\s*=`);
    let at = callOffset;
    for (;;) {
        const open = enclosingBlockStart(masked, at);
        if (open === -1) throw new Error(`${label}: nothing declares ${ident}`);
        const end = blockEnd(masked, open);
        if (declared.test(masked.slice(open, end))) return [open, end];
        at = open - 1;
    }
}

// The prose the empty-field guard puts in front of the user. Exactly one
// guard per handler is required: two would mean the condition is answered
// in more than one place and this would be pinning only one of them.
function emptyGuardMessages(file, callOffset, label) {
    const { masked, strings } = scan(fs.readFileSync(file, "utf8"));
    const ident = passwordArg(masked, callOffset, label);
    const [from, to] = declaringBlock(masked, callOffset, ident, label);
    const guard = new RegExp(`if\\s*\\(\\s*!\\s*${ident}\\s*\\)\\s*\\{`, "g");
    const opens = [];
    let m;
    while ((m = guard.exec(masked.slice(from, to))) !== null)
        opens.push(from + m.index + m[0].length - 1);
    if (opens.length !== 1)
        throw new Error(
            `${label}: expected one empty-${ident} guard, found ${opens.length}`,
        );
    const close = blockEnd(masked, opens[0]);
    return strings
        .filter((s) => s.offset >= opens[0] && s.offset < close)
        .map((s) => s.value)
        .filter((v) => v.includes(" "));
}

// The call sites are found, not listed: the file layout moves (the private
// key export was in addressDetail.js when #172 was filed and is its own
// view now), and a hardcoded list would quietly stop covering a screen it
// no longer names.
function callSites() {
    const sites = [];
    for (const file of jsFilesUnder(SRC)) {
        if (file === path.join(SRC, "shared", "vault.js")) continue;
        const { masked } = scan(fs.readFileSync(file, "utf8"));
        const rel = path.relative(SRC, file).split(path.sep).join("/");
        let n = 0;
        let at = masked.indexOf("decryptWithPassword(");
        while (at !== -1) {
            n += 1;
            sites.push({ file, rel, offset: at, label: `${rel} #${n}` });
            at = masked.indexOf("decryptWithPassword(", at + 1);
        }
    }
    return sites.sort((a, b) => a.label.localeCompare(b.label));
}

describe("password failure messages", () => {
    const sites = callSites();
    const files = [...new Set(sites.map((s) => s.file))].sort();

    test("the call sites are found where they are expected", () => {
        const counts = {};
        for (const site of sites)
            counts[site.rel] = (counts[site.rel] ?? 0) + 1;
        expect(counts).toEqual({
            "popup/views/approval.js": 2,
            "popup/views/confirmTx.js": 1,
            "popup/views/deleteWallet.js": 1,
            "popup/views/exportPrivkey.js": 1,
            "popup/views/showPhrase.js": 1,
        });
    });

    test("the canonical messages are full sentences", () => {
        expect(CANONICAL).toMatch(/^[A-Z][^]*\.$/);
        expect(CANONICAL_EMPTY).toMatch(/^[A-Z][^]*\.$/);
    });

    // Exact equality, per call site: a message that is merely different
    // rather than known-obsolete fails here too, which a scan for historic
    // wordings cannot do.
    test.each(sites.map((s) => [s.label, s]))(
        "%s answers a rejected password with the canonical sentence",
        (label, site) => {
            expect(handlerMessages(site.file, site.offset, label)).toEqual([
                CANONICAL,
            ]);
        },
    );

    test.each(sites.map((s) => [s.label, s]))(
        "%s answers an empty password field with the canonical sentence",
        (label, site) => {
            expect(emptyGuardMessages(site.file, site.offset, label)).toEqual([
                CANONICAL_EMPTY,
            ]);
        },
    );

    test.each(files.map((f) => [path.relative(SRC, f), f]))(
        "%s carries no superseded wording",
        (_rel, file) => {
            const source = fs.readFileSync(file, "utf8");
            for (const old of SUPERSEDED) expect(source).not.toContain(old);
        },
    );
});
