#!/usr/bin/python3

import functools
import re
from typing import (
    Any,
    Callable,
    Dict,
    Final,
    List,
    Literal,
    Mapping,
    Optional,
    TypedDict,
    Union,
    final,
    overload,
)

import cchecksum
import eth_abi
import hexbytes
from eth_abi.exceptions import InsufficientDataBytes, NoEntriesFound, NonEmptyPaddingBytes
from eth_hash import auto
from eth_typing import ChecksumAddress, HexAddress, HexStr

from .conditional_imports import InvalidPointer


@final
class ABIError(Exception):
    pass


@final
class EventError(Exception):
    pass


@final
class StructLogError(Exception):
    pass


@final
class UnknownEvent(Exception):
    pass


@final
class DecodedEvent(TypedDict, total=False):
    name: str
    data: List[Dict[str, Any]]
    decoded: Literal[True]
    address: ChecksumAddress


@final
class NonDecodedEvent(TypedDict, total=False):
    name: None
    topics: List[HexStr]
    data: HexStr
    decoded: Literal[False]
    address: ChecksumAddress


Event = Union[DecodedEvent, NonDecodedEvent]


ADD_LOG_ENTRIES: Final = "logIndex", "blockNumber", "transactionIndex"

HexBytes: Final = hexbytes.HexBytes

to_checksum_address: Final = cchecksum.to_checksum_address
keccak: Final = auto.keccak
lru_cache: Final = functools.lru_cache
_tuple_match: Final = re.compile(r"tuple(\[(\d*)\])?").match


def get_log_topic(event_abi: Dict[str, Any]) -> HexStr:
    """
    Generate an encoded event topic for an event.

    Arguments
    ---------
    event_abi : Dict
        Dictionary from a contract ABI, describing a specific event.

    Returns
    -------
    str
        bytes32 encoded topic for the event.
    """
    if not isinstance(event_abi, dict):
        raise TypeError("Must be a dictionary of the specific event's ABI")
    if event_abi.get("anonymous"):
        raise ABIError("Anonymous events do not have a topic")

    types = _params(event_abi["inputs"])
    key = f"{event_abi['name']}({','.join(types)})".encode()

    return _0xstring(keccak(key))


class TopicMapData(TypedDict):
    name: str
    inputs: List[Dict[str, Any]]


TopicMap = Mapping[HexStr, TopicMapData]
# must use Mapping instead of Dict because this change will be breaking if we don't.
# brownie passes in an AttributeDict not a dict.


def get_topic_map(abi: List) -> Dict[HexStr, TopicMapData]:  # type: ignore [type-arg]
    """
    Generate a dictionary of event topics from an ABI.

    This dictionary is required by `decode_log`, `decode_logs`, and
    `decode_traceTransaction`.

    Anonymous events are ignored. The return data is formatted as follows:

        {
            'encoded bytes32 topic': {
                'name':"Event Name",
                'inputs': [abi inputs]
            }
        }

    Arguments
    ---------
    abi : List
        Contract ABI

    Returns
    -------
    Dict
        Mapping of contract events.
    """
    try:
        return {
            get_log_topic(i): {"name": i["name"], "inputs": i["inputs"]}
            for i in abi
            if i["type"] == "event" and not i.get("anonymous")
        }
    except (KeyError, TypeError) as e:
        raise ABIError("Invalid ABI") from e


def decode_log(
    log: Mapping[str, Any],
    topic_map: TopicMap,
    __checksum_func: Callable[[HexAddress], ChecksumAddress] = to_checksum_address,
) -> Event:
    """
    Decode a single event log from a transaction receipt.

    Indexed arrays cannot be decoded. The returned value will still
    be encoded. Anonymous events and events where the topic is not found in
    `topic_map` will raise an exception.

    The return data is formatted as follows:

    {
        'name': "",  # event name
        'address': "",  # address where the event was emitted
        'decoded': True / False,
        'data': [{
            'name': "",  # variable name
            'type': "",  # type as given by the ABI
            'value': "",  # decoded value, formatted by `eth_abi.decode_single`
            'decoded': True / False
        }, ...]
    }

    And additional entries: 'logIndex', 'blockNumber', 'transactionIndex', if
    they are present in log.

    Arguments
    ---------
    log : Dict
        Event log as returned from the `eth_getTransactionReceipt` RPC endpoint.
    topic_map : Dict
        Contract event map generated by `get_topic_map`
    __checksum_func : Callable
        This argument is for internal use only, and is used to loosely cache
        checksummed addresses while decoding a batch of logs

    Returns
    -------
    Dict
        Decoded event log.
    """
    log_topics = log["topics"]
    if not log_topics:
        raise EventError("Cannot decode an anonymous event")

    topic0, *data_topics = log_topics
    key = _0xstring(topic0)
    if key not in topic_map:
        raise UnknownEvent("Event topic is not present in given ABI")

    abi = topic_map[key]

    try:
        event: DecodedEvent = {
            "name": abi["name"],
            "data": _decode(abi["inputs"], data_topics, log["data"]),
            "decoded": True,
            "address": __checksum_func(log["address"]),
        }
        _append_additional_log_data(log, event)
        return event
    except KeyError as e:
        raise EventError("Invalid event") from e


