// A stored profile the popup cannot read must produce a SCREEN, not a blank
// popup (https://git.eeqj.de/sneak/AutistMask/issues/311).
//
// The three corrupt blobs below are the ones the pre-1.0 audit wrote into
// storage. Against the build this file was added to, each one rendered nothing
// at all — no view, no message, no control — because init() dereferenced
// `state.wallets[0].addresses` on a record nothing had validated and threw
// before the first showView().
//
// So the assertions here are deliberately made through the REAL popup entry
// point rather than against the recovery view module directly. A recovery
// screen that renders perfectly when something calls it, and that nothing
// calls, is exactly the defect: what has to be true is that BOOTING the popup
// on a bad blob lands on it.
//
// The DOM stub is built FROM src/popup/index.html — every id in the markup,
// with the classes the markup gives it — so "which views are visible" is
// answered against the real element set, and a recovery screen with no markup
// behind it cannot pass.
//
// The fourth case is the upgrade one, and it is the case that must NOT reach
// the recovery screen: every install in the field has a valid profile with no
// version field, and showing those users a wipe prompt would be a worse defect
// than the one being fixed. It is migrated in place and keeps working.

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

const { makeStorageStub } = require("./support/storageStub");

const POPUP_HTML = fs.readFileSync(
    path.join(__dirname, "..", "src", "popup", "index.html"),
    "utf8",
);

// Fixed address, never used for anything but these tests.
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
// A fixed ERC-20 contract address, same rule.
const TOKEN_ADDRESS = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";

// ------------------------------------------------------------- fixtures

// A profile in the shape every install in the field has it: complete, valid,
// and carrying no version field, because no build ever wrote one.
function unversionedValidProfile() {
    return {
        hasWallet: true,
        wallets: [
            {
                type: "hd",
                name: "Wallet 1",
                xpub: "xpub-wallet-1",
                encryptedSecret: "encrypted-secret-1",
                nextIndex: 1,
                addresses: [
                    { address: ADDRESS, balance: "1.5", tokenBalances: [] },
                ],
            },
        ],
        activeAddress: ADDRESS,
        networkId: "mainnet",
        rpcUrl: "https://ethereum-rpc.publicnode.com",
        blockscoutUrl: "https://eth.blockscout.com/api/v2",
        allowedSites: { [ADDRESS]: ["dapp.example"] },
        deniedSites: {},
        trackedTokens: [],
        theme: "system",
    };
}

// The three blobs from the issue, each with the error it produced.
const CORRUPT_BLOBS = [
    {
        name: "wallets is a string",
        blob: { hasWallet: true, wallets: ADDRESS, activeAddress: ADDRESS },
    },
    {
        name: "wallets is an array of garbage",
        blob: {
            hasWallet: true,
            wallets: [null, 42, "wallet"],
            activeAddress: ADDRESS,
        },
    },
    {
        name: "future-schema blob (unknown fields, no version)",
        blob: {
            hasWallet: true,
            // A later schema that renamed the field and moved the key
            // material, written by a build this one knows nothing about, and
            // stamped with no version because this build never wrote one.
            wallets: [
                {
                    id: "wallet-1",
                    label: "Wallet 1",
                    accounts: [{ addr: ADDRESS, wei: "0x0" }],
                    keyring: { kind: "hd", vault: "…" },
                },
            ],
            profileFormat: "am-2",
            activeAccount: ADDRESS,
        },
    },
];

// ------------------------------------------------------------- DOM stub

function makeElement(id, className) {
    const classes = new Set(
        (className || "").split(/\s+/).filter((name) => name !== ""),
    );
    const el = {
        id,
        tagName: "DIV",
        textContent: "",
        value: "",
        innerHTML: "",
        href: "",
        download: "",
        disabled: false,
        style: {},
        dataset: {},
        listeners: {},
        clicked: 0,
        classList: {
            add: (...names) => names.forEach((n) => classes.add(n)),
            remove: (...names) => names.forEach((n) => classes.delete(n)),
            contains: (n) => classes.has(n),
            toggle: (n, force) => {
                const on = force === undefined ? !classes.has(n) : force;
                if (on) classes.add(n);
                else classes.delete(n);
                return on;
            },
        },
        addEventListener: (name, fn) => {
            el.listeners[name] = el.listeners[name] || [];
            el.listeners[name].push(fn);
        },
        removeEventListener: () => {},
        appendChild: () => {},
        remove: () => {},
        focus: () => {},
        select: () => {},
        setAttribute: (name, value) => {
            el[name] = value;
        },
        querySelector: () => null,
        querySelectorAll: () => [],
        click: () => {
            el.clicked += 1;
        },
    };
    return el;
}

