// The stored-profile version stamp and the shape gate in front of it
// (https://git.eeqj.de/sneak/AutistMask/issues/311).
//
// tests/stateRecovery.test.js covers what the USER sees when a record is
// refused. This file covers what is refused and what is not, which is the
// half that decides whether an upgrade brick or a false alarm ever happens:
// a gate that refuses too much sends a perfectly good wallet to a wipe prompt,
// and one that refuses too little is the blank popup again.

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

const {
    STATE_SCHEMA_VERSION,
    StateUnusableError,
    assertStateUsable,
    migrationNeeded,
    stateProblem,
} = require("../src/shared/stateSchema");
const {
    NETWORKS,
    UnknownNetworkError,
    isKnownNetworkId,
    networkById,
} = require("../src/shared/networks");
const { normalizePersisted } = require("../src/shared/persistedState");
const { RESET_PHRASE } = require("../src/popup/views/stateRecovery");
const { makeStorageStub } = require("./support/storageStub");

const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";

function validProfile(extra) {
    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",
        ...(extra || {}),
    };
}

afterEach(() => {
    delete global.chrome;
});

describe("what the gate accepts", () => {
    test("nothing stored at all is a first run, not a defect", () => {
        expect(stateProblem(undefined)).toBeNull();
        expect(stateProblem(null)).toBeNull();
        expect(stateProblem({})).toBeNull();
    });

    test("a valid profile with no version field is accepted and migrated", () => {
        const saved = validProfile();

        expect(stateProblem(saved)).toBeNull();
        expect(migrationNeeded(saved)).toBe(true);
        // The migration IS the stamp: version 1 is the shape that shipped
        // unversioned, so nothing about the record has to change.
        expect(normalizePersisted(saved).schemaVersion).toBe(
            STATE_SCHEMA_VERSION,
        );
        expect(normalizePersisted(saved).wallets).toEqual(saved.wallets);
    });

    test("a profile already at the current version needs no migration", () => {
        const saved = validProfile({ schemaVersion: STATE_SCHEMA_VERSION });

        expect(stateProblem(saved)).toBeNull();
        expect(migrationNeeded(saved)).toBe(false);
    });

    test("an empty wallet list is fine", () => {
        expect(stateProblem({ hasWallet: false, wallets: [] })).toBeNull();
    });

    test("unknown extra fields alone are not a defect", () => {
        // Only a change in the MEANING of a stored field is a version bump, so
        // a field this build does not know must not be a refusal on its own —
        // otherwise a downgrade would wipe a working wallet.
        expect(stateProblem(validProfile({ somethingNew: 42 }))).toBeNull();
    });
});

describe("what the gate refuses", () => {
    test("a record that is not the record AutistMask stores", () => {
        expect(stateProblem("wallet")).toMatch(/not the record/);
        expect(stateProblem([1, 2])).toMatch(/not the record/);
    });

    test("a version from a newer build, naming both versions", () => {
        const problem = stateProblem(
            validProfile({ schemaVersion: STATE_SCHEMA_VERSION + 1 }),
        );

        expect(problem).toMatch(/newer version/);
        expect(problem).toContain(String(STATE_SCHEMA_VERSION + 1));
        expect(problem).toContain(String(STATE_SCHEMA_VERSION));
    });

    test("a version that is not a version at all", () => {
        for (const version of ["1", 1.5, 0, -1, null, {}]) {
            expect(
                stateProblem(validProfile({ schemaVersion: version })),
            ).toMatch(/schema version/);
        }
    });

    test("wallets that is not a list", () => {
        expect(stateProblem({ wallets: ADDRESS })).toMatch(/not a list/);
        expect(stateProblem({ wallets: { 0: {} } })).toMatch(/not a list/);
    });

    test("a wallet that is not a wallet record", () => {
        expect(stateProblem({ wallets: [null] })).toMatch(/wallet record/);
        expect(stateProblem({ wallets: [42] })).toMatch(/wallet record/);
    });

    test("a wallet whose addresses are missing or not records", () => {
        expect(stateProblem({ wallets: [{ name: "Wallet 1" }] })).toMatch(
            /no list of addresses/,
        );
        expect(stateProblem({ wallets: [{ addresses: [ADDRESS] }] })).toMatch(
            /not a record/,
        );
        expect(stateProblem({ wallets: [{ addresses: [{}] }] })).toMatch(
            /no address/,
        );
    });

    test("the problem names WHICH wallet, counting from one", () => {
        const problem = stateProblem({
            wallets: [validProfile().wallets[0], { name: "Wallet 2" }],
        });

        expect(problem).toContain("Wallet 2");
    });

    test("assertStateUsable throws the sentence, not a generic error", () => {
        let thrown = null;
        try {
            assertStateUsable({ wallets: ADDRESS });
        } catch (e) {
            thrown = e;
        }

        expect(thrown).toBeInstanceOf(StateUnusableError);
        expect(thrown.problem).toMatch(/not a list/);
        expect(thrown.message).toBe(thrown.problem);
    });
});

