// What the balance fetcher stores when the block explorer reports no decimals
// for a token, and what the approval screens then display.
//
// https://git.eeqj.de/sneak/AutistMask/issues/349: `fetchTokenBalances()` did
// `parseInt(item.token.decimals || "18", 10)` BEFORE writing the row, so a
// token whose `decimals()` reverts — and which the explorer therefore reports
// no scale for — was stored with a fabricated 18. Nothing downstream could
// tell that from a real 18.
//
// That matters because it is upstream of two refusals that were already built
// and already merged. https://git.eeqj.de/sneak/AutistMask/issues/306 made the
// ERC-20 amount line resolve the real scale or refuse to format, and
// https://git.eeqj.de/sneak/AutistMask/issues/340 did the same for the swap
// lines. Both read this stored value as an authoritative source, so the guess
// walked straight past them: the refusal was intact and simply never fired.
//
// So these tests run a real explorer response through the real fetcher and
// assert on the real approval screens. A test that hand-writes `decimals: null`
// onto state would pass on the broken build, because the fabrication is in the
// writer, not the readers.

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");
});

const { makeStorageStub } = require("./support/storageStub");
global.chrome = { storage: makeStorageStub() };

const { AbiCoder, Interface } = require("ethers");
const { ERC20_ABI } = require("../src/shared/constants");
const { fetchTokenBalances } = require("../src/shared/balances");
const { debugFetch } = require("../src/shared/log");
const { state } = require("../src/shared/state");
const { unknownDecimalsAmount } = require("../src/shared/approvalAmount");
const { decodeCalldata } = require("../src/popup/views/approval");
const { TOKEN_BY_ADDRESS } = require("../src/shared/tokenList");

const HOLDER = "0x" + "a".repeat(40);
const BLOCKSCOUT = "https://blockscout.example/api/v2";
const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
const SPENDER = "0x1111111111111111111111111111111111111111";
// Outside the bundled list and untracked, so the explorer is the only source
// of a scale for it — which is the case the fabrication was hiding.
const NOVEL = "0xE2E0000000000000000000000000000000000E2e";
// In the bundled list, at 18 decimals, for the other side of a swap.
const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";

// The holding the explorer reports, in base units. Large enough that it does
// not round to zero even when divided by 10^18, which is what makes it the
// case the laundering actually REACHED: a smaller holding formatted at the
// fabricated 18 comes out "0.0", the balance list drops the row as dust, and
// the approval screens then find no source for the scale and refuse anyway —
// for the wrong reason, and only by luck.
const HOLDING = 5000000000000000000n;

// The amount in the dApp's calldata, which is a separate number from the
// holding. 1,000.00 of a 6-decimal token; formatted at the fabricated 18 it
// reads 0.000000001, and at a real scale of 0 it reads 1000000000.
const THOUSAND_AT_SIX = 1000000000n;
const HALF_WETH = 500000000000000000n;

const erc20Iface = new Interface(ERC20_ABI);
const coder = AbiCoder.defaultAbiCoder();
const routerIface = new Interface([
    "function execute(bytes commands, bytes[] inputs, uint256 deadline)",
]);

// One Blockscout token-balances row. `token` is spread last so a test can
// override or blank a field; the base row carries no `decimals` at all, which
// is exactly what a token whose decimals() reverts produces.
function row(token = {}, value = HOLDING) {
    return {
        value: String(value),
        token: {
            type: "ERC-20",
            address_hash: NOVEL,
            symbol: "NOVEL",
            name: "Novel Token",
            // Well clear of the balance list's own spam floor, so the row is
            // admitted on its holder count alone: neither the bundled list nor
            // a tracked entry can supply a scale for it.
            holders_count: "50000",
            ...token,
        },
    };
}

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

// Fetch and place the result exactly where refreshBalances() places it, so the
// approval screens read what a real refresh would have left on state.
async function fetchOnto(items, trackedTokens = []) {
    respondWith(items);
    const balances = await fetchTokenBalances(
        HOLDER,
        BLOCKSCOUT,
        trackedTokens,
    );
    state.trackedTokens = trackedTokens;
    state.wallets = [
        {
            name: "Wallet 1",
            addresses: [
                { address: HOLDER, balance: "1.0", tokenBalances: balances },
            ],
        },
    ];
    return balances;
}

// The ERC-20 approval screen's Amount line, and the swap decoder's.
function erc20AmountLine(data, tokenAddress) {
    return decodeCalldata(data, tokenAddress).details.find(
        (d) => d.label === "Amount",
    ).value;
}

function swapAmountLine(data) {
    return decodeCalldata(data, ROUTER).details.find(
        (d) => d.label === "Amount",
    ).value;
}

function transferData(amount) {
    return erc20Iface.encodeFunctionData("transfer", [RECIPIENT, amount]);
}

function approveData(amount) {
    return erc20Iface.encodeFunctionData("approve", [SPENDER, amount]);
}