// Every id in the markup, with the classes the markup gives it. A view the
// popup is supposed to reveal has to exist here, which means it has to exist
// in src/popup/index.html.
function idsFromHtml(html) {
    const out = new Map();
    const tags = html.match(/<[a-zA-Z][^>]*>/g) || [];
    for (const tag of tags) {
        const id = /\bid="([^"]+)"/.exec(tag);
        if (!id) continue;
        const cls = /\bclass="([^"]*)"/.exec(tag);
        out.set(id[1], cls ? cls[1] : "");
    }
    return out;
}

function makeDocument(html) {
    const authored = idsFromHtml(html);
    const els = new Map();
    for (const [id, className] of authored) {
        els.set(id, makeElement(id, className));
    }
    const created = [];
    const doc = {
        listeners: {},
        getElementById(id) {
            // Created on demand by updateDebugBanner(); absent is the state a
            // non-debug, non-testnet popup is in.
            if (id === "debug-banner") return null;
            if (!els.has(id)) els.set(id, makeElement(id, ""));
            return els.get(id);
        },
        createElement(tag) {
            const el = makeElement("created-" + tag, "");
            el.tagName = String(tag).toUpperCase();
            created.push(el);
            return el;
        },
        addEventListener(name, fn) {
            doc.listeners[name] = doc.listeners[name] || [];
            doc.listeners[name].push(fn);
        },
        querySelectorAll: () => [],
        documentElement: makeElement("html", ""),
        body: {
            prepend: () => {},
            appendChild: () => {},
            removeChild: () => {},
        },
        elements: els,
        authoredIds: authored,
        created,
    };
    return doc;
}

// ------------------------------------------------------------- harness

// Boot the real popup entry point over `stored`, exactly as the browser does:
// storage already holds the record, the page loads, DOMContentLoaded fires.
async function bootPopup(stored) {
    jest.resetModules();

    // The two modules that reach the network. Neither is on the path under
    // test; both would make this suite hit the internet.
    jest.doMock("../src/shared/prices", () => ({
        prices: {},
        refreshPrices: jest.fn(async () => {}),
        clearPrices: jest.fn(),
        getPrice: () => null,
        formatUsd: () => "",
        formatAddressTotal: () => "",
        getAddressValue: () => ({ usd: null, partial: false }),
        getWalletValue: () => ({ usd: null, partial: false }),
        getTotalValue: () => ({ usd: null, partial: false }),
    }));
    jest.doMock("../src/shared/balances", () => ({
        fetchTokenBalances: jest.fn(async () => []),
        refreshBalances: jest.fn(async () => {}),
        lookupTokenInfo: jest.fn(async () => null),
        getProvider: () => ({}),
        scanForAddresses: jest.fn(async () => []),
    }));
    jest.doMock("../src/shared/transactions", () => ({
        fetchRecentTransactions: jest.fn(async () => []),
        filterTransactions: () => [],
    }));

    const storage = makeStorageStub(
        stored === undefined ? {} : { autistmask: stored },
    );
    const document = makeDocument(POPUP_HTML);
    const reloads = [];

    globalThis.chrome = {
        storage: { local: storage.local },
        runtime: {
            sendMessage: jest.fn(async () => ({})),
            getURL: (p) => "chrome-extension://autistmask/" + p,
            onMessage: { addListener: () => {} },
        },
    };
    globalThis.document = document;
    globalThis.window = {
        location: {
            search: "",
            href: "chrome-extension://autistmask/src/popup/index.html",
            reload: () => reloads.push(Date.now()),
        },
        matchMedia: () => ({
            matches: false,
            addEventListener: () => {},
            removeEventListener: () => {},
        }),
        addEventListener: () => {},
    };
    // The 10s refresh loop init() starts would outlive the test.
    const realSetInterval = globalThis.setInterval;
    globalThis.setInterval = () => 0;

    require("../src/popup/index");

    const booted = [];
    for (const fn of document.listeners.DOMContentLoaded || []) {
        booted.push(fn());
    }

    // What the browser console would have shown. A throw out of init() is the
    // blank popup this issue is about, so it is captured rather than thrown:
    // the assertion that matters is what ended up on screen.
    const pageErrors = [];
    for (const p of booted) {
        try {
            await p;
        } catch (e) {
            pageErrors.push(String((e && e.message) || e));
        }
    }
    await settle();

    globalThis.setInterval = realSetInterval;

    return {
        storage,
        document,
        pageErrors,
        reloaded: () => reloads.length,
        node: (id) => document.getElementById(id),
        text: (id) => document.getElementById(id).textContent,
        value: (id) => document.getElementById(id).value,
        hidden: (id) =>
            document.getElementById(id).classList.contains("hidden"),
        click: async (id) => {
            const el = document.getElementById(id);
            const fns = el.listeners.click || [];
            for (const fn of fns) await fn();
            await settle();
        },
        // The view ids whose section is not hidden, as the audit measured them.
        visibleViews: () => {
            const out = [];
            for (const [id, el] of document.elements) {
                if (!id.startsWith("view-")) continue;
                if (!el.classList.contains("hidden")) out.push(id.slice(5));
            }
            return out;
        },
    };
}