@overload
def decode_logs(
    logs: List[Mapping[str, Any]], topic_map: TopicMap, allow_undecoded: Literal[True]
) -> List[DecodedEvent]: ...


@overload
def decode_logs(
    logs: List[Mapping[str, Any]], topic_map: TopicMap, allow_undecoded: Literal[False]
) -> List[Event]: ...


def decode_logs(logs: List[Mapping[str, Any]], topic_map: TopicMap, allow_undecoded: bool = False) -> List[Event]:  # type: ignore [misc]
    """
    Decode a list of event logs from a transaction receipt.

    If `allow_undecoded` is `True`, an undecoded event is returned with the
    following structure:

    {
        'name': None,
        'decoded': False,
        'data': "",  # raw data hexstring
        'topics': [],  # list of undecoded topics as 32 byte hexstrings
        'address: "",  # address where the event was emitted
    }

    And additional entries: 'logIndex', 'blockNumber', 'transactionIndex', if
    they are present in log.

    Arguments
    ---------
    logs : List
        List of event logs as returned from the `eth_getTransactionReceipt`
        RPC endpoint.
    topic_map : Dict
        Contract event map generated by `get_topic_map`
    allow_undecoded: bool, optional
        Determines how undecodable events are handled. If `True`, they are
        returned

    Returns
    -------
    List
        A list of decoded events, formatted in the same structure as `decode_log`
    """
    events: List[Event] = []

    # we loosely cache the results to save time during processing
    # but discard the cache after each batch of logs is processed
    to_checksum_address_cached = lru_cache(to_checksum_address)

    for item in logs:
        topics = [_0xstring(i) for i in item["topics"]]
        if topics and topics[0] in topic_map:
            events.append(decode_log(item, topic_map, to_checksum_address_cached))
        elif not allow_undecoded:
            raise UnknownEvent("Log contains undecodable event")
        else:
            event: NonDecodedEvent = {
                "name": None,
                "topics": topics,
                "data": _0xstring(item["data"]),
                "decoded": False,
                "address": to_checksum_address_cached(item["address"]),
            }
            _append_additional_log_data(item, event)
            events.append(event)

    return events


def _append_additional_log_data(log: Mapping[str, Any], event: Event) -> None:
    for log_entry in ADD_LOG_ENTRIES:
        if log_entry in log:
            event[log_entry] = log[log_entry]


def decode_traceTransaction(
    struct_logs: List, topic_map: TopicMap, allow_undecoded: bool = False, initial_address: Optional[str] = None  # type: ignore [type-arg]
) -> List[Event]:
    """
    Extract and decode a list of event logs from a transaction traceback.

    Useful for obtaining the events fired in a transaction that reverted.

    Arguments
    ---------
    struct_logs : List
        `structLogs` field from Geth's `debug_traceTransaction` RPC endpoint
    topic_map : Dict
        Contract event map generated by `get_topic_map`
    allow_undecoded : bool, optional
        If `False`, an exception is raised when an event cannod be decoded.
    initial_address : str, optional
        The initial address being called in the trace. If given, the decoded
        events will also include addresses.

    Returns
    -------
    List
        A list of decoded events, formatted in the same structure as `decode_log`
    """
    address_list: List[Optional[ChecksumAddress]]

    # we loosely cache the results to save time during processing
    # but discard the cache after each batch of logs is processed
    to_checksum_address_cached = lru_cache(to_checksum_address)

    events: List[Event] = []
    if initial_address is not None:
        address_list = [to_checksum_address_cached(initial_address)]
    else:
        address_list = [None]

    last_step = struct_logs[0]
    for i in range(1, len(struct_logs)):
        step = struct_logs[i]
        if initial_address is not None:
            if step["depth"] > last_step["depth"]:
                if last_step["op"] in ("CREATE", "CREATE2"):
                    out_step = next(x for x in struct_logs[i:] if x["depth"] == last_step["depth"])
                    address = to_checksum_address_cached(f"0x{out_step['stack'][-1][-40:]}")
                    address_list.append(address)
                else:
                    address = to_checksum_address_cached(f"0x{last_step['stack'][-2][-40:]}")
                    address_list.append(address)

            elif step["depth"] < last_step["depth"]:
                address_list.pop()

        last_step = step
        op = step["op"]
        if not op.startswith("LOG"):
            continue

        try:
            stack = step["stack"]
            offset = int(stack[-1], 16)
            length = int(stack[-2], 16)
            topic_len = int(op[-1])
            topics = [_0xstring(i) for i in stack[-3 : -3 - topic_len : -1]]
        except KeyError as e:
            raise StructLogError("StructLog has no stack") from e
        except (IndexError, TypeError) as e:
            raise StructLogError("Malformed stack") from e

        try:
            data = _0xstring(HexBytes("".join(step["memory"]))[offset : offset + length].hex())
        except (KeyError, TypeError) as e:
            raise StructLogError("Malformed memory") from e

        if not topics or topics[0] not in topic_map:
            if not allow_undecoded:
                raise UnknownEvent("Log contains undecodable event")
            non_decoded: NonDecodedEvent = {
                "name": None,
                "topics": topics,
                "data": data,
                "decoded": False,
                "address": address_list[-1],  # type: ignore [typeddict-item]
            }
            events.append(non_decoded)
        else:
            topic0, *data_topics = topics
            topic0_map = topic_map[topic0]
            decoded: DecodedEvent = {
                "name": topic0_map["name"],
                "data": _decode(topic0_map["inputs"], data_topics, data),
                "decoded": True,
                "address": address_list[-1],  # type: ignore [typeddict-item]
            }
            events.append(decoded)

    return events