describe("networkId, which is used as an object key", () => {
    // https://git.eeqj.de/sneak/AutistMask/issues/311#issuecomment-67478:
    // state.networkId keys state.networkEndpoints, so a corrupt "__proto__"
    // sets that map's prototype instead of an own key and the user's endpoint
    // is silently not recorded.
    test("a network this build does not know is refused", () => {
        expect(stateProblem(validProfile({ networkId: "base" }))).toMatch(
            /does not know/,
        );
    });

    test('"__proto__" and "constructor" are refused, not resolved', () => {
        for (const id of ["__proto__", "constructor", "toString"]) {
            expect(stateProblem(validProfile({ networkId: id }))).toMatch(
                /does not know/,
            );
            expect(isKnownNetworkId(id)).toBe(false);
        }
    });

    test("the gate reads own properties only", () => {
        // A record whose PROTOTYPE carries the fields must not be read as
        // though it carried them itself: that is how a polluted prototype
        // would decide whether a profile is refused.
        const inherited = Object.create({
            schemaVersion: STATE_SCHEMA_VERSION + 99,
            networkId: "base",
            wallets: "not a list",
        });

        expect(stateProblem(inherited)).toBeNull();
    });

    test("normalizing never turns a stored key into a prototype", () => {
        // JSON can carry an own "__proto__" key, and plain assignment would
        // treat it as the prototype setter rather than storing an entry.
        const saved = JSON.parse(
            '{"networkId":"mainnet","networkEndpoints":' +
                '{"__proto__":{"rpcUrl":"https://evil.invalid"}}}',
        );

        const out = normalizePersisted(saved);

        expect(Object.prototype.hasOwnProperty.call(out, "rpcUrl")).toBe(true);
        expect(out.rpcUrl).not.toBe("https://evil.invalid");
        expect(Object.getPrototypeOf(out.networkEndpoints)).toBe(
            Object.prototype,
        );
        expect({}.rpcUrl).toBeUndefined();
    });

    test("an unknown stored networkId never reaches the endpoint map", () => {
        // The floor under the gate: normalization alone must not adopt it.
        const out = normalizePersisted({ networkId: "__proto__" });

        expect(out.networkId).toBe("mainnet");
        expect(Object.keys(out.networkEndpoints)).toEqual(["mainnet"]);
    });
});

describe("the floors under the gate, for fields the gate does not check", () => {
    // The gate's scope is what nothing can floor. Everything it lets through
    // is normalizePersisted()'s to make safe, and a floor written as
    // `saved.x || default` is not one: a truthy value of the wrong type walks
    // through it and throws on the first dereference, which produced the blank
    // popup from the issue. Nor is a container check on its own: [1, 2] is a
    // list, and the dereference is `t.address.toLowerCase()` one level below
    // it. Container AND entries, therefore — an empty list still survives.
    const TOKEN = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";

    test("trackedTokens that is not a list becomes an empty list", () => {
        for (const bad of ["nope", 42, true, { a: 1 }]) {
            expect(
                normalizePersisted({ trackedTokens: bad }).trackedTokens,
            ).toEqual([]);
        }
    });

    test("a trackedTokens entry that is not a token record is dropped", () => {
        for (const bad of [1, null, {}, { address: 42 }, TOKEN, [], true]) {
            expect(
                normalizePersisted({ trackedTokens: [bad] }).trackedTokens,
            ).toEqual([]);
        }
    });

    test("a real trackedTokens list survives, copied not shared", () => {
        const saved = { trackedTokens: [{ address: ADDRESS, symbol: "AM" }] };

        const out = normalizePersisted(saved);

        expect(out.trackedTokens).toEqual(saved.trackedTokens);
        expect(out.trackedTokens).not.toBe(saved.trackedTokens);
        expect(normalizePersisted({ trackedTokens: [] }).trackedTokens).toEqual(
            [],
        );
    });

    test("a good trackedTokens entry beside a malformed one survives", () => {
        const good = { address: TOKEN, symbol: "AM", decimals: 18 };

        expect(
            normalizePersisted({ trackedTokens: [1, null, good, {}] })
                .trackedTokens,
        ).toEqual([good]);
    });

    // Below an address record, which the gate walks but does not descend into.
    // refreshBalances() writes tokenBalances WHOLESALE, so a write that only
    // partly lands leaves exactly this field malformed.
    function walletWith(tokenBalances) {
        return {
            wallets: [
                {
                    name: "Wallet 1",
                    addresses: [{ address: ADDRESS, tokenBalances }],
                },
            ],
        };
    }

    function balancesOf(out) {
        return out.wallets[0].addresses[0].tokenBalances;
    }

    test("an address's tokenBalances that is not a list becomes an empty list", () => {
        for (const bad of ["x", 42, true, { a: 1 }, undefined]) {
            expect(balancesOf(normalizePersisted(walletWith(bad)))).toEqual([]);
        }
    });

    test("a tokenBalances entry that is not a token record is dropped", () => {
        for (const bad of [null, 42, "x", {}, { address: 42 }]) {
            expect(balancesOf(normalizePersisted(walletWith([bad])))).toEqual(
                [],
            );
        }
    });

    test("a real tokenBalances entry survives, copied not shared", () => {
        const held = { address: TOKEN, symbol: "AM", balance: "2.0" };
        const saved = walletWith([held]);

        const out = normalizePersisted(saved);

        expect(balancesOf(out)).toEqual([held]);
        expect(balancesOf(out)[0]).not.toBe(held);
    });

    test("activeAddress that is not text becomes null", () => {
        for (const bad of [42, true, { a: 1 }, [ADDRESS]]) {
            expect(
                normalizePersisted({ activeAddress: bad }).activeAddress,
            ).toBeNull();
        }
    });

    test("a real activeAddress survives; the empty string becomes null", () => {
        expect(
            normalizePersisted({ activeAddress: ADDRESS }).activeAddress,
        ).toBe(ADDRESS);
        // "" is text but it is not an address, and src/popup/index.js
        // auto-selects the first address only on a STRICT null — so keeping
        // the empty string would leave the popup with none ever selected.
        expect(
            normalizePersisted({ activeAddress: "" }).activeAddress,
        ).toBeNull();
    });
});

