from __future__ import annotations

from typing import Any, Callable
from urllib.parse import urlsplit, urlunsplit

from web3 import Web3
from web3.exceptions import TransactionNotFound
from eth_utils import keccak

from brawny._rpc.errors import (
    RPCError,
    RPCFatalError,
    RPCRecoverableError,
    RPCDecode,
    RPCPermanent,
    RPCTransient,
    RPCRateLimited,
    RpcErrorKind,
    classify_rpc_error,
)
from brawny.metrics import (
    RPC_ERROR_CLASSIFIED,
    RPC_ERROR_UNKNOWN,
    get_metrics,
)
from brawny.network_guard import allow_network_calls
from brawny.timeout import Deadline


def _extract_url_auth(url: str) -> tuple[str, tuple[str, str] | None]:
    split = urlsplit(url)
    if split.username or split.password:
        auth = (split.username or "", split.password or "")
        clean_netloc = split.hostname or ""
        if split.port:
            clean_netloc = f"{clean_netloc}:{split.port}"
        clean_url = urlunsplit((split.scheme, clean_netloc, split.path, split.query, split.fragment))
        return clean_url, auth
    return url, None


_FATAL_KINDS = frozenset({
    RpcErrorKind.NONCE_TOO_LOW,
    RpcErrorKind.NONCE_TOO_HIGH,
    RpcErrorKind.INSUFFICIENT_FUNDS,
    RpcErrorKind.INTRINSIC_GAS_TOO_LOW,
    RpcErrorKind.GAS_LIMIT_EXCEEDED,
    RpcErrorKind.TX_TYPE_NOT_SUPPORTED,
    RpcErrorKind.EXECUTION_REVERTED,
    RpcErrorKind.OUT_OF_GAS,
    RpcErrorKind.INVALID_PARAMS,
    RpcErrorKind.PARSE_ERROR,
    RpcErrorKind.BAD_REQUEST,
})

_RECOVERABLE_KINDS = frozenset({
    RpcErrorKind.REPLACEMENT_UNDERPRICED,
    RpcErrorKind.TX_UNDERPRICED,
    RpcErrorKind.MAX_FEE_TOO_LOW,
})


def _map_kind_to_class(kind: RpcErrorKind) -> type[RPCError]:
    if kind in _RECOVERABLE_KINDS:
        return RPCRecoverableError
    if kind == RpcErrorKind.RATE_LIMIT:
        return RPCRateLimited
    if kind in (RpcErrorKind.PARSE_ERROR, RpcErrorKind.BAD_REQUEST):
        return RPCDecode
    if kind in (RpcErrorKind.INVALID_PARAMS,):
        return RPCPermanent
    if kind == RpcErrorKind.DEADLINE_EXHAUSTED:
        return RPCPermanent
    if kind in _FATAL_KINDS:
        return RPCFatalError
    return RPCTransient


def _handle_already_known(raw_tx: bytes) -> str:
    if isinstance(raw_tx, str):
        raw_tx = raw_tx[2:] if raw_tx.startswith("0x") else raw_tx
        raw_tx = bytes.fromhex(raw_tx)
    return "0x" + keccak(raw_tx).hex()