def _0xstring(value: Any) -> HexStr:
    return f"0x{HexBytes(value).hex()}"  # type: ignore [return-value]


def _params(abi_params: List) -> List:  # type: ignore [type-arg]
    types = []
    # regex with 2 capturing groups
    # first group captures whether this is an array tuple
    # second group captures the size if this is a fixed size tuple
    for i in abi_params:
        i_type = i["type"]
        if tuple_match := _tuple_match(i_type):
            _array, _size = tuple_match.group(1, 2)  # unpack the captured info
            tuple_type_tail = f"[{_size}]" if _array is not None else ""
            types.append(f"({','.join(x for x in _params(i['components']))}){tuple_type_tail}")
            continue
        types.append(i_type)

    return types


def _decode(inputs: List, topics: List, data: Any) -> List:  # type: ignore [type-arg]
    indexed_count = 0
    for i in inputs:
        if i["indexed"]:
            indexed_count += 1

    if indexed_count and not topics:
        # special case - if the ABI has indexed values but the log does not,
        # we should still be able to decode the data
        unindexed_types = inputs

    else:
        len_topics = len(topics)
        if indexed_count < len_topics:
            raise EventError(
                "Event log does not contain enough topics for the given ABI - this"
                " is usually because an event argument is not marked as indexed"
            )
        if indexed_count > len_topics:
            raise EventError(
                "Event log contains more topics than expected for the given ABI - this is"
                " usually because an event argument is incorrectly marked as indexed"
            )
        unindexed_types = [i for i in inputs if not i["indexed"]]

    # decode the unindexed event data
    try:
        unindexed_types = _params(unindexed_types)
    except (KeyError, TypeError) as e:
        raise ABIError("Invalid ABI") from e

    if unindexed_types and data == "0x":
        length = len(unindexed_types) * 32
        data = _0xstring(length)

    try:
        decoded = list(eth_abi.decode(unindexed_types, HexBytes(data)))[::-1]
    except InsufficientDataBytes:
        raise EventError("Event data has insufficient length")
    except NonEmptyPaddingBytes:
        raise EventError("Malformed data field in event log")
    except InvalidPointer as e:
        raise EventError(str(e))
    except OverflowError:
        raise EventError("Cannot decode event due to overflow error")

    # decode the indexed event data and create the returned dict
    topics = topics[::-1]
    result = []
    for i in inputs:
        i_type = i["type"]

        if "components" in i:
            element = {"name": i["name"], "type": i_type, "components": i["components"]}
        else:
            element = {"name": i["name"], "type": i_type}

        if topics and i["indexed"]:
            encoded = HexBytes(topics.pop())
            try:
                value = eth_abi.decode([i_type], encoded)[0]
            except (InsufficientDataBytes, NoEntriesFound, OverflowError, InvalidPointer):
                # an array or other data type that uses multiple slots
                element.update({"value": _0xstring(encoded), "decoded": False})
                result.append(element)
                continue
        else:
            value = decoded.pop()

        if isinstance(value, bytes):
            value = _0xstring(value)

        element.update({"value": value, "decoded": True})
        result.append(element)

    return result
