// The EIP-1193 error the page actually catches (src/content/inpage.js).
//
// The bug this pins down (issue #274): the provider rebuilt every failure as
// `new Error(error.message)`, so the `code` the background produced and the
// content script relayed intact was thrown away in the last hop. A dApp
// checking `err.code === 4001` — the standard way to tell "the user said no"
// from "the wallet broke" — saw undefined, and well-behaved sites showed an
// error or retried instead of accepting the refusal.
//
// inpage.js is a bare IIFE injected into the page's JS context, not a module:
// it takes no import and exports nothing, and reaches for `window` at load.
// So it is evaluated here the way the browser evaluates it, against a stub
// window, and the provider is collected from `window.ethereum`. The globals it
// touches are passed in as function parameters rather than assigned to
// globalThis: nothing leaks between tests, and the source is compiled in this
// realm, so the errors it constructs are comparable against this file's own
// `Error` — which a second realm's intrinsics would silently defeat.
//
// There is no jsdom in this repo; see tests/txStatus.test.js.

const fs = require("fs");
const path = require("path");
const { webcrypto } = require("crypto");

const SOURCE = fs.readFileSync(
    path.join(__dirname, "..", "src", "content", "inpage.js"),
    "utf8",
);

const loadInto = new Function(
    "window",
    "self",
    "crypto",
    "Event",
    "CustomEvent",
    SOURCE,
);

class StubEvent {
    constructor(type) {
        this.type = type;
    }
}

class StubCustomEvent extends StubEvent {
    constructor(type, init) {
        super(type);
        this.detail = init && init.detail;
    }
}

// Every code the background emits on the RPC path today, read out of
// src/background/index.js. The provider must not know this list — it passes
// through whatever arrived — but the cases below are the real ones.
const REJECTED = 4001; // user rejected the request
const UNAUTHORIZED = 4100; // site not connected / wrong address
const UNRECOGNIZED_CHAIN = 4902; // switch/add to an unsupported chain

// A stub window with the four things inpage.js touches: message listeners,
// postMessage out to the content script, window.ethereum, and dispatchEvent
// for the EIP-6963 announcement.
function loadProvider() {
    const messageListeners = [];
    const posted = [];

    const win = {
        addEventListener(type, fn) {
            if (type === "message") messageListeners.push(fn);
        },
        removeEventListener(type, fn) {
            const i = messageListeners.indexOf(fn);
            if (type === "message" && i !== -1) messageListeners.splice(i, 1);
        },
        postMessage(data) {
            posted.push(data);
        },
        dispatchEvent() {
            return true;
        },
    };
    win.window = win;

    loadInto(win, win, webcrypto, StubEvent, StubCustomEvent);

    // Deliver the content script's answer to an outstanding request. The id is
    // read back off the wire rather than assumed: inpage.js issues its own
    // eth_chainId at load, so the first id a test sees is not 1.
    function respond(response) {
        const request = posted
            .filter((m) => m.type === "AUTISTMASK_REQUEST")
            .pop();
        expect(request).toBeDefined();
        const event = {
            source: win,
            data: { type: "AUTISTMASK_RESPONSE", id: request.id, ...response },
        };
        for (const fn of messageListeners.slice()) fn(event);
    }

    return { provider: win.ethereum, posted, respond };
}

// Start a request, answer it with `response`, and hand back the rejection.
// Fails the test if the call resolves instead.
async function rejectionFrom(start, response) {
    const { provider, respond } = loadProvider();
    const settled = start(provider).then(
        (result) => ({ resolved: result }),
        (error) => ({ error }),
    );
    // The provider posts synchronously, so the request is already on the wire.
    respond(response);
    const outcome = await settled;
    expect(outcome).not.toHaveProperty("resolved");
    return outcome.error;
}