class Caller:
    """Execute a single RPC call against a specific endpoint."""

    def __init__(self, endpoints: list[str], timeout_seconds: float, chain_id: int | None) -> None:
        self._timeout = timeout_seconds
        self._chain_id = chain_id
        self._web3_instances: dict[str, Web3] = {}
        self._web3_timeout_cache: dict[tuple[str, float], Web3] = {}
        for endpoint in endpoints:
            clean_url, auth = _extract_url_auth(endpoint)
            request_kwargs: dict[str, Any] = {"timeout": timeout_seconds}
            if auth:
                request_kwargs["auth"] = auth
            self._web3_instances[endpoint] = Web3(
                Web3.HTTPProvider(clean_url, request_kwargs=request_kwargs)
            )

    def get_web3(self, endpoint_url: str, timeout: float) -> Web3:
        if timeout == self._timeout:
            return self._web3_instances[endpoint_url]
        key = (endpoint_url, timeout)
        cached = self._web3_timeout_cache.get(key)
        if cached is not None:
            return cached
        clean_url, auth = _extract_url_auth(endpoint_url)
        request_kwargs: dict[str, Any] = {"timeout": timeout}
        if auth:
            request_kwargs["auth"] = auth
        w3 = Web3(Web3.HTTPProvider(clean_url, request_kwargs=request_kwargs))
        self._web3_timeout_cache[key] = w3
        return w3

    def call(
        self,
        endpoint_url: str,
        method: str,
        args: tuple[Any, ...],
        *,
        timeout: float,
        deadline: Deadline | None,
        block_identifier: int | str,
    ) -> Any:
        w3 = self.get_web3(endpoint_url, timeout)
        try:
            with allow_network_calls(reason="rpc"):
                return self._execute_method(w3, method, args, block_identifier)
        except Exception as exc:  # noqa: BLE001 - preserve original exception
            if isinstance(exc, RPCError):
                if exc.method is None or exc.endpoint is None:
                    raise type(exc)(str(exc), code=exc.code, method=method, endpoint=endpoint_url) from exc
                raise

            extracted = classify_rpc_error(exc, endpoint=endpoint_url, method=method, deadline=deadline)
            if extracted.kind == RpcErrorKind.ALREADY_KNOWN and method == "eth_sendRawTransaction":
                raw_tx = args[0] if args else b""
                return _handle_already_known(raw_tx)
            self._raise_classified(exc, extracted, endpoint_url, method)

    def call_with_web3(
        self,
        endpoint_url: str,
        *,
        timeout: float,
        deadline: Deadline | None,
        method: str,
        fn: Callable[[Web3], Any],
    ) -> Any:
        w3 = self.get_web3(endpoint_url, timeout)
        try:
            with allow_network_calls(reason="rpc"):
                return fn(w3)
        except Exception as exc:  # noqa: BLE001
            if isinstance(exc, RPCError):
                if exc.method is None or exc.endpoint is None:
                    raise type(exc)(str(exc), code=exc.code, method=method, endpoint=endpoint_url) from exc
                raise
            extracted = classify_rpc_error(exc, endpoint=endpoint_url, method=method, deadline=deadline)
            self._raise_classified(exc, extracted, endpoint_url, method)

    @staticmethod
    def _raise_classified(
        exc: Exception,
        extracted,
        endpoint_url: str,
        method: str,
    ) -> None:
        metrics = get_metrics()
        if extracted.kind == RpcErrorKind.UNKNOWN:
            metrics.counter(RPC_ERROR_UNKNOWN).inc(
                method=method or "unknown",
                exception_type=type(exc).__name__,
                provider=extracted.provider or "unknown",
                http_status=str(extracted.http_status or "none"),
                jsonrpc_code=str(extracted.code or "none"),
            )
            raise exc
        metrics.counter(RPC_ERROR_CLASSIFIED).inc(
            kind=extracted.kind.value,
            method=method or "unknown",
            source=extracted.classification_source,
        )

        error_class = _map_kind_to_class(extracted.kind)
        err = error_class(
            str(exc),
            code=extracted.kind.value,
            endpoint=endpoint_url,
            method=method,
        )
        setattr(err, "failover_ok", extracted.failover_ok)
        setattr(err, "classification_kind", extracted.kind)
        raise err from exc

    @staticmethod
    def _execute_method(
        w3: Web3,
        method: str,
        args: tuple[Any, ...],
        block_identifier: int | str,
    ) -> Any:
        if method == "eth_blockNumber":
            return w3.eth.block_number
        if method == "eth_getBlockByNumber":
            block_num = args[0] if args else "latest"
            full_tx = args[1] if len(args) > 1 else False
            return w3.eth.get_block(block_num, full_transactions=full_tx)
        if method == "eth_getTransactionCount":
            address = args[0]
            block = args[1] if len(args) > 1 else "pending"
            return w3.eth.get_transaction_count(address, block)
        if method == "eth_getTransactionReceipt":
            tx_hash = args[0]
            try:
                return w3.eth.get_transaction_receipt(tx_hash)
            except TransactionNotFound:
                return None
        if method == "eth_sendRawTransaction":
            return w3.eth.send_raw_transaction(args[0])
        if method == "eth_estimateGas":
            return w3.eth.estimate_gas(args[0], block_identifier=block_identifier)
        if method == "eth_call":
            tx = args[0]
            block = args[1] if len(args) > 1 else block_identifier
            return w3.eth.call(tx, block_identifier=block)
        if method == "eth_getStorageAt":
            address = args[0]
            slot = args[1]
            block = args[2] if len(args) > 2 else block_identifier
            return w3.eth.get_storage_at(address, slot, block_identifier=block)
        if method == "eth_chainId":
            return w3.eth.chain_id
        if method == "eth_gasPrice":
            return w3.eth.gas_price
        if method == "eth_getBalance":
            address = args[0]
            block = args[1] if len(args) > 1 else block_identifier
            return w3.eth.get_balance(address, block_identifier=block)
        return w3.provider.make_request(method, list(args))
