// Tests for the known-symbol spoof rule (src/shared/symbolSpoof.js) and for
// its application on all three surfaces that show tokens: the transaction
// history, the Send token selector, and the balance list.
//
// Issue #235: the three surfaces disagreed about what a `null` entry in
// KNOWN_SYMBOLS means.  The history and the selector read it as "no contract
// may bear this symbol" and filtered a fake `ETH` ERC-20; the balance list
// read it as "no comparison is possible" and listed the fake token next to
// the user's real ETH, which is where a user forms their belief about what
// they own.  The rule now lives in one module, so a fourth surface cannot
// reintroduce a fourth reading, and these tests assert the same attack on
// each surface.
//
// Nothing here touches the network: global.fetch is a throwing stub and the
// only fetch path in the modules under test (debugFetch, from
// src/shared/log) is mocked at the module boundary.

// The RPC provider is replaced so that refreshBalances can be driven end to
// end: the native balance it reports must survive a balance list in which
// every ERC-20 row is a fake ETH.  Everything else in ethers is the real
// module, including the formatters the assertions depend on.
jest.mock("ethers", () => {
    const actual = jest.requireActual("ethers");
    class StubProvider {
        async getBalance() {
            return 1234500000000000000n;
        }
        async lookupAddress() {
            return null;
        }
    }
    return {
        ...actual,
        JsonRpcProvider: StubProvider,
        Network: { from: () => ({}) },
    };
});

jest.mock("../src/shared/log", () => ({
    log: {
        debugf: () => {},
        infof: () => {},
        warnf: () => {},
        errorf: () => {},
    },
    debugFetch: jest.fn(),
    setRuntimeDebug: () => {},
    isDebug: () => false,
}));

global.fetch = jest.fn(() => {
    throw new Error("tests must not perform network requests");
});
global.chrome = {
    storage: { local: { get: async () => ({}), set: async () => {} } },
};

const { isSpoofedSymbol } = require("../src/shared/symbolSpoof");
const { KNOWN_SYMBOLS } = require("../src/shared/tokenList");
const { filterTransactions } = require("../src/shared/transactions");
const {
    fetchTokenBalances,
    refreshBalances,
} = require("../src/shared/balances");
const { renderSendTokenSelect } = require("../src/popup/views/send");
const { state } = require("../src/shared/state");
const { debugFetch } = require("../src/shared/log");

// The fake "Ethereum" token with symbol "ETH" from the attack documented in
// README.md, given a holder count high enough to clear every other filter so
// that only the known-symbol rule can catch it.
const FAKE_ETH_CONTRACT = "0xd05339f9ea5ab9d9f03b9d57f671d2abd1f55c82";
const HOLDER = "0x66133e8ea0f5d1d612d2502a968757d1048c214a";
const USDC_CONTRACT = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
const WETH_CONTRACT = "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2";
const BLOCKSCOUT = "https://eth.blockscout.com/api/v2";

describe("the shared rule", () => {
    test('"ETH" is still the null-mapped symbol these tests assume', () => {
        expect(KNOWN_SYMBOLS.get("ETH")).toBeNull();
    });

    test("a contract bearing a null-mapped symbol is a spoof", () => {
        expect(isSpoofedSymbol("ETH", FAKE_ETH_CONTRACT)).toBe(true);
    });

    test("even a genuine contract may not bear a null-mapped symbol", () => {
        expect(isSpoofedSymbol("ETH", WETH_CONTRACT)).toBe(true);
    });

    test("the native asset carries no contract and is never a spoof", () => {
        expect(isSpoofedSymbol("ETH", null)).toBe(false);
        expect(isSpoofedSymbol("ETH", undefined)).toBe(false);
        expect(isSpoofedSymbol("ETH", "")).toBe(false);
    });

    // The native exemption is "has no contract address", not "the symbol is
    // ETH".  A second null-mapped symbol added to the table later inherits
    // both halves of the rule without any call site being revisited.
    test("a newly null-mapped symbol behaves the same way", () => {
        const added = !KNOWN_SYMBOLS.has("XTZTEST");
        KNOWN_SYMBOLS.set("XTZTEST", null);
        try {
            expect(isSpoofedSymbol("XTZTEST", FAKE_ETH_CONTRACT)).toBe(true);
            expect(isSpoofedSymbol("XTZTEST", null)).toBe(false);
        } finally {
            if (added) KNOWN_SYMBOLS.delete("XTZTEST");
        }
    });

    test("a known symbol from its own contract is not a spoof", () => {
        expect(isSpoofedSymbol("USDC", USDC_CONTRACT)).toBe(false);
        expect(isSpoofedSymbol("usdc", USDC_CONTRACT.toUpperCase())).toBe(
            false,
        );
    });

    test("a known symbol from another contract is a spoof", () => {
        expect(isSpoofedSymbol("USDC", FAKE_ETH_CONTRACT)).toBe(true);
    });

    test("a symbol that is not in the table is not judged here", () => {
        expect(isSpoofedSymbol("SPAMTKN", FAKE_ETH_CONTRACT)).toBe(false);
    });
});

