// Transaction history fetching via Blockscout v2 API.
// Fetches normal transactions and ERC-20 token transfers,
// merges them, and returns the most recent entries.
//
// Filtering is separated from fetching: fetchRecentTransactions returns
// raw parsed data including token metadata, and filterTransactions is
// a pure function that applies anti-poisoning heuristics.

const { formatEther, formatUnits } = require("ethers");
const { log, debugFetch } = require("./log");
const { TOKEN_BY_ADDRESS } = require("./tokenList");
const { parseHoldersCount, isLowHolderCount } = require("./holders");
const { isSpoofedSymbol } = require("./symbolSpoof");
// The uint8 test every scale in this wallet goes through. Shared, not copied:
// a scale is either reported or it is unknown, and "unknown" must mean the
// same thing here as it does on the screens that refuse to format one.
const { toDecimals } = require("./transferAmount");
// The plain 4-decimal rule. The history and balance lists deliberately keep
// truncation without the approval screens' nonzero floor: the transaction
// detail view is the authoritative record and already shows exact precision.
const { truncateAmount: formatTxValue } = require("./amountDisplay");

// Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum
// over the address, not part of its identity.  Every address comparison in
// this file goes through this helper, so an address arriving in checksummed
// or upper-case form can never be read as a different address.
function normalizeAddress(addr) {
    return (addr || "").toLowerCase();
}

function parseTx(tx, addrLower) {
    const from = tx.from?.hash || "";
    const to = tx.to?.hash || "";
    const rawWei = tx.value || "0";
    const toIsContract = tx.to?.is_contract || false;
    const method = tx.method || null;

    // For contract calls, produce a meaningful label instead of "0.0000 ETH"
    let symbol = "ETH";
    let value = formatTxValue(formatEther(rawWei));
    let exactValue = formatEther(rawWei);
    let rawAmount = rawWei;
    let rawUnit = "wei";
    let direction = normalizeAddress(from) === addrLower ? "sent" : "received";
    let directionLabel = direction === "sent" ? "Sent" : "Received";
    if (toIsContract && method && method !== "transfer") {
        const token = TOKEN_BY_ADDRESS.get(normalizeAddress(to));
        if (token) {
            symbol = token.symbol;
        }
        // Map known DEX methods to "Swap" for cleaner display
        const SWAP_METHODS = new Set([
            "execute",
            "swap",
            "swapExactTokensForTokens",
            "swapTokensForExactTokens",
            "swapExactETHForTokens",
            "swapTokensForExactETH",
            "swapExactTokensForETH",
            "swapETHForExactTokens",
            "multicall",
        ]);
        const label = SWAP_METHODS.has(method)
            ? "Swap"
            : method.charAt(0).toUpperCase() + method.slice(1);
        direction = "contract";
        directionLabel = label;
        value = "";
        exactValue = "";
        rawAmount = "";
        rawUnit = "";
    }

    return {
        hash: tx.hash,
        blockNumber: tx.block_number,
        timestamp: Math.floor(new Date(tx.timestamp).getTime() / 1000),
        from: from,
        to: to,
        value: value,
        exactValue: exactValue,
        rawAmount: rawAmount,
        rawUnit: rawUnit,
        valueGwei: Math.floor(Number(BigInt(rawWei) / BigInt(1000000000))),
        symbol: symbol,
        direction: direction,
        directionLabel: directionLabel,
        isError: tx.status !== "ok",
        contractAddress: null,
        holders: null,
        isContractCall: toIsContract,
        method: method,
    };
}

