// Balance fetching: ETH balances via RPC, ERC-20 token balances via
// Blockscout, ENS reverse lookup via RPC.

const {
    JsonRpcProvider,
    Network,
    Contract,
    formatEther,
    formatUnits,
} = require("ethers");
const { ERC20_ABI } = require("./constants");
const { NETWORKS } = require("./networks");
const { log, debugFetch } = require("./log");
const { deriveAddressFromXpub } = require("./wallet");
const { TOKEN_BY_ADDRESS } = require("./tokenList");
const { LOW_HOLDER_THRESHOLD, parseHoldersCount } = require("./holders");
const { isSpoofedSymbol } = require("./symbolSpoof");
const { toDecimals } = require("./transferAmount");
const { resolveTokenDecimals } = require("./approvalAmount");

// Use a static network to skip auto-detection (which can fail and cause
// "could not coalesce error" on some RPC endpoints like Cloudflare).
//
// `networkId` is REQUIRED, and is one of the ids in networks.js. It used to be
// optional, falling back to currentNetwork() — the module-level `state`
// singleton, which the MV3 service worker never populates. The endpoint then
// came out right and the static hint came out mainnet, so ethers fixed
// `chainId` at 0x1 and every non-mainnet dApp send was prepared for the wrong
// chain and then refused by the wallet's own verifier
// (https://git.eeqj.de/sneak/AutistMask/issues/320). Requiring it is what
// stops that from coming back: a caller that has no network to name has no
// business constructing a provider, and there is no longer a default for it
// to get silently wrong.
//
// Validated against NETWORKS rather than passed straight to Network.from():
// ethers knows chains this wallet does not, so an id that is not one of ours
// is a caller bug and must not resolve to a working provider for some other
// chain.
function getProvider(rpcUrl, networkId) {
    const net = Network.from(requireNetworkId(networkId).id);
    return new JsonRpcProvider(rpcUrl, net, { staticNetwork: net });
}

function requireNetworkId(networkId) {
    const net = NETWORKS[networkId];
    if (!net) {
        throw new Error(
            "getProvider requires the id of a supported network; got " +
                JSON.stringify(networkId),
        );
    }
    return net;
}

function formatBalance(wei) {
    const eth = formatEther(wei);
    const parts = eth.split(".");
    if (parts.length === 1) return eth + ".0";
    const dec = parts[1].slice(0, 6).replace(/0+$/, "") || "0";
    return parts[0] + "." + dec;
}

function formatTokenBalance(raw, decimals) {
    const val = formatUnits(raw, decimals);
    const parts = val.split(".");
    if (parts.length === 1) return val + ".0";
    const dec = parts[1].slice(0, 6).replace(/0+$/, "") || "0";
    return parts[0] + "." + dec;
}

// The explorer's reported holding as an exact base-unit integer, or null when
// it reported nothing usable. Base units carry no scale, so this value is
// meaningful before the scale is known — which is what lets a holding of zero
// be recognised as zero without guessing a scale to divide it by.
function rawUnits(value) {
    if (typeof value === "bigint") return value >= 0n ? value : null;
    if (typeof value === "number") {
        return Number.isSafeInteger(value) && value >= 0 ? BigInt(value) : null;
    }
    if (typeof value !== "string" || !/^[0-9]+$/.test(value)) return null;
    return BigInt(value);
}