describe("surface 1: the transaction history", () => {
    function fakeEthTransfer() {
        return {
            hash: "0x" + "1".repeat(64),
            symbol: "ETH",
            contractAddress: FAKE_ETH_CONTRACT,
            holders: 900000,
            valueGwei: null,
            isContractCall: false,
        };
    }

    test("a fake ETH token transfer is filtered", () => {
        const result = filterTransactions([fakeEthTransfer()], {
            hideSpoofedSymbols: true,
            hideFraudContracts: true,
            hideLowHolderTokens: true,
            hideDustTransactions: true,
            dustThresholdGwei: 100000,
        });
        expect(result.transactions).toEqual([]);
    });

    test("a real native ETH transfer survives", () => {
        const native = {
            hash: "0x" + "2".repeat(64),
            symbol: "ETH",
            contractAddress: null,
            holders: null,
            valueGwei: 5000000,
            isContractCall: false,
        };
        const result = filterTransactions([native], {
            hideSpoofedSymbols: true,
            hideFraudContracts: true,
            hideLowHolderTokens: true,
            hideDustTransactions: true,
            dustThresholdGwei: 100000,
        });
        expect(result.transactions).toEqual([native]);
    });
});

describe("surface 2: the Send token selector", () => {
    let select;

    function render(tokenBalances) {
        select = { innerHTML: "", children: [] };
        select.appendChild = (child) => select.children.push(child);
        globalThis.document = {
            getElementById: (id) => (id === "send-token" ? select : null),
            createElement: () => ({ value: "", textContent: "" }),
        };
        renderSendTokenSelect({
            address: "0x" + "a".repeat(40),
            tokenBalances,
        });
    }

    beforeEach(() => {
        state.fraudContracts = [];
        state.hideLowHolderTokens = true;
    });

    test("a fake ETH token is not selectable", () => {
        render([
            {
                address: FAKE_ETH_CONTRACT,
                symbol: "ETH",
                decimals: 18,
                balance: "0.005",
                holders: 900000,
            },
        ]);
        expect(select.children).toEqual([]);
    });

    test("native ETH remains the always-present option", () => {
        render([]);
        expect(select.innerHTML).toBe('<option value="ETH">ETH</option>');
    });
});

describe("surface 3: the balance list", () => {
    function respondWith(items) {
        debugFetch.mockImplementation(async () => ({
            ok: true,
            status: 200,
            statusText: "OK",
            json: async () => items,
        }));
    }

    function fakeEthItem(overrides = {}) {
        return {
            value: "5000000000000000",
            token: {
                type: "ERC-20",
                address_hash: FAKE_ETH_CONTRACT,
                symbol: "ETH",
                name: "Ethereum",
                decimals: "18",
                holders_count: "900000",
                ...overrides,
            },
        };
    }

    beforeEach(() => {
        debugFetch.mockReset();
    });

    // The bug in issue #235: this token cleared the balance list's own
    // 1,000-holder floor and was listed as a holding named ETH.
    test("a fake ETH token clearing the holder floor is filtered", async () => {
        respondWith([fakeEthItem()]);
        expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
    });

    test("tracking the fake token manually does not admit it either", async () => {
        respondWith([fakeEthItem({ holders_count: "0" })]);
        const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, [
            { address: FAKE_ETH_CONTRACT },
        ]);
        expect(balances).toEqual([]);
    });

    test("a genuine token keeps its place in the list", async () => {
        respondWith([
            fakeEthItem({
                address_hash: USDC_CONTRACT,
                symbol: "USDC",
                name: "USD Coin",
                decimals: "6",
            }),
        ]);
        const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
        expect(balances).toHaveLength(1);
        expect(balances[0].symbol).toBe("USDC");
    });

    // The trap in this change: the user's real ETH balance is not an ERC-20
    // and is fetched over RPC in refreshBalances, so it never passes through
    // this loop at all.  An explorer row that is not an ERC-20 is dropped
    // before the symbol rule is consulted.
    test("a non-ERC-20 row claiming ETH never reaches the symbol rule", async () => {
        respondWith([fakeEthItem({ type: "ERC-721" })]);
        expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
    });

    // The money test: the user holds real ETH and has been airdropped a fake
    // ETH ERC-20.  The fake is gone from the list of tokens; the real balance
    // is exactly what the node reported.
    test("the real native ETH balance survives a fake ETH airdrop", async () => {
        respondWith([fakeEthItem()]);
        const addr = { address: HOLDER };
        await refreshBalances(
            [{ addresses: [addr] }],
            "https://rpc.example.invalid",
            BLOCKSCOUT,
            [],
        );
        expect(addr.balance).toBe("1.2345");
        expect(addr.tokenBalances).toEqual([]);
    });

    test("no test in this file performed a network request", () => {
        expect(global.fetch).not.toHaveBeenCalled();
    });
});
