// 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 { KNOWN_SYMBOLS, TOKEN_BY_ADDRESS } = require("./tokenList");

function formatTxValue(val) {
    const parts = val.split(".");
    if (parts.length === 1) return val + ".0000";
    const dec = (parts[1] + "0000").slice(0, 4);
    return parts[0] + "." + dec;
}

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 = from.toLowerCase() === addrLower ? "sent" : "received";
    let directionLabel = direction === "sent" ? "Sent" : "Received";
    if (toIsContract && method && method !== "transfer") {
        const token = TOKEN_BY_ADDRESS.get(to.toLowerCase());
        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 || "";
    const decimals = parseInt(tt.total?.decimals || "18", 10);
    const rawVal = tt.total?.value || "0";
    const direction = from.toLowerCase() === addrLower ? "sent" : "received";
    const sym = tt.token?.symbol || "?";
    return {
        hash: tt.transaction_hash,
        blockNumber: tt.block_number,
        timestamp: Math.floor(new Date(tt.timestamp).getTime() / 1000),
        from: from,
        to: to,
        value: formatTxValue(formatUnits(rawVal, decimals)),
        exactValue: formatUnits(rawVal, decimals),
        rawAmount: rawVal,
        rawUnit: sym + " base units (10^-" + decimals + ")",
        valueGwei: null,
        symbol: sym,
        direction: direction,
        directionLabel: direction === "sent" ? "Sent" : "Received",
        isError: false,
        contractAddress: (
            tt.token?.address_hash ||
            tt.token?.address ||
            ""
        ).toLowerCase(),
        holders: parseInt(tt.token?.holders_count || "0", 10),
    };
}

// 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 = address.toLowerCase();

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

// Check if a token transfer is spoofing a known symbol.
// Returns true if the symbol matches a known token but the contract
// address doesn't match the legitimate one.
function isSpoofedSymbol(tx) {
    if (!tx.contractAddress) return false;
    const symbol = (tx.symbol || "").toUpperCase();
    if (!KNOWN_SYMBOLS.has(symbol)) return false;
    const legit = KNOWN_SYMBOLS.get(symbol);
    if (legit === null) return true; // "ETH" as ERC-20 is always fake
    return tx.contractAddress !== legit;
}

// Pure filter function. Takes raw transactions and filter settings,
// returns { transactions, newFraudContracts }.
function filterTransactions(txs, filters = {}) {
    const fraudSet = new Set(
        (filters.fraudContracts || []).map((a) => a.toLowerCase()),
    );
    const newFraud = [];
    const filtered = [];

    for (const tx of txs) {
        // Always filter spoofed known symbols and record the fraud contract
        if (isSpoofedSymbol(tx)) {
            if (tx.contractAddress && !fraudSet.has(tx.contractAddress)) {
                fraudSet.add(tx.contractAddress);
                newFraud.push(tx.contractAddress);
            }
            continue;
        }

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

        // Filter low-holder tokens (<1000) if setting is on
        if (
            filters.hideLowHolderTokens &&
            tx.contractAddress &&
            tx.holders !== null &&
            tx.holders < 1000
        ) {
            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 < (filters.dustThresholdGwei || 100000)
        ) {
            continue;
        }

        filtered.push(tx);
    }

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

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