"""
Blockchain data fetcher for CrashSense.

Uses JSON-RPC and block-explorer APIs (Etherscan-compatible) to retrieve
transaction receipts, input data, revert reasons, and contract ABIs.
Works with any EVM chain (Ethereum, Polygon, BSC, Arbitrum, Base, …).
"""

from __future__ import annotations

import json
import re
from typing import Any, Dict, List, Optional

import requests

from ..config import get_chain_info, load_config

# ── Well-known 4-byte error selectors ─────────────────────────────────────
ERROR_SELECTORS: Dict[str, str] = {
    "0x08c379a0": "Error(string)",
    "0x4e487b71": "Panic(uint256)",
    "0xe450d38c": "ERC20InsufficientBalance(address,uint256,uint256)",
    "0xfb8f41b2": "ERC20InsufficientAllowance(address,uint256,uint256)",
    "0x3b99b53d": "SlippageExceeded()",
    "0xf4d678b8": "InsufficientOutputAmount()",
    "0xbd7bc3a5": "UniswapV2: INSUFFICIENT_LIQUIDITY",
    "0xcf479181": "STF()",  # SafeTransferFrom failed
}

# Panic code → human description
PANIC_CODES: Dict[int, str] = {
    0x00: "Generic / compiler-inserted panic",
    0x01: "Assertion failure (assert)",
    0x11: "Arithmetic overflow / underflow",
    0x12: "Division or modulo by zero",
    0x21: "Conversion to invalid enum value",
    0x22: "Access to incorrectly-encoded storage byte array",
    0x31: "pop() on an empty array",
    0x32: "Out-of-bounds array access",
    0x41: "Too much memory allocated",
    0x51: "Called a zero-initialized function pointer",
}

# ── Common ERC-20 / DeFi function selectors ───────────────────────────────
FUNCTION_SELECTORS: Dict[str, str] = {
    "0xa9059cbb": "transfer(address,uint256)",
    "0x23b872dd": "transferFrom(address,address,uint256)",
    "0x095ea7b3": "approve(address,uint256)",
    "0x70a08231": "balanceOf(address)",
    "0x18160ddd": "totalSupply()",
    "0xdd62ed3e": "allowance(address,address)",
    "0x38ed1739": "swapExactTokensForTokens(…)",
    "0x7ff36ab5": "swapExactETHForTokens(…)",
    "0x18cbafe5": "swapExactTokensForETH(…)",
    "0x5c11d795": "swapExactTokensForTokensSupportingFeeOnTransferTokens(…)",
    "0xe8e33700": "addLiquidity(…)",
    "0xf305d719": "addLiquidityETH(…)",
    "0xbaa2abde": "removeLiquidity(…)",
    "0x2e1a7d4d": "withdraw(uint256)",
    "0xd0e30db0": "deposit()",
    "0x3593564c": "execute(bytes,bytes[],uint256)",  # Uniswap UniversalRouter
    "0x04e45aaf": "exactInputSingle(…)",  # Uniswap V3
    "0x414bf389": "exactInputSingle(…)",  # Uniswap V3 (alt)
    "0xc04b8d59": "exactInput(…)",
    "0xdb3e2198": "exactOutputSingle(…)",
    "0x472b43f3": "swapExactTokensForTokens(uint256,uint256,address[],address)",
    "0x36c78516": "transferOutAndCallV5(…)",
}


