// Tests for the private key export screen (issue #221).
//
// The screen holds the one secret that owns an address outright, so what is
// pinned here is disposal: the key is wiped from the DOM whenever the screen
// is left by any route, and a decrypt still in flight when the screen is
// left never writes at all. That last case is the one a per-button wipe and
// a naive leave hook both miss — the write lands after the wipe, with
// nothing scheduled to wipe it again.
//
// The view is driven against a minimal DOM stub rather than a real browser:
// the module is deliberately shaped like src/popup/views/showPhrase.js, with
// no dependency that needs a document beyond the nodes it reads and writes.

const mockPrivateKey = "0x" + "ab".repeat(32);

jest.mock("ethereum-blockies-base64", () => () => "data:image/png;base64,x");
jest.mock("../src/shared/vault", () => ({
    decryptWithPassword: jest.fn(),
}));
jest.mock("../src/shared/wallet", () => ({
    getSignerForAddress: jest.fn(() => ({ privateKey: mockPrivateKey })),
}));

const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");

const VIEW = "export-privkey";
const PASSWORD = "correct horse battery";

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

function makeElement(id, withParent) {
    const classes = new Set();
    const el = {
        id,
        textContent: "",
        value: "",
        innerHTML: "",
        disabled: false,
        style: {},
        dataset: {},
        listeners: {},
        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);
        },
        appendChild: () => {},
        remove: () => {},
        querySelectorAll: () => [],
    };
    el.parentElement = withParent ? makeElement(id + "-parent", false) : null;
    return el;
}

function makeDocument() {
    const els = new Map();
    return {
        getElementById(id) {
            // The debug banner is created on demand by helpers.js; 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, true));
            return els.get(id);
        },
        createElement: () => makeElement("created", false),
        addEventListener: () => {},
        body: { prepend: () => {} },
    };
}

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

function load() {
    jest.resetModules();
    globalThis.chrome = {
        storage: { local: { get: async () => ({}), set: async () => {} } },
    };
    globalThis.document = makeDocument();

    const helpers = require("../src/popup/views/helpers");
    const { state } = require("../src/shared/state");
    const vault = require("../src/shared/vault");
    const wallet = require("../src/shared/wallet");
    const exportPrivkey = require("../src/popup/views/exportPrivkey");

    state.wallets = [
        {
            name: "Wallet 1",
            type: "key",
            encryptedSecret: "ciphertext",
            addresses: [
                {
                    address: "0x" + "11".repeat(20),
                    balance: "0.0000",
                    tokenBalances: [],
                },
                {
                    address: "0x" + "22".repeat(20),
                    balance: "0.0000",
                    tokenBalances: [],
                },
            ],
        },
    ];
    state.viewStack = [];
    state.currentView = "address";

    exportPrivkey.init();
    return { helpers, state, vault, wallet, exportPrivkey };
}

function click(id) {
    const el = globalThis.document.getElementById(id);
    return Promise.all((el.listeners.click || []).map((fn) => fn()));
}

function node(id) {
    return globalThis.document.getElementById(id);
}

// Start a reveal and hand back both the promise it returns and the resolver
// for the decrypt it is waiting on, so a test can navigate away mid-flight.
function startReveal(vault) {
    let resolveDecrypt;
    let rejectDecrypt;
    vault.decryptWithPassword.mockImplementation(
        () =>
            new Promise((resolve, reject) => {
                resolveDecrypt = resolve;
                rejectDecrypt = reject;
            }),
    );
    node("export-privkey-password").value = PASSWORD;
    const pending = click("btn-export-privkey-confirm");
    return {
        pending,
        resolve: (v) => resolveDecrypt(v),
        reject: (e) => rejectDecrypt(e),
    };
}

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

describe("a decrypt still running when the screen is left", () => {
    // The load-bearing case. Without the liveness guard in reveal(), the
    // write lands after the leave hook has already wiped, and the key sits
    // in the hidden view for the life of the popup.
    test("never writes the key into the DOM", async () => {
        const { helpers, vault, wallet, exportPrivkey } = load();
        exportPrivkey.show(0, 0);

        const reveal = startReveal(vault);
        // The settings gear, mid-decrypt.
        helpers.showView("settings");
        reveal.resolve("wallet secret");
        await reveal.pending;

        expect(node("export-privkey-value").textContent).toBe("");
        // Nothing was even derived: the guard sits in front of the
        // derivation, not just in front of the write.
        expect(wallet.getSignerForAddress).not.toHaveBeenCalled();
    });

    // The generation counter, not merely the current-view check: by the time
    // the stale decrypt resolves the user is back on the screen, so a guard
    // that only asked "is this view showing?" would let the write through.
    test("never writes it after the screen is re-entered", async () => {
        const { helpers, vault, exportPrivkey } = load();
        exportPrivkey.show(0, 0);

        const stale = startReveal(vault);
        helpers.showView("settings");
        exportPrivkey.show(0, 1);
        expect(node("export-privkey-value").textContent).toBe("");

        stale.resolve("wallet secret");
        await stale.pending;

        expect(node("export-privkey-value").textContent).toBe("");
        expect(node("export-privkey-result").classList.contains("hidden")).toBe(
            true,
        );
    });

    // Same hole on the failure path: a wrong-password error written after
    // the wipe would restore the flash line on a screen the user has left.
    test("never writes the failure message either", async () => {
        const { helpers, vault, exportPrivkey } = load();
        exportPrivkey.show(0, 0);

        const reveal = startReveal(vault);
        helpers.showView("settings");
        reveal.reject(new Error("decryption failed"));
        await reveal.pending;

        expect(node("export-privkey-flash").textContent).toBe("");
        expect(node("export-privkey-flash").style.visibility).toBe("hidden");
    });
});