describe("an EIP-1193 code reaches the page", () => {
    test("a user rejection arrives as code 4001", async () => {
        const err = await rejectionFrom(
            (p) => p.request({ method: "eth_requestAccounts" }),
            {
                error: {
                    code: REJECTED,
                    message: "User rejected the request.",
                },
            },
        );
        expect(err.code).toBe(REJECTED);
        expect(err.message).toBe("User rejected the request.");
    });

    test("it is a ProviderRpcError, and an Error", async () => {
        const err = await rejectionFrom(
            (p) => p.request({ method: "eth_requestAccounts" }),
            {
                error: {
                    code: REJECTED,
                    message: "User rejected the request.",
                },
            },
        );
        expect(err).toBeInstanceOf(Error);
        expect(err.name).toBe("ProviderRpcError");
    });

    test("4100 unauthorized arrives intact", async () => {
        const err = await rejectionFrom(
            (p) => p.request({ method: "personal_sign", params: ["0x00"] }),
            { error: { code: UNAUTHORIZED, message: "Unauthorized" } },
        );
        expect(err.code).toBe(UNAUTHORIZED);
        expect(err.message).toBe("Unauthorized");
    });

    test("4902 unrecognized chain arrives intact", async () => {
        const message =
            "AutistMask supports Ethereum Mainnet and Sepolia Testnet only.";
        const err = await rejectionFrom(
            (p) => p.request({ method: "wallet_switchEthereumChain" }),
            { error: { code: UNRECOGNIZED_CHAIN, message } },
        );
        expect(err.code).toBe(UNRECOGNIZED_CHAIN);
        expect(err.message).toBe(message);
    });

    // The provider is not allowed to know the list above: a code added to the
    // background later must reach the page without this file being edited.
    test("a code the provider has never heard of is passed through", async () => {
        const err = await rejectionFrom(
            (p) => p.request({ method: "eth_accounts" }),
            { error: { code: 4900, message: "Disconnected" } },
        );
        expect(err.code).toBe(4900);
    });

    test("data is carried when the boundary sent it", async () => {
        const err = await rejectionFrom(
            (p) => p.request({ method: "eth_call" }),
            {
                error: {
                    code: -32000,
                    message: "execution reverted",
                    data: "0x08c379a0",
                },
            },
        );
        expect(err.code).toBe(-32000);
        expect(err.data).toBe("0x08c379a0");
    });

    test("no data property is invented when the boundary sent none", async () => {
        const err = await rejectionFrom(
            (p) => p.request({ method: "eth_requestAccounts" }),
            {
                error: {
                    code: REJECTED,
                    message: "User rejected the request.",
                },
            },
        );
        expect("data" in err).toBe(false);
    });
});

describe("the message is untouched", () => {
    test("a coded error keeps the message byte for byte", async () => {
        const message =
            "This site asked to sign as an address that is not " +
            "the active one.";
        const err = await rejectionFrom(
            (p) => p.request({ method: "personal_sign" }),
            { error: { code: UNAUTHORIZED, message } },
        );
        expect(err.message).toBe(message);
    });

    test("an error the background sent with no code keeps its message", async () => {
        const err = await rejectionFrom(
            (p) => p.request({ method: "eth_sendTransaction" }),
            { error: { message: "No accounts available" } },
        );
        expect(err.message).toBe("No accounts available");
    });

    // A ProviderRpcError whose code is undefined would claim a conformance it
    // does not have, and `'code' in err` is exactly what a careful dApp asks.
    test("an error with no code gets no code property at all", async () => {
        const err = await rejectionFrom(
            (p) => p.request({ method: "eth_sendTransaction" }),
            { error: { message: "No accounts available" } },
        );
        expect(err).toBeInstanceOf(Error);
        expect("code" in err).toBe(false);
    });

    test("an error with no message keeps the generic fallback", async () => {
        const err = await rejectionFrom(
            (p) => p.request({ method: "eth_sendTransaction" }),
            { error: { code: REJECTED } },
        );
        expect(err.message).toBe("Request failed");
        expect(err.code).toBe(REJECTED);
    });
});

// Every entry point the provider exposes, not just eth_requestAccounts. They
// all funnel through the same response listener, and this is what says so.
describe("every request path carries the code", () => {
    const rejection = {
        error: { code: REJECTED, message: "User rejected the request." },
    };

    test("request()", async () => {
        const err = await rejectionFrom(
            (p) => p.request({ method: "eth_requestAccounts" }),
            rejection,
        );
        expect(err.code).toBe(REJECTED);
    });

    test("enable()", async () => {
        const err = await rejectionFrom((p) => p.enable(), rejection);
        expect(err.code).toBe(REJECTED);
    });

    test("send(method, params)", async () => {
        const err = await rejectionFrom(
            (p) => p.send("eth_requestAccounts", []),
            rejection,
        );
        expect(err.code).toBe(REJECTED);
    });

    test("send({ method, params })", async () => {
        const err = await rejectionFrom(
            (p) => p.send({ method: "personal_sign", params: ["0x00"] }),
            rejection,
        );
        expect(err.code).toBe(REJECTED);
    });

    test("sendAsync() hands the code to its callback", async () => {
        const { provider, respond } = loadProvider();
        const called = new Promise((resolve) => {
            provider.sendAsync({ id: 1, method: "eth_requestAccounts" }, (e) =>
                resolve(e),
            );
        });
        respond(rejection);
        const err = await called;
        expect(err.name).toBe("ProviderRpcError");
        expect(err.code).toBe(REJECTED);
        expect(err.message).toBe("User rejected the request.");
    });
});

describe("the success path is unchanged", () => {
    test("a result still resolves", async () => {
        const { provider, respond } = loadProvider();
        const settled = provider.request({ method: "eth_requestAccounts" });
        respond({ result: ["0xb61264DEFB0c4B8afb3D73724be15310036743a5"] });
        await expect(settled).resolves.toEqual([
            "0xb61264DEFB0c4B8afb3D73724be15310036743a5",
        ]);
        expect(provider.selectedAddress).toBe(
            "0xb61264DEFB0c4B8afb3D73724be15310036743a5",
        );
    });
});