function parseTokenTransfer(tt, addrLower) {
    const from = tt.from?.hash || "";
    const to = tt.to?.hash || "";
    // The explorer's own answer, or null. Never a default: a transfer of
    // 5000000000 units formatted at a guessed 18 reads as 0.000000005, and
    // nothing downstream can tell that from a real 18-decimal transfer of
    // that size. `parseInt(x || "18", 10)` also collapsed a genuine scale of
    // ZERO into 18 (https://git.eeqj.de/sneak/AutistMask/issues/246).
    const decimals = toDecimals(tt.total?.decimals);
    const rawVal = tt.total?.value || "0";
    const direction =
        normalizeAddress(from) === addrLower ? "sent" : "received";
    const sym = tt.token?.symbol || "?";
    // Without a scale there is no token quantity, so none is stated: the list
    // row falls back to the symbol alone and the detail screen to its
    // direction label, exactly as the contract-call rows above already do.
    // The exact figure is not lost — it is the base-unit line below, which is
    // the one number that needs no scale to be true.
    const formatted =
        decimals === null ? "" : formatTxValue(formatUnits(rawVal, decimals));
    const exact = decimals === null ? "" : formatUnits(rawVal, decimals);
    return {
        hash: tt.transaction_hash,
        blockNumber: tt.block_number,
        timestamp: Math.floor(new Date(tt.timestamp).getTime() / 1000),
        from: from,
        to: to,
        value: formatted,
        exactValue: exact,
        rawAmount: rawVal,
        rawUnit:
            decimals === null
                ? sym + " base units (decimals unknown)"
                : sym + " base units (10^-" + decimals + ")",
        valueGwei: null,
        symbol: sym,
        direction: direction,
        directionLabel: direction === "sent" ? "Sent" : "Received",
        isError: false,
        contractAddress: normalizeAddress(
            tt.token?.address_hash || tt.token?.address || "",
        ),
        // null when the explorer reported no count: unknown, not zero.  The
        // low-holder filter declines to judge a null, so a legitimate token
        // is not hidden because a field went missing upstream.
        holders: parseHoldersCount(tt.token?.holders_count),
    };
}

// True when a parsed native entry moved no ETH.  Contract-call entries have
// their amount fields blanked by parseTx, so they are never judged here.
function movedNoEther(tx) {
    if (tx.direction === "contract") return false;
    return BigInt(tx.rawAmount || "0") === BigInt(0);
}

// Merge parsed normal transactions with parsed ERC-20 token transfers into
// one row per distinct value movement.  Pure: it reads only its arguments
// and returns a new list sorted newest block first.
//
// The merge key is the transaction hash for the native entry and
// hash + token contract for each token transfer, so:
//
//   - A display-level contract call (a swap and friends, direction
//     "contract") absorbs every token leg of its hash into the single
//     native entry, because the legs are hops of one operation rather
//     than separate movements the user made.
//   - Otherwise each distinct token contract in the transaction keeps its
//     own row, so a hash carrying several genuine transfers stays several
//     rows.
//   - The native entry of such a transaction is dropped when it moved no
//     ETH and at least one token transfer shares its hash: that entry is
//     the ERC-20 call itself, already represented by the token row.  A
//     native entry that moved ETH survives alongside the token rows, since
//     the ETH and the tokens are two real movements, and a zero-value
//     native transaction with no token transfer on its hash survives too.
function mergeTransactions(txs, tokenTransfers) {
    const byKey = new Map();

    // Entries are copied so consolidation never writes through to the
    // caller's objects.
    for (const tx of txs) {
        byKey.set(tx.hash, { ...tx });
    }

    const absorbedHashes = new Set();

    for (const parsed of tokenTransfers) {
        const existing = byKey.get(parsed.hash);
        if (existing && existing.direction === "contract") {
            // For contract calls (swaps), consolidate into the original
            // tx entry.  Prefer the "received" transfer (swap output)
            // for the display amount.  If no received transfer exists,
            // fall back to the first "sent" transfer (swap input).
            const isReceived = parsed.direction === "received";
            const needsAmount = !existing.exactValue;
            if (isReceived || needsAmount) {
                existing.value = parsed.value;
                existing.exactValue = parsed.exactValue;
                existing.rawAmount = parsed.rawAmount;
                existing.rawUnit = parsed.rawUnit;
                existing.symbol = parsed.symbol;
                existing.contractAddress = parsed.contractAddress;
                existing.holders = parsed.holders;
            }
            // Keep the original tx's from/to (the user's address and the
            // contract they called), not the token transfer's from/to
            // which may be a router or Permit2 contract.
            continue;
        }
        if (existing && movedNoEther(existing)) {
            absorbedHashes.add(parsed.hash);
        }
        // Every other token transfer gets its own entry.
        byKey.set(parsed.hash + ":" + (parsed.contractAddress || ""), {
            ...parsed,
        });
    }

    for (const hash of absorbedHashes) {
        byKey.delete(hash);
    }

    const merged = [...byKey.values()];
    merged.sort((a, b) => b.blockNumber - a.blockNumber);
    return merged;
}