describe("networkById on an unknown id", () => {
    test("throws instead of quietly answering mainnet", () => {
        expect(() => networkById("base")).toThrow(UnknownNetworkError);
        expect(() => networkById(undefined)).toThrow(UnknownNetworkError);
        // Both of these used to answer with something truthy off the
        // prototype chain rather than with a network.
        expect(() => networkById("constructor")).toThrow(UnknownNetworkError);
        expect(() => networkById("__proto__")).toThrow(UnknownNetworkError);
    });

    test("still answers every network it does know", () => {
        for (const id of Object.keys(NETWORKS)) {
            expect(networkById(id).id).toBe(id);
        }
    });
});

describe("the version stamp on the way out", () => {
    function loadStateModule(persisted) {
        jest.resetModules();
        const storage = makeStorageStub(
            persisted ? { autistmask: persisted } : {},
        );
        global.chrome = { storage };
        return { storage, mod: require("../src/shared/state") };
    }

    test("the popup stamps it on a profile that had none", async () => {
        const { storage, mod } = loadStateModule(validProfile());

        await mod.loadState();
        await mod.saveState();

        expect(storage.read("autistmask").schemaVersion).toBe(
            STATE_SCHEMA_VERSION,
        );
    });

    test("the background stamps it on a profile that had none", async () => {
        jest.resetModules();
        const storage = makeStorageStub({ autistmask: validProfile() });
        global.chrome = { storage };
        const { updateState } = require("../src/background/state");

        await updateState((s) => {
            s.lastBalanceRefresh = 1;
        });

        expect(storage.read("autistmask").schemaVersion).toBe(
            STATE_SCHEMA_VERSION,
        );
    });

    test("a save refuses to write over a record it cannot read", async () => {
        // Another context — a newer build, or something else entirely — wrote
        // a record this one does not understand while this page was open. That
        // record is the only copy of whatever it holds, and normalizing it
        // back into storage would destroy it.
        const { storage, mod } = loadStateModule(validProfile());
        await mod.loadState();

        const hostile = { schemaVersion: STATE_SCHEMA_VERSION + 1 };
        storage.write("autistmask", hostile);

        // By name rather than by constructor: jest.resetModules() above gives
        // the module under test its own copy of the error class, so instanceof
        // across that boundary would be comparing two identical classes.
        const thrown = await mod.saveState().then(
            () => null,
            (e) => e,
        );
        expect(thrown && thrown.name).toBe("StateUnusableError");
        expect(thrown.problem).toMatch(/newer version/);
        expect(storage.read("autistmask")).toEqual(hostile);
    });
});

describe("the typed confirmation phrase", () => {
    test("the markup asks for the phrase the code checks", () => {
        // The button is behind a phrase typed by hand. A screen that asks for
        // one phrase while the code compares another is an exit that cannot be
        // taken, on the one screen that exists to be an exit.
        const html = fs.readFileSync(
            path.join(__dirname, "..", "src", "popup", "index.html"),
            "utf8",
        );

        expect(html).toContain(RESET_PHRASE);
    });
});