// Fetch token balances for a single address from Blockscout.
// Returns [{ address, name, symbol, decimals, balance, holders }].
// Filters out spam: only shows tokens that are in the known token list,
// explicitly tracked by the user, or have >= 1000 holders.
//
// `decimals` and `balance` are each null when the answer is unknown, the same
// way `holders` already is. Absence is never filled in here: this is the
// upstream of every screen that displays a token amount, so a value invented
// at this point is indistinguishable from a real one everywhere below it.
async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
    try {
        const resp = await debugFetch(
            blockscoutUrl + "/addresses/" + address + "/token-balances",
        );
        if (!resp.ok) {
            log.errorf("blockscout token-balances:", resp.status);
            return null;
        }
        const items = await resp.json();
        if (!Array.isArray(items)) return null;

        const trackedSet = new Set(
            (trackedTokens || []).map((t) => t.address.toLowerCase()),
        );

        const balances = [];
        for (const item of items) {
            // Case-insensitive: the token type is an explorer's label, not a
            // protocol value, and an exact comparison silently drops a real
            // holding if one ever writes "erc-20".  Which types are admitted
            // is unchanged.
            const type = String(item.token?.type || "").toUpperCase();
            if (type !== "ERC-20") continue;

            const tokenAddr = (item.token.address_hash || "").toLowerCase();

            // What the explorer reported, or null. NEVER a default: this
            // value is written to state and every later reader — the approval
            // screen's amount line, the swap lines, the Send screen — takes it
            // as the token's resolved scale. A fabricated 18 reads exactly
            // like a real 18 at that point, so it does not merely display the
            // wrong quantity, it walks straight past the refusal those screens
            // already have for a scale nobody knows
            // (https://git.eeqj.de/sneak/AutistMask/issues/349).
            const decimals = toDecimals(item.token.decimals);

            const raw = rawUnits(item.value);
            // No usable amount at all is nothing to list, exactly as a
            // formatted "0.0" was before. Checked on the base-unit integer so
            // it does not depend on knowing the scale: zero base units is zero
            // tokens at every scale, and a value the explorer did not report
            // as an integer is not a holding.
            if (raw === null || raw === 0n) continue;

            // The scale this row's balance is DISPLAYED at, which is not the
            // same question as what the explorer said. The bundled list and
            // the tokens the user tracks both outrank the explorer already
            // (resolveTokenDecimals), so a token they know keeps showing its
            // real quantity even when the explorer's entry omits decimals.
            // Only what neither of them nor the explorer knows is unknown.
            // The stored `decimals` above stays the explorer's own answer
            // either way: copying another source into it would make
            // explorerDecimals()'s disagreement check compare something other
            // than explorer values.
            const known = resolveTokenDecimals(tokenAddr, { trackedTokens });
            const scale = known !== null ? known : decimals;
            // null is a holding of an amount that cannot be stated, which is
            // not the same as a holding of zero, and must never render as one.
            // With a scale, the display filter proper applies: a balance that
            // rounds to zero at six places is dust and is not listed. Without
            // one there is no such judgement to make, and the row is kept.
            const bal = scale === null ? null : formatTokenBalance(raw, scale);
            if (bal === "0.0") continue;
            // null means the explorer reported no count, which is not the
            // same as a count of zero.  This gate is not the low-holder
            // display filter: it has no user-facing off switch and governs
            // the whole balance list, so it stays strict and admits a token
            // only on a reported count — an unreported one is no evidence.
            // A legitimate token still reaches the list through the known
            // token list or by the user tracking it, and the null is carried
            // through to the views, where the two low-holder filters treat
            // an unknown count as "do not judge" rather than as zero.
            const holders = parseHoldersCount(item.token.holders_count);
            const isKnown = TOKEN_BY_ADDRESS.has(tokenAddr);
            const isTracked = trackedSet.has(tokenAddr);
            const hasEnoughHolders =
                holders !== null && holders >= LOW_HOLDER_THRESHOLD;

            // Skip spam tokens the user never asked to see
            if (!isKnown && !isTracked && !hasEnoughHolders) continue;

            // Skip tokens spoofing a known symbol from a different address.
            // Every row here is an ERC-20 the explorer reported, so it has a
            // contract address; the native ETH balance is fetched over RPC in
            // refreshBalances and never passes through this loop.
            if (isSpoofedSymbol(item.token.symbol, tokenAddr)) continue;

            balances.push({
                address: item.token.address_hash,
                name: item.token.name || "",
                symbol: item.token.symbol || "???",
                // null means the explorer reported no usable scale — unknown,
                // not 18. Distinguishable from a real 18 at read time is the
                // entire point: resolveTokenDecimals() falls through a null to
                // its refusal, and takes an 18 as the answer.
                decimals: decimals,
                // null means nothing anywhere knows the scale, so there is no
                // token quantity to state. Not "0.0": a nonzero holding shown
                // as zero is the same lie in the balance list that the
                // approval screens refuse to tell.
                balance: bal,
                holders: holders,
            });
        }
        return balances;
    } catch (e) {
        log.errorf("fetchTokenBalances failed:", e.message);
        return null;
    }
}