async function settle() {
    for (let i = 0; i < 50; i++) await Promise.resolve();
}

afterEach(() => {
    delete globalThis.chrome;
    delete globalThis.document;
    delete globalThis.window;
});

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

describe("a stored profile the popup cannot read", () => {
    for (const { name, blob } of CORRUPT_BLOBS) {
        test(`${name}: the recovery screen, not a blank popup`, async () => {
            const env = await bootPopup(blob);

            // Asserted together, and in the audit's own shape: a failure here
            // prints both what was on screen and what the console said, which
            // is the pair that identifies this defect.
            expect({
                visibleViews: env.visibleViews(),
                errors: env.pageErrors,
            }).toEqual({ visibleViews: ["state-recovery"], errors: [] });

            // The screen has to NAME the problem. A blank recovery screen is
            // the same dead end with a border around it.
            expect(env.text("state-recovery-problem").length).toBeGreaterThan(
                10,
            );
        });
    }

    test("the Settings gear is hidden, since every screen behind it reads the profile", async () => {
        const env = await bootPopup(CORRUPT_BLOBS[0].blob);

        expect(env.hidden("btn-settings")).toBe(true);
    });

    test("it does not write over the record it could not read", async () => {
        // The blob is evidence, and possibly the only copy of key material in
        // a shape a later build could recover. A boot that normalized it back
        // into storage would destroy exactly that.
        const env = await bootPopup(CORRUPT_BLOBS[2].blob);

        expect(env.storage.read("autistmask")).toEqual(CORRUPT_BLOBS[2].blob);
        expect(env.storage.set).not.toHaveBeenCalled();
    });
});

describe("the export on the recovery screen", () => {
    test("hands back the raw stored record verbatim", async () => {
        const env = await bootPopup(CORRUPT_BLOBS[2].blob);

        await env.click("btn-state-recovery-export");

        // Shown in the page, which always works, whatever the browser does
        // with a download from an extension popup.
        expect(env.hidden("state-recovery-blob")).toBe(false);
        expect(JSON.parse(env.value("state-recovery-blob"))).toEqual(
            CORRUPT_BLOBS[2].blob,
        );
    });
});

describe("the destructive reset on the recovery screen", () => {
    test("erases nothing without the typed confirmation", async () => {
        const env = await bootPopup(CORRUPT_BLOBS[0].blob);

        env.node("state-recovery-reset-input").value = "yes";
        await env.click("btn-state-recovery-reset");

        expect(env.storage.read("autistmask")).toEqual(CORRUPT_BLOBS[0].blob);
        expect(env.text("state-recovery-flash").length).toBeGreaterThan(10);
        expect(env.reloaded()).toBe(0);
    });

    test("erases the stored profile once the phrase is typed", async () => {
        const env = await bootPopup(CORRUPT_BLOBS[0].blob);

        env.node("state-recovery-reset-input").value = "erase my wallet";
        await env.click("btn-state-recovery-reset");

        expect(env.storage.read("autistmask")).toBeUndefined();
        expect(env.reloaded()).toBe(1);
    });
});