describe("a reveal that is not interrupted", () => {
    // Guards the guard: a liveness check that rejected every write would
    // pass every test above and ship a screen that reveals nothing.
    test("puts the key on screen", async () => {
        const { vault, exportPrivkey } = load();
        exportPrivkey.show(0, 0);

        const reveal = startReveal(vault);
        reveal.resolve("wallet secret");
        await reveal.pending;

        expect(node("export-privkey-value").textContent).toBe(mockPrivateKey);
        expect(node("export-privkey-result").classList.contains("hidden")).toBe(
            false,
        );
        // The password is dropped as soon as it has been spent.
        expect(node("export-privkey-password").value).toBe("");
    });

    test("writes nothing before the password is accepted", async () => {
        const { vault, exportPrivkey } = load();
        exportPrivkey.show(0, 0);

        const reveal = startReveal(vault);
        expect(node("export-privkey-value").textContent).toBe("");
        reveal.resolve("wallet secret");
        await reveal.pending;
    });

    test("reveals nothing when the password is wrong", async () => {
        const { vault, exportPrivkey } = load();
        exportPrivkey.show(0, 0);

        const reveal = startReveal(vault);
        reveal.reject(new Error("decryption failed"));
        await reveal.pending;

        expect(node("export-privkey-value").textContent).toBe("");
        expect(node("export-privkey-flash").textContent).toBe(
            "That password is incorrect. Please try again.",
        );
    });
});

describe("leaving the screen after the key is on it", () => {
    async function revealed() {
        const loaded = load();
        loaded.exportPrivkey.show(0, 0);
        const reveal = startReveal(loaded.vault);
        reveal.resolve("wallet secret");
        await reveal.pending;
        expect(node("export-privkey-value").textContent).toBe(mockPrivateKey);
        return loaded;
    }

    test("the Back button clears the key", async () => {
        await revealed();
        await click("btn-export-privkey-back");

        expect(node("export-privkey-value").textContent).toBe("");
        expect(node("export-privkey-password").value).toBe("");
    });

    test("the settings gear clears the key", async () => {
        const { helpers } = await revealed();
        helpers.showView("settings");

        expect(node("export-privkey-value").textContent).toBe("");
        expect(node("export-privkey-password").value).toBe("");
        // And the screen is back to its password prompt, not to a result
        // panel that would flash an empty well on the next visit.
        expect(node("export-privkey-result").classList.contains("hidden")).toBe(
            true,
        );
        expect(
            node("export-privkey-password-section").classList.contains(
                "hidden",
            ),
        ).toBe(false);
    });

    // Any other navigation: the same hook covers routes that do not exist
    // yet, which is the point of registering it on the view rather than on
    // the controls that leave it.
    test("any other navigation clears the key", async () => {
        const { helpers } = await revealed();
        helpers.showView("main");

        expect(node("export-privkey-value").textContent).toBe("");
    });
});

describe("views the popup may reopen onto", () => {
    // Restoring onto this screen would put a private key on display with no
    // password prompt in front of it, on a popup reopened by accident.
    test("the private key export screen is not restorable", () => {
        expect(RESTORABLE_VIEWS.has(VIEW)).toBe(false);
    });

    test("it is still a registered view", () => {
        const { helpers } = load();
        expect(helpers.VIEWS).toContain(VIEW);
    });
});

describe("the key cannot reach the logger", () => {
    const fs = require("fs");
    const path = require("path");
    const source = fs.readFileSync(
        path.join(__dirname, "..", "src", "popup", "views", "exportPrivkey.js"),
        "utf8",
    );

    test("the view does not import src/shared/log.js", () => {
        expect(source).not.toMatch(/require\(["'][^"']*shared\/log["']\)/);
    });

    test("the view calls no logger method", () => {
        expect(source).not.toMatch(/\blog\.(debugf|infof|warnf|errorf)\b/);
    });
});