function swapData(tokenIn, amountIn, tokenOut, amountOutMin) {
    const input = coder.encode(
        ["address", "uint256", "uint256", "address[]", "bool"],
        [RECIPIENT, amountIn, amountOutMin, [tokenIn, tokenOut], true],
    );
    return routerIface.encodeFunctionData("execute", [
        "0x08",
        [input],
        9999999999n,
    ]);
}

beforeEach(() => {
    debugFetch.mockReset();
    state.trackedTokens = [];
    state.wallets = [];
});

describe("what fetchTokenBalances stores for an absent scale", () => {
    test("the token is not in the bundled list, so the explorer is the only source", () => {
        expect(TOKEN_BY_ADDRESS.has(NOVEL.toLowerCase())).toBe(false);
    });

    test("an absent decimals is stored as null, not as 18", async () => {
        const balances = await fetchOnto([row()]);
        expect(balances).toHaveLength(1);
        expect(balances[0].decimals).toBeNull();
    });

    test("an explicit null decimals is stored as null too", async () => {
        const balances = await fetchOnto([row({ decimals: null })]);
        expect(balances[0].decimals).toBeNull();
    });

    // The same explorer row twice, differing only in whether it reports a
    // scale of 18. Before the fix both stored 18 and no reader could tell
    // which one had actually been reported.
    test("a real 18 is stored as 18, and so is distinguishable from absent", async () => {
        const real = await fetchOnto([row({ decimals: "18" })]);
        expect(real[0].decimals).toBe(18);
        expect(real[0].balance).toBe("5.0");
        const absent = await fetchOnto([row()]);
        expect(absent[0].decimals).toBeNull();
        expect(real[0].decimals).not.toBe(absent[0].decimals);
    });

    // The falsy-collapse trap of
    // https://git.eeqj.de/sneak/AutistMask/issues/246. `decimals || "18"` reads
    // a real scale of zero as absent and then as eighteen, which is eighteen
    // orders of magnitude of error in the direction that displays as nothing.
    test("a real scale of zero is stored as zero, not collapsed", async () => {
        for (const reported of ["0", 0]) {
            const balances = await fetchOnto([row({ decimals: reported })]);
            expect(balances[0].decimals).toBe(0);
            expect(balances[0].balance).toBe("5000000000000000000.0");
        }
    });

    test("no quantity is stated for a holding whose scale is unknown", async () => {
        const balances = await fetchOnto([row()]);
        // Not "0.0": the holding is real and nonzero, and a zero here is the
        // same lie the approval screens refuse to tell.
        expect(balances[0].balance).toBeNull();
    });

    // Zero base units is zero tokens at every scale, so this filter never
    // needed a scale in the first place and does not acquire one now.
    test("a holding of zero base units is still dropped without a scale", async () => {
        expect(await fetchOnto([row({}, 0n)])).toEqual([]);
    });

    test("the bundled list still supplies a quantity the explorer omitted", async () => {
        const balances = await fetchOnto([
            row({ address_hash: WETH, symbol: "WETH" }),
        ]);
        // The stored decimals stay the explorer's own answer — absent. Copying
        // another source in here would make explorerDecimals()'s disagreement
        // check compare something other than explorer values.
        expect(balances[0].decimals).toBeNull();
        // The displayed quantity still comes out right, because the bundled
        // list knows this token's scale and outranks the explorer anyway.
        expect(balances[0].balance).toBe("5.0");
    });
});

describe("the ERC-20 approval line reaches its refusal", () => {
    test("a transfer of a token the explorer gave no scale for is not formatted", async () => {
        await fetchOnto([row()]);
        const line = erc20AmountLine(transferData(THOUSAND_AT_SIX), NOVEL);
        expect(line).toBe(unknownDecimalsAmount(THOUSAND_AT_SIX));
        // The defect: a fabricated 18 renders this as 0.000000001, a quantity,
        // and a wrong one.
        expect(line).not.toMatch(/^0\./);
    });

    test("an approve of the same token is not formatted either", async () => {
        await fetchOnto([row()]);
        const line = erc20AmountLine(approveData(THOUSAND_AT_SIX), NOVEL);
        expect(line).toBe(unknownDecimalsAmount(THOUSAND_AT_SIX));
        expect(line).not.toMatch(/^0\./);
    });

    test("a scale the explorer did report still formats", async () => {
        await fetchOnto([row({ decimals: "6" })]);
        expect(erc20AmountLine(transferData(THOUSAND_AT_SIX), NOVEL)).toBe(
            "1000.0000",
        );
    });
});

describe("the swap approval line reaches its refusal", () => {
    test("a swap of a token the explorer gave no scale for is not formatted", async () => {
        await fetchOnto([row()]);
        const line = swapAmountLine(
            swapData(NOVEL, THOUSAND_AT_SIX, WETH, HALF_WETH),
        );
        expect(line).toBe(unknownDecimalsAmount(THOUSAND_AT_SIX));
        expect(line).not.toMatch(/^0\./);
    });

    test("a scale the explorer did report still formats", async () => {
        await fetchOnto([row({ decimals: "6" })]);
        expect(
            swapAmountLine(swapData(NOVEL, THOUSAND_AT_SIX, WETH, HALF_WETH)),
        ).toBe("1000.0000");
    });
});

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