describe("an unversioned profile that is perfectly valid", () => {
    // The upgrade case. Every install in the field is in this state, and the
    // popup must load it, not offer to wipe it.
    test("boots to the wallet list, not the recovery screen", async () => {
        const env = await bootPopup(unversionedValidProfile());

        expect(env.visibleViews()).toEqual(["main"]);
        expect(env.pageErrors).toEqual([]);
    });

    test("is migrated in place: the version is stamped, the wallet survives", async () => {
        const env = await bootPopup(unversionedValidProfile());

        const stored = env.storage.read("autistmask");
        expect(stored.schemaVersion).toBe(1);
        expect(stored.wallets).toHaveLength(1);
        expect(stored.wallets[0].encryptedSecret).toBe("encrypted-secret-1");
        expect(stored.wallets[0].addresses[0].address).toBe(ADDRESS);
        expect(stored.activeAddress).toBe(ADDRESS);
        expect(stored.allowedSites).toEqual({ [ADDRESS]: ["dapp.example"] });
    });
});

describe("a first run with nothing in storage", () => {
    test("boots to Welcome", async () => {
        const env = await bootPopup(undefined);

        expect(env.visibleViews()).toEqual(["welcome"]);
        expect(env.pageErrors).toEqual([]);
    });
});

describe("a garbage value in a field the gate does not check", () => {
    // The gate refuses only what nothing can floor: the wallet list, the
    // version, the network key. Everything else is normalizePersisted()'s job,
    // and where that job was written as `saved.x || default` rather than a
    // type check, a TRUTHY value of the wrong type walked straight through and
    // threw on the first dereference — the same blank popup this issue is
    // about, measured the same way. Every row below did, at the head named
    // against it; none has ever been removed from this list.
    //
    // These belong on the floor rather than in the gate: none of these values
    // carries key material, all have a sane default, and sending a user whose
    // wallets are perfectly readable to an export-or-erase screen over a
    // broken token list would destroy more than it saves.
    //
    // The CONTAINER and its ELEMENTS are separate defects. Round 2 floored the
    // containers with Array.isArray(), which left every row whose container is
    // a well-formed list of malformed entries still blanking the popup: the
    // dereference is `t.address.toLowerCase()`, one level below the check.
    const CORRUPT_FIELDS = [
        // Container shapes. Observed at 2e2ecf9, before the round-2 floor:
        //   trackedTokens: "nope"  ->  views=[] "Cannot read properties of
        //                              undefined (reading 'toLowerCase')"
        //   trackedTokens: 42      ->  views=[] "trackedTokens is not iterable"
        //   trackedTokens: {a:1}   ->  views=[] "trackedTokens is not iterable"
        //   activeAddress: 42      ->  views=[] "address.slice is not a
        //                              function"
        //   activeAddress: {a:1}   ->  views=[] "address.slice is not a
        //                              function"
        { name: "trackedTokens is a string", patch: { trackedTokens: "nope" } },
        { name: "trackedTokens is a number", patch: { trackedTokens: 42 } },
        {
            name: "trackedTokens is an object",
            patch: { trackedTokens: { a: 1 } },
        },
        { name: "activeAddress is a number", patch: { activeAddress: 42 } },
        {
            name: "activeAddress is an object",
            patch: { activeAddress: { a: 1 } },
        },
        // Element shapes: a list, holding entries that are not token records.
        // Observed at a10a984, AFTER the container floor:
        //   [1,2]           ->  views=[] "Cannot read properties of undefined
        //                       (reading 'toLowerCase')"
        //   [null]          ->  views=[] "Cannot read properties of null
        //                       (reading 'address')"
        //   [{}]            ->  views=[] "Cannot read properties of undefined
        //                       (reading 'toLowerCase')"
        //   [{address:42}]  ->  views=[] "t.address.toLowerCase is not a
        //                       function"
        //   ["0xAA…"]       ->  views=[] "Cannot read properties of undefined
        //                       (reading 'toLowerCase')"
        {
            name: "trackedTokens holds numbers",
            patch: { trackedTokens: [1, 2] },
        },
        {
            name: "trackedTokens holds null",
            patch: { trackedTokens: [null] },
        },
        {
            name: "trackedTokens holds a record with no address",
            patch: { trackedTokens: [{}] },
        },
        {
            name: "trackedTokens holds a record whose address is a number",
            patch: { trackedTokens: [{ address: 42 }] },
        },
        {
            name: "trackedTokens holds bare address strings",
            patch: { trackedTokens: [TOKEN_ADDRESS] },
        },
    ];

    // The same defect one level deeper, inside a wallet the gate accepted.
    // tokenBalances is written WHOLESALE by refreshBalances(), so the partial
    // write https://git.eeqj.de/sneak/AutistMask/issues/311 names as the live
    // cause of a corrupt record lands exactly here. Observed at a10a984:
    //   "x"     ->  views=[] "Cannot read properties of undefined (reading
    //               'toLowerCase')"   (a string iterates as characters)
    //   [null]  ->  views=[] "Cannot read properties of null (reading
    //               'balance')"
    //   [42]    ->  views=[] "Cannot read properties of undefined (reading
    //               'toLowerCase')"
    //   42      ->  views=[] "number 42 is not iterable"
    const CORRUPT_TOKEN_BALANCES = [
        { name: "a string", value: "x" },
        { name: "a list holding null", value: [null] },
        { name: "a list of numbers", value: [42] },
        { name: "a number", value: 42 },
    ];

    function profileWithTokenBalances(value) {
        const profile = unversionedValidProfile();
        profile.wallets[0].addresses[0].tokenBalances = value;
        return profile;
    }

    for (const { name, patch } of CORRUPT_FIELDS) {
        test(`${name}: a working popup, not a blank one`, async () => {
            const env = await bootPopup(
                Object.assign(unversionedValidProfile(), patch),
            );

            expect({
                visibleViews: env.visibleViews(),
                errors: env.pageErrors,
            }).toEqual({ visibleViews: ["main"], errors: [] });
        });
    }

    for (const { name, value } of CORRUPT_TOKEN_BALANCES) {
        test(`an address whose tokenBalances is ${name}: a working popup, not a blank one`, async () => {
            const env = await bootPopup(profileWithTokenBalances(value));

            expect({
                visibleViews: env.visibleViews(),
                errors: env.pageErrors,
            }).toEqual({ visibleViews: ["main"], errors: [] });
        });
    }

    test("the wallet is intact afterwards, and the bad value is gone", async () => {
        const env = await bootPopup(
            Object.assign(unversionedValidProfile(), {
                trackedTokens: "nope",
                activeAddress: 42,
            }),
        );

        const stored = env.storage.read("autistmask");
        expect(stored.wallets[0].encryptedSecret).toBe("encrypted-secret-1");
        expect(stored.trackedTokens).toEqual([]);
        // Floored to null, then filled in by init()'s auto-default.
        expect(stored.activeAddress).toBe(ADDRESS);
    });

    test("an empty activeAddress does not leave the popup with none selected", async () => {
        // "" is text, so a type check alone lets it through — and init()
        // auto-selects the first address only on a STRICT null, so it has to
        // be floored to null rather than kept.
        const env = await bootPopup(
            Object.assign(unversionedValidProfile(), { activeAddress: "" }),
        );

        expect(env.visibleViews()).toEqual(["main"]);
        expect(env.storage.read("autistmask").activeAddress).toBe(ADDRESS);
    });

    test("a malformed token entry is dropped, and the well-formed ones beside it survive", async () => {
        const env = await bootPopup(
            Object.assign(unversionedValidProfile(), {
                trackedTokens: [
                    1,
                    null,
                    {},
                    { address: 42 },
                    TOKEN_ADDRESS,
                    { address: TOKEN_ADDRESS, symbol: "AAA", decimals: 18 },
                ],
            }),
        );

        const stored = env.storage.read("autistmask");
        expect(stored.trackedTokens).toEqual([
            { address: TOKEN_ADDRESS, symbol: "AAA", decimals: 18 },
        ]);
    });

    test("a malformed tokenBalances entry is dropped, and the wallet and its address survive", async () => {
        const env = await bootPopup(
            profileWithTokenBalances([
                null,
                42,
                { address: TOKEN_ADDRESS, symbol: "AAA", balance: "2.0" },
            ]),
        );

        const stored = env.storage.read("autistmask");
        expect(stored.wallets[0].encryptedSecret).toBe("encrypted-secret-1");
        expect(stored.wallets[0].addresses[0].address).toBe(ADDRESS);
        expect(stored.wallets[0].addresses[0].tokenBalances).toEqual([
            { address: TOKEN_ADDRESS, symbol: "AAA", balance: "2.0" },
        ]);
    });
});
