import warnings

from eth_abi import (
    abi,
)
from eth_utils import (
    to_bytes,
)

from web3.exceptions import (
    BlockNotFound,
    ContractCustomError,
    ContractLogicError,
    ContractPanicError,
    OffchainLookup,
    TransactionIndexingInProgress,
    Web3ValueError,
)
from web3.types import (
    RPCResponse,
)

# func selector for "Error(string)"
SOLIDITY_ERROR_FUNC_SELECTOR = "0x08c379a0"

# --- CCIP Read - EIP-3668 --- #
# the first 4 bytes of keccak hash (func selector) for:
# "OffchainLookup(address,string[],bytes,bytes4,bytes)"
OFFCHAIN_LOOKUP_FUNC_SELECTOR = "0x556f1830"
OFFCHAIN_LOOKUP_FIELDS = {
    "sender": "address",
    "urls": "string[]",
    "callData": "bytes",
    "callbackFunction": "bytes4",
    "extraData": "bytes",
}


# --- Solidity Panic Error, as of Solidity 0.8.0 --- #
PANIC_ERROR_FUNC_SELECTOR = "0x4e487b71"
PANIC_ERROR_CODES = {
    "00": "Panic error 0x00: Generic compiler inserted panics.",
    "01": "Panic error 0x01: Assert evaluates to false.",
    "11": "Panic error 0x11: Arithmetic operation results in underflow or overflow.",
    "12": "Panic error 0x12: Division by zero.",
    "21": "Panic error 0x21: Cannot convert value into an enum type.",
    "22": "Panic error 0x22: Storage byte array is incorrectly encoded.",
    "31": "Panic error 0x31: Call to 'pop()' on an empty array.",
    "32": "Panic error 0x32: Array index is out of bounds.",
    "41": "Panic error 0x41: Allocation of too much memory or array too large.",
    "51": "Panic error 0x51: Call to a zero-initialized variable of internal "
    "function type.",
}

MISSING_DATA = "no data"


def _parse_error_with_reverted_prefix(data: str) -> str:
    """
    Parse errors from the data string which begin with the "Reverted" prefix.
    "Reverted", function selector and offset are always the same for revert errors
    """
    prefix = f"Reverted {SOLIDITY_ERROR_FUNC_SELECTOR}"
    data_offset = ("00" * 31) + "20"  # 0x0000...0020 (32 bytes)
    revert_pattern = prefix + data_offset
    error = data

    if data.startswith(revert_pattern):
        # if common revert pattern
        string_length = int(data[len(revert_pattern) : len(revert_pattern) + 64], 16)
        error = data[
            len(revert_pattern) + 64 : len(revert_pattern) + 64 + string_length * 2
        ]
    elif data.startswith("Reverted 0x"):
        # Special case for this form: 'Reverted 0x...'
        error = data.split(" ")[1][2:]

    try:
        error = bytes.fromhex(error).decode("utf8")
    except UnicodeDecodeError:
        warnings.warn(
            "Could not decode revert reason as UTF-8", RuntimeWarning, stacklevel=2
        )
        raise ContractLogicError("execution reverted", data=data)

    return error


def _raise_contract_error(response_error_data: str) -> None:
    """
    Decode response error from data string and raise appropriate exception.

        "Reverted " (prefix may be present in `data`)
        Function selector for Error(string): 08c379a (4 bytes)
        Data offset: 32 (32 bytes)
        String length (32 bytes)
        Reason string (padded, use string length from above to get meaningful part)
    """
    if response_error_data.startswith("Reverted "):
        reason_string = _parse_error_with_reverted_prefix(response_error_data)
        raise ContractLogicError(
            f"execution reverted: {reason_string}", data=response_error_data
        )

    elif response_error_data[:10] == OFFCHAIN_LOOKUP_FUNC_SELECTOR:
        # --- EIP-3668 | CCIP read error --- #
        parsed_data_as_bytes = to_bytes(hexstr=response_error_data[10:])
        abi_decoded_data = abi.decode(
            list(OFFCHAIN_LOOKUP_FIELDS.values()), parsed_data_as_bytes
        )
        offchain_lookup_payload = dict(
            zip(OFFCHAIN_LOOKUP_FIELDS.keys(), abi_decoded_data)
        )
        raise OffchainLookup(offchain_lookup_payload, data=response_error_data)

    elif response_error_data[:10] == PANIC_ERROR_FUNC_SELECTOR:
        # --- Solidity Panic Error --- #
        panic_error_code = response_error_data[-2:]
        raise ContractPanicError(
            PANIC_ERROR_CODES[panic_error_code], data=response_error_data
        )

    # Solidity 0.8.4 introduced custom error messages that allow args to
    # be passed in (or not). See:
    # https://blog.soliditylang.org/2021/04/21/custom-errors/
    elif (
        len(response_error_data) >= 10
        and not response_error_data[:10] == SOLIDITY_ERROR_FUNC_SELECTOR
    ):
        # Raise with data as both the message and the data for backwards
        # compatibility and so that data can be accessed via 'data' attribute
        # on the ContractCustomError exception
        raise ContractCustomError(response_error_data, data=response_error_data)


def raise_contract_logic_error_on_revert(response: RPCResponse) -> RPCResponse:
    """
    Revert responses contain an error with the following optional attributes:
        `code` - in this context, used for an unknown edge case when code = '3'
        `message` - error message is passed to the raised exception
        `data` - response error details (str, dict, None)

    See also https://solidity.readthedocs.io/en/v0.6.3/control-structures.html#revert
    """
    error = response.get("error")
    if error is None or isinstance(error, str):
        raise Web3ValueError(error)

    message = error.get("message")
    message_present = message is not None and message != ""
    data = error.get("data", MISSING_DATA)

    if data is None:
        if message_present:
            raise ContractLogicError(message, data=data)
        elif not message_present:
            raise ContractLogicError("execution reverted", data=data)
    elif isinstance(data, dict) and message_present:
        raise ContractLogicError(f"execution reverted: {message}", data=data)
    elif isinstance(data, str):
        _raise_contract_error(data)

    if message_present:
        # Geth Revert with error message and code 3 case:
        if error.get("code") == 3:
            raise ContractLogicError(message, data=data)
        # Geth Revert without error message case:
        elif "execution reverted" in message:
            raise ContractLogicError("execution reverted", data=data)

    return response


def raise_transaction_indexing_error_if_indexing(response: RPCResponse) -> RPCResponse:
    """
    Raise an error if ``eth_getTransactionReceipt`` returns an error indicating that
    transactions are still being indexed.
    """
    error = response.get("error")
    if not isinstance(error, str) and error is not None:
        message = error.get("message")
        if message is not None:
            if all(
                idx_key_phrases in message for idx_key_phrases in ("index", "progress")
            ):
                raise TransactionIndexingInProgress(message)

    return response


def raise_block_not_found_on_error(response: RPCResponse) -> RPCResponse:
    """
    Raise ``BlockNotFound`` on specific error message(s).
    """
    error = response.get("error")
    if not isinstance(error, str) and error is not None:
        message = error.get("message")
        if message is not None:
            if "not found" in message.lower() and any(
                key in message.lower() for key in ("block", "header")
            ):
                raise BlockNotFound(message)

    return response