// Fetch ETH balances, ENS names, and ERC-20 token balances for all addresses.
async function refreshBalances(
    wallets,
    rpcUrl,
    blockscoutUrl,
    trackedTokens,
    networkId,
) {
    log.debugf("refreshBalances start, rpc:", rpcUrl);
    const provider = getProvider(rpcUrl, networkId);
    const updates = [];

    for (const wallet of wallets) {
        for (const addr of wallet.addresses) {
            // ETH balance
            updates.push(
                provider
                    .getBalance(addr.address)
                    .then((bal) => {
                        addr.balance = formatBalance(bal);
                        log.debugf("ETH balance", addr.address, addr.balance);
                    })
                    .catch((e) => {
                        log.errorf(
                            "ETH balance failed",
                            addr.address,
                            e.shortMessage || e.message,
                        );
                    }),
            );

            // ENS reverse lookup — only overwrite on success so that
            // transient RPC errors don't wipe a previously resolved name.
            updates.push(
                provider
                    .lookupAddress(addr.address)
                    .then((name) => {
                        addr.ensName = name || null;
                        log.debugf(
                            "ENS reverse",
                            addr.address,
                            "->",
                            addr.ensName,
                        );
                    })
                    .catch((e) => {
                        log.errorf(
                            "ENS reverse failed",
                            addr.address,
                            e.message,
                        );
                        // Keep existing addr.ensName if we had one
                    }),
            );

            // ERC-20 token balances via Blockscout
            updates.push(
                fetchTokenBalances(
                    addr.address,
                    blockscoutUrl,
                    trackedTokens,
                ).then((balances) => {
                    if (balances !== null) {
                        addr.tokenBalances = balances;
                        log.debugf(
                            "Token balances",
                            addr.address,
                            balances.length,
                            "tokens",
                        );
                    }
                }),
            );
        }
    }

    await Promise.all(updates);
    log.debugf("refreshBalances done");
}

// Look up token metadata from its contract.
// Calls symbol() and decimals() to verify it implements ERC-20.
async function lookupTokenInfo(contractAddress, rpcUrl, networkId) {
    log.debugf("lookupTokenInfo", contractAddress, "rpc:", rpcUrl);
    const provider = getProvider(rpcUrl, networkId);
    const contract = new Contract(contractAddress, ERC20_ABI, provider);

    let name, symbol, decimals;
    try {
        symbol = await contract.symbol();
        log.debugf("symbol() =", symbol);
    } catch (e) {
        log.errorf("symbol() failed:", e.shortMessage || e.message);
        throw new Error("Not a valid ERC-20 token (symbol() failed).");
    }

    try {
        decimals = await contract.decimals();
        log.debugf("decimals() =", decimals);
    } catch (e) {
        log.errorf("decimals() failed:", e.shortMessage || e.message);
        throw new Error("Not a valid ERC-20 token (decimals() failed).");
    }

    try {
        name = await contract.name();
        log.debugf("name() =", name);
    } catch (e) {
        log.warnf("name() failed, using symbol as name:", e.message);
        name = symbol;
    }

    // Truncate to prevent storage of excessively long values from RPC
    name = String(name).slice(0, 64);
    symbol = String(symbol).slice(0, 12);

    log.infof("Token resolved:", symbol, "decimals", Number(decimals));
    return { name, symbol, decimals: Number(decimals) };
}

// Derive HD addresses starting from index 0 and check for on-chain activity.
// Checks gapLimit addresses in parallel per batch.  Stops when an entire
// batch has no used addresses (i.e. gapLimit consecutive empty addresses).
// Returns { addresses: [{ address, index }], nextIndex }.
async function scanForAddresses(xpub, rpcUrl, networkId, gapLimit = 5) {
    log.debugf("scanForAddresses start, gapLimit:", gapLimit);
    const provider = getProvider(rpcUrl, networkId);
    const used = [];
    let checked = 0;
    let checkUpTo = gapLimit;

    while (checked < checkUpTo) {
        const batch = [];
        for (let i = checked; i < checkUpTo; i++) {
            const addr = deriveAddressFromXpub(xpub, i);
            batch.push({ addr, index: i });
        }

        const results = await Promise.all(
            batch.map(async ({ addr, index }) => {
                try {
                    const [balance, txCount] = await Promise.all([
                        provider.getBalance(addr),
                        provider.getTransactionCount(addr),
                    ]);
                    return { addr, index, isUsed: balance > 0n || txCount > 0 };
                } catch (e) {
                    log.errorf(
                        "scanForAddresses check failed",
                        addr,
                        e.shortMessage || e.message,
                    );
                    return { addr, index, isUsed: false };
                }
            }),
        );

        checked = checkUpTo;

        for (const r of results) {
            if (r.isUsed) {
                used.push({ address: r.addr, index: r.index });
                log.debugf("scanForAddresses used", r.addr, "index:", r.index);
                checkUpTo = Math.max(checkUpTo, r.index + 1 + gapLimit);
            }
        }
    }

    used.sort((a, b) => a.index - b.index);
    const nextIndex = used.length > 0 ? used[used.length - 1].index + 1 : 1;
    log.infof(
        "scanForAddresses done, found:",
        used.length,
        "nextIndex:",
        nextIndex,
    );
    return { addresses: used, nextIndex };
}

module.exports = {
    fetchTokenBalances,
    refreshBalances,
    lookupTokenInfo,
    getProvider,
    scanForAddresses,
};