class ChainFetcher:
    """Fetches and decodes EVM transaction data using JSON-RPC + explorer API."""

    def __init__(self, cfg: Optional[Dict[str, Any]] = None) -> None:
        self.cfg = cfg or load_config()
        self.chain_info = get_chain_info(self.cfg)
        self.rpc_url: str = self.chain_info["rpc_url"]
        self.explorer_api: str = self.chain_info.get("explorer_api", "")
        self.explorer_key: str = self.cfg.get("chain", {}).get("explorer_api_key", "")
        self._session = requests.Session()
        self._session.headers["Content-Type"] = "application/json"

    # ── JSON-RPC helpers ──────────────────────────────────────────────────

    def _rpc_call(self, method: str, params: list, timeout: int = 30) -> Any:
        """Execute a JSON-RPC call against the configured node."""
        payload = {"jsonrpc": "2.0", "id": 1, "method": method, "params": params}
        resp = self._session.post(self.rpc_url, json=payload, timeout=timeout)
        resp.raise_for_status()
        data = resp.json()
        if "error" in data:
            raise RuntimeError(f"RPC error: {data['error']}")
        return data.get("result")

    # ── Public API ────────────────────────────────────────────────────────

    def get_transaction(self, tx_hash: str) -> Dict[str, Any]:
        """Fetch full transaction object (eth_getTransactionByHash)."""
        return self._rpc_call("eth_getTransactionByHash", [tx_hash]) or {}

    def get_receipt(self, tx_hash: str) -> Dict[str, Any]:
        """Fetch transaction receipt (eth_getTransactionReceipt)."""
        return self._rpc_call("eth_getTransactionReceipt", [tx_hash]) or {}

    def get_revert_reason(self, tx: Dict[str, Any]) -> str:
        """
        Try to extract the revert reason by replaying the call via eth_call.
        Falls back to heuristic decoding from the RPC error message.
        """
        try:
            call_params = {
                "from": tx.get("from", ""),
                "to": tx.get("to", ""),
                "data": tx.get("input", "0x"),
                "value": tx.get("value", "0x0"),
                "gas": tx.get("gas", "0x0"),
            }
            block = tx.get("blockNumber", "latest")
            result = self._rpc_call("eth_call", [call_params, block])
            return self._decode_revert_data(result or "")
        except RuntimeError as exc:
            msg = str(exc)
            # Many nodes embed the revert reason inside the RPC error
            m = re.search(r"execution reverted:?\s*(.*?)(?:\"|$)", msg, re.I)
            if m and m.group(1).strip():
                return m.group(1).strip()
            # Try to find hex-encoded revert data in the error
            m = re.search(r"(0x[0-9a-fA-F]{8,})", msg)
            if m:
                return self._decode_revert_data(m.group(0))
            return msg
        except Exception:
            return ""

    def get_contract_abi(self, address: str) -> List[Dict]:
        """Fetch verified contract ABI from an etherscan-compatible explorer."""
        if not self.explorer_api or not self.explorer_key:
            return []
        try:
            chain_id = self.chain_info.get("chain_id", 1)
            params: Dict[str, str] = {
                "module": "contract",
                "action": "getabi",
                "address": address,
                "apikey": self.explorer_key,
            }
            # Etherscan v2 takes chainid as a param
            if "etherscan.io/v2" in self.explorer_api:
                params["chainid"] = str(chain_id)
            resp = self._session.get(self.explorer_api, params=params, timeout=15)
            data = resp.json()
            if data.get("status") == "1" and data.get("result"):
                return json.loads(data["result"])
        except Exception:
            pass
        return []

    def fetch_full_context(self, tx_hash: str) -> Dict[str, Any]:
        """
        One-stop method: fetch tx + receipt + decoded input + revert reason.
        Returns a structured dict ready for LLM analysis.
        """
        tx = self.get_transaction(tx_hash)
        if not tx:
            chain_name = self.cfg.get("chain", {}).get("name", "the chain")
            return {"error": f"Transaction {tx_hash} not found on {chain_name}"}

        receipt = self.get_receipt(tx_hash)
        status_ok = receipt.get("status") == "0x1" if receipt else None

        # Decode input
        input_data = tx.get("input", "0x")
        func_sig = self.decode_function_selector(input_data)

        # Gas analysis
        gas_limit = _hex_to_int(tx.get("gas", "0x0"))
        gas_used = _hex_to_int(receipt.get("gasUsed", "0x0")) if receipt else 0
        gas_price = _hex_to_int(tx.get("gasPrice", "0x0"))
        eff_gas_price = _hex_to_int(receipt.get("effectiveGasPrice", "0x0")) if receipt else gas_price
        value_wei = _hex_to_int(tx.get("value", "0x0"))

        # Revert reason (only if failed)
        revert_reason = ""
        if receipt and not status_ok:
            revert_reason = self.get_revert_reason(tx)

        # Logs summary
        logs = receipt.get("logs", []) if receipt else []
        log_summaries = self._summarise_logs(logs)

        return {
            "tx_hash": tx_hash,
            "chain": self.cfg.get("chain", {}).get("name", "ethereum"),
            "from": tx.get("from", ""),
            "to": tx.get("to", ""),
            "value_wei": value_wei,
            "value_eth": value_wei / 1e18 if value_wei else 0.0,
            "input_data": input_data[:200] + ("…" if len(input_data) > 200 else ""),
            "function_called": func_sig,
            "gas_limit": gas_limit,
            "gas_used": gas_used,
            "gas_utilisation_pct": round(gas_used / gas_limit * 100, 1) if gas_limit else 0,
            "gas_price_gwei": round(gas_price / 1e9, 4) if gas_price else 0,
            "effective_gas_price_gwei": round(eff_gas_price / 1e9, 4) if eff_gas_price else 0,
            "tx_fee_eth": round(gas_used * eff_gas_price / 1e18, 8) if gas_used and eff_gas_price else 0,
            "status": "success" if status_ok else ("failed" if status_ok is False else "pending"),
            "revert_reason": revert_reason,
            "block_number": _hex_to_int(tx.get("blockNumber", "0x0")),
            "logs_count": len(logs),
            "log_summaries": log_summaries,
            "nonce": _hex_to_int(tx.get("nonce", "0x0")),
            "contract_address": receipt.get("contractAddress") if receipt else None,
        }

    # ── Decoding helpers ──────────────────────────────────────────────────

    @staticmethod
    def decode_function_selector(input_data: str) -> str:
        """Decode 4-byte function selector to a human-readable name."""
        if not input_data or input_data == "0x":
            return "plain ETH transfer"
        selector = input_data[:10].lower()
        return FUNCTION_SELECTORS.get(selector, f"unknown({selector})")

    @staticmethod
    def _decode_revert_data(data: str) -> str:
        """Decode ABI-encoded revert data to a human-readable string."""
        if not data or data in ("0x", "0X", ""):
            return "Transaction reverted without a reason string"

        raw = data.lower().replace("0x", "")

        # Error(string): selector 08c379a0
        if raw.startswith("08c379a0") and len(raw) >= 136:
            try:
                length = int(raw[72:136], 16)
                start = 136
                msg_hex = raw[start: start + length * 2]
                return bytes.fromhex(msg_hex).decode("utf-8", errors="replace")
            except Exception:
                pass

        # Panic(uint256): selector 4e487b71
        if raw.startswith("4e487b71") and len(raw) >= 72:
            try:
                code = int(raw[8:72], 16)
                return f"Panic: {PANIC_CODES.get(code, f'unknown panic code 0x{code:02x}')}"
            except Exception:
                pass

        # Check other known 4-byte selectors
        sel = "0x" + raw[:8]
        if sel in ERROR_SELECTORS:
            return f"Custom error: {ERROR_SELECTORS[sel]}"

        return f"Raw revert data: 0x{raw[:128]}{'…' if len(raw) > 128 else ''}"

    @staticmethod
    def _summarise_logs(logs: List[Dict]) -> List[Dict[str, str]]:
        """Extract concise summaries from event logs."""
        TRANSFER = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
        APPROVAL = "0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925"
        SWAP_V2 = "0xd78ad95fa46c994b6551d0da85fc275fe613ce37657fb8d5e3d130840159d822"
        SWAP_V3 = "0xc42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67"

        summaries: List[Dict[str, str]] = []
        for log in logs[:25]:
            topics = log.get("topics", [])
            if not topics:
                continue
            t0 = topics[0].lower()
            addr = log.get("address", "")
            if t0 == TRANSFER:
                summaries.append({"event": "Transfer", "contract": addr})
            elif t0 == APPROVAL:
                summaries.append({"event": "Approval", "contract": addr})
            elif t0 in (SWAP_V2, SWAP_V3):
                summaries.append({"event": "Swap", "contract": addr})
            else:
                summaries.append({"event": f"Event({t0[:10]}…)", "contract": addr})
        return summaries


# ── Module-level helpers ──────────────────────────────────────────────────

def _hex_to_int(val: Any) -> int:
    """Safely convert a hex string or int to int."""
    if isinstance(val, int):
        return val
    if isinstance(val, str):
        val = val.strip()
        if val.startswith("0x") or val.startswith("0X"):
            return int(val, 16)
        if val.isdigit():
            return int(val)
    return 0