async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
    log.debugf("fetchRecentTransactions", address);
    const addrLower = normalizeAddress(address);

    const [txResp, ttResp] = await Promise.all([
        debugFetch(blockscoutUrl + "/addresses/" + address + "/transactions"),
        debugFetch(
            blockscoutUrl +
                "/addresses/" +
                address +
                "/token-transfers?type=ERC-20",
        ),
    ]);

    if (!txResp.ok) {
        log.errorf(
            "blockscout transactions:",
            txResp.status,
            txResp.statusText,
        );
    }
    if (!ttResp.ok) {
        log.errorf(
            "blockscout token-transfers:",
            ttResp.status,
            ttResp.statusText,
        );
    }

    const txJson = txResp.ok ? await txResp.json() : {};
    const ttJson = ttResp.ok ? await ttResp.json() : {};

    const txs = mergeTransactions(
        (txJson.items || []).map((tx) => parseTx(tx, addrLower)),
        (ttJson.items || []).map((tt) => parseTokenTransfer(tt, addrLower)),
    );

    const result = txs.slice(0, count);
    log.debugf("fetchRecentTransactions done, count:", result.length);
    return result;
}

// Pure filter function. Takes raw transactions and filter settings,
// returns { transactions, newFraudContracts }.
function filterTransactions(txs, filters = {}) {
    const fraudSet = new Set(
        (filters.fraudContracts || []).map(normalizeAddress),
    );
    // The dust threshold defaults only when it is unset (nullish): a
    // threshold of 0 is a real value meaning "hide nothing", since no
    // transaction has a value below 0 gwei.  It is therefore equivalent to
    // clearing the hide-dust checkbox, and the two controls cannot override
    // each other in either direction.
    const dustThresholdGwei = filters.dustThresholdGwei ?? 100000;
    const newFraud = [];
    const filtered = [];
    // Fail-safe, unlike the three flags below: this one is off only when the
    // caller says so explicitly, so a caller that omits the key keeps the
    // check rather than silently losing it.  The setting also governs the
    // blocklist learning below, which exists only to serve this check —
    // leaving learning on while the check is off would re-hide the very rows
    // the user asked to see, through the fraud-contract rule.
    const hideSpoofed = filters.hideSpoofedSymbols !== false;

    for (const tx of txs) {
        const contract = normalizeAddress(tx.contractAddress);

        // Filter spoofed known symbols and record the fraud contract
        if (hideSpoofed && isSpoofedSymbol(tx.symbol, tx.contractAddress)) {
            if (contract && !fraudSet.has(contract)) {
                fraudSet.add(contract);
                newFraud.push(contract);
            }
            continue;
        }

        // Filter fraud contracts if setting is on
        if (filters.hideFraudContracts && contract && fraudSet.has(contract)) {
            continue;
        }

        // Filter low-holder tokens (<1000) if setting is on.  A token whose
        // holder count the explorer did not report is kept: only a reported
        // count below the threshold is "low".
        if (
            filters.hideLowHolderTokens &&
            tx.contractAddress &&
            isLowHolderCount(tx.holders)
        ) {
            continue;
        }

        // Filter dust transactions (below gwei threshold) if setting is on.
        // Contract calls (approve, transfer, etc.) often have 0 ETH value
        // and should never be filtered as dust.
        if (
            filters.hideDustTransactions &&
            !tx.isContractCall &&
            tx.valueGwei !== null &&
            tx.valueGwei < dustThresholdGwei
        ) {
            continue;
        }

        filtered.push(tx);
    }

    return { transactions: filtered, newFraudContracts: newFraud };
}

module.exports = {
    fetchRecentTransactions,
    filterTransactions,
    mergeTransactions,
};
