import logging as log
from threading import Thread
from time import sleep
from typing import Optional, Callable, Type, Dict, Any

from eth_account.datastructures import SignedTransaction
from eth_typing import ChecksumAddress
from web3 import Web3
from web3._utils.filters import LogFilter  # noqa
from web3.contract import Contract
from web3.contract.contract import ContractFunction
from web3.types import ABI, Nonce

from rubi.contracts.types.events import BaseEvent


class BaseContract:
    """Base class representation of a contract which defines the structure of a contract and provides several helpful
    methods that can be used by subclass contracts that extend this contract.

    :param w3: Web3 instance
    :type w3: Web3
    :param contract: Contract instance
    :type contract: Contract
    :param wallet: a wallet address of the signer (optional, default is None)
    :type wallet: Optional[ChecksumAddress]
    :param key: the private key of the signer (optional, default is None)
    :type key: Optional[str]
    """

    def __init__(
        self,
        w3: Web3,
        contract: Contract,
        wallet: Optional[ChecksumAddress] = None,
        key: Optional[str] = None
    ):
        """constructor method"""
        if (wallet is None) != (key is None):
            raise Exception("both a wallet and a key are required to sign transactions. provide both or omit both")

        self.contract = contract
        self.address = contract.address
        self.w3 = w3
        self.chain_id = self.w3.eth.chain_id

        # Signing permissions
        self.signing_permissions = (wallet is not None and key is not None)

        if self.signing_permissions:
            log.info(f"instantiated {self.__class__} with signing rights")

            # Force typing as my editors inspection is throwing a tantrum
            self.wallet = wallet  # type: ChecksumAddress
            self.key = key  # type: str

    @classmethod
    def from_address_and_abi(
        cls,
        w3: Web3,
        address: ChecksumAddress,
        contract_abi: ABI,
        wallet: Optional[ChecksumAddress] = None,
        key: Optional[str] = None
    ) -> "BaseContract":
        """Create a BaseContract instance from the contract address and ABI.

        :param w3: The Web3 instance.
        :type w3: Web3
        :param address: The address of the contract.
        :type address: ChecksumAddress
        :param contract_abi: The ABI of the contract.
        :type contract_abi: ABI
        :param wallet: The wallet address to use for interacting with the contract (optional, default is None).
        :type wallet: Optional[ChecksumAddress]
        :param key: The private key of the wallet (optional, default is None).
        :type key: Optional[str]
        :return: An instance of BaseContract.
        :rtype: BaseContract
        """

        contract = w3.eth.contract(address=address, abi=contract_abi)

        return cls(w3=w3, contract=contract, wallet=wallet, key=key)

    ######################################################################
    # event listeners
    ######################################################################

    # TODO: revisit poll time. Right now it is set to block production time of optimism according to:
    #  https://community.optimism.io/docs/protocol/2-rollup-protocol/#block-storage
    #  however arbitrum produces blocks faster (every 0.25 secs) according to:
    #  https://arbiscan.io/chart/blocktime so we may be prudent to account for different chains
    #  however the better way to do this is probably a websocket connection to the node
    # TODO: investigate using a more generic filter so that you don't need to poll for each event as this could
    #  spam the node that is being connected to
    def start_event_poller(
        self,
        pair_name: str,
        event_type: Type[BaseEvent],
        argument_filters: Optional[Dict[str, Any]] = None,
        event_handler: Optional[Callable] = None,
        poll_time: int = 2
    ) -> None:
        """Start a thread which runs an event poller for a specific event type.

        :param pair_name: The name of the pair we are monitoring events of.
        :type pair_name: str
        :param event_type: The type of event to poll for.
        :type event_type: Type[BaseEvent]
        :param argument_filters: Optional filters that the node will filter events on (optional, default is None).
        :type argument_filters: Optional[Dict[str, Any]]
        :param event_handler: Optional event handler function. Defaults to using the events default handler.
        :type event_handler: Optional[Callable]
        :param poll_time: The time interval between each poll in seconds. Defaults to 2 seconds.
        :type poll_time: int
        """

        event_filter = event_type.create_event_filter(contract=self.contract, argument_filters=argument_filters)
        handler = event_handler if event_handler is not None else event_type.default_handler

        thread = Thread(
            target=self._start_default_event_poller,
            args=(pair_name, event_type, event_filter, handler, poll_time),
            daemon=True
        )
        thread.start()

    @staticmethod
    def _start_default_event_poller(
        pair_name: str,
        event_type: Type[BaseEvent],
        event_filter: LogFilter,
        event_handler: Callable,
        poll_time: int
    ) -> None:
        """Start the default event poller loop. This thread will stop if the pair is removed from the client.

        :param pair_name: The name of the event pair.
        :type pair_name: str
        :param event_type: The type of the event.
        :type event_type: Type[BaseEvent]
        :param event_filter: The event filter to retrieve new entries.
        :type event_filter: LogFilter
        :param event_handler: The event handler function.
        :type event_handler: Callable
        :param poll_time: The time interval between poll iterations in seconds.
        :type poll_time: int
        """
        polling = True

        while polling:
            try:
                for event_data in event_filter.get_new_entries():
                    event_handler(pair_name, event_type, event_data)
            except Exception as e:
                log.error(e)

                # TODO: this is a hack to detect if a PairDoesNotExistException is raised and polling should stop.
                #  Currently an additional except PairDoesNotExistException as e: cannot be added as this causes a
                #  circular import. Think about restructuring the directories to avoid this (e.g one root level types
                #  directory).
                if "add pair to the client" in str(e):
                    polling = False

            sleep(poll_time)

    ######################################################################
    # helper methods
    ######################################################################

    def _default_transaction_handler(
        self,
        instantiated_contract_function: ContractFunction,
        gas: int,
        nonce: Optional[int] = None,
        max_fee_per_gas: Optional[int] = None,
        max_priority_fee_per_gas: Optional[int] = None
    ) -> str:
        """Default transaction handler for executing transactions against this contract. This function will build, sign
        and execute a transaction with reasonable defaults (mostly from the web3py library).

        Note: if a nonce is not passed then this function will query to the wallet to get the nonce and also wait for
        the transaction receipt before returning.

        :param instantiated_contract_function: The instantiated contract function to call.
        :type instantiated_contract_function: ContractFunction
        :param gas: The gas limit for the transaction.
        :type gas: int
        :param nonce: Optional nonce value for the transaction (optional, default is None).
        :type nonce: Optional[int]
        :param max_fee_per_gas: Optional maximum fee per gas for the transaction (optional, default is None).
        :type max_fee_per_gas: Optional[int]
        :param max_priority_fee_per_gas: Optional maximum priority fee per gas for the transaction.
            (optional, default is None).
        :type max_priority_fee_per_gas: Optional[int]
        :return: The transaction hash of the executed transaction.
        :rtype: str
        """
        if not self.signing_permissions:
            raise Exception(f"cannot write transaction without signing rights. "
                            f"re-instantiate {self.__class__} with a wallet and private key")

        base_txn = self._transaction_params(
            gas=gas,
            nonce=nonce,
            max_fee_per_gas=max_fee_per_gas,
            max_priority_fee_per_gas=max_priority_fee_per_gas
        )

        txn = instantiated_contract_function.build_transaction(
            transaction=base_txn
        )

        signed_txn = self.w3.eth.account.sign_transaction(
            transaction_dict=txn,
            private_key=self.key
        )
        result = self.w3.eth.send_raw_transaction(signed_txn.rawTransaction)

        # If a user is not providing a nonce, wait for the transaction to either be confirmed or rejected before
        # continuing
        if nonce is None:
            self._wait_for_transaction_receipt(transaction=signed_txn)

        return result.hex()

    def _transaction_params(
        self,
        nonce: Optional[Nonce],
        gas: int,
        max_fee_per_gas: Optional[int],
        max_priority_fee_per_gas: Optional[int]
    ) -> Dict:
        """Build transaction parameters Dict for a transaction. If a key is associated with a None value after building
        the Dict then this key will be removed before returning the dict.

        :param nonce: Optional nonce value for the transaction (optional, default is None).
        :type nonce: Optional[Nonce]
        :param gas: The gas limit for the transaction.
        :type gas: int
        :param max_fee_per_gas: Optional maximum fee per gas for the transaction (optional, default is None).
        :type max_fee_per_gas: Optional[int]
        :param max_priority_fee_per_gas: Optional maximum priority fee per gas for the transaction.
            (optional, default is None).
        :type max_priority_fee_per_gas: Optional[int]
        :return: The transaction parameters dictionary.
        :rtype: Dict
        """

        if nonce is None:
            nonce = self.w3.eth.get_transaction_count(self.wallet)

        transaction = {
            'chainId': self.chain_id,
            'gas': gas,
            'maxFeePerGas': max_fee_per_gas,
            'maxPriorityFeePerGas': max_priority_fee_per_gas,
            'nonce': nonce,
        }

        return {key: value for key, value in transaction.items() if value is not None}

    # TODO: actually make use of the transaction receipt to update data structures. This can be useful for example
    #  in getting the amount of gas and cost of gas of the transaction
    def _wait_for_transaction_receipt(self, transaction: SignedTransaction) -> None:
        """Wait for the transaction receipt and check if the transaction was successful.

        :param transaction: The signed transaction object.
        :type transaction: SignedTransaction
        :raises Exception: If the transaction fails.
        """
        if self.w3.eth.wait_for_transaction_receipt(transaction.hash)['status'] == 0:
            raise Exception(f"transaction {transaction.hash.hex()} failed")
