from __future__ import absolute_import

import pkg_resources

from semantic_version import (
    Spec,
)

from cytoolz.dicttoolz import (
    assoc,
)

import rlp

from eth_utils import (
    encode_hex,
    remove_0x_prefix,
    to_tuple,
)

from eth_tester.constants import (
    FORK_HOMESTEAD,
    FORK_DAO,
    FORK_ANTI_DOS,
    FORK_STATE_CLEANUP,
)
from eth_tester.exceptions import (
    BlockNotFound,
    TransactionNotFound,
    UnknownFork,
    TransactionFailed,
)
from eth_tester.backends.base import BaseChainBackend
from eth_tester.backends.pyethereum.utils import (
    get_pyethereum_version,
    is_pyethereum16_available,
)

from eth_tester.utils.accounts import (
    private_key_to_address,
)

from eth_tester.backends.pyethereum.serializers import (
    serialize_block,
    serialize_transaction,
    serialize_transaction_hash,
    serialize_transaction_receipt,
)
from eth_tester.backends.pyethereum.validation import (
    validate_transaction,
)
from eth_tester.utils.formatting import (
    replace_exceptions,
)


if is_pyethereum16_available():
    from ethereum.tester import (
        TransactionFailed as Pyeth16TransactionFailed,
    )
else:
    Pyeth16TransactionFailed = None


#
# Internal getters for EVM objects
#
def _get_transaction_by_hash(evm, transaction_hash, mined=True):
    # first check unmined transactions
    for index, candidate in enumerate(evm.block.get_transaction_hashes()):
        if candidate == transaction_hash:
            transaction = evm.block.transaction_list[index]
            return evm.block, transaction, index

    # then check work backwards through the blocks looking for mined transactions.
    for block in reversed(evm.blocks[:-1]):
        for index, candidate in enumerate(block.get_transaction_hashes()):
            if candidate == transaction_hash:
                transaction = block.transaction_list[index]
                return block, transaction, index
    else:
        raise TransactionNotFound(
            "No transaction found for transaction hash {0}".format(
                encode_hex(transaction_hash),
            )
        )


def _get_block_by_number(evm, block_number="latest"):
    if block_number == "latest":
        if len(evm.blocks) == 0:
            raise BlockNotFound("Chain has no blocks")
        elif len(evm.blocks) == 1:
            return evm.blocks[0]
        else:
            return evm.blocks[-2]
    elif block_number == "earliest":
        return evm.blocks[0]
    elif block_number == "pending":
        return evm.block
    elif block_number == evm.block.number:
        return evm.block
    else:
        if block_number >= len(evm.blocks):
            raise BlockNotFound("Block number is longer than current chain.")
        return evm.blocks[block_number]


def _get_block_by_hash(evm, block_hash):
    block_hash = block_hash

    for block in reversed(evm.blocks):
        if block.hash == block_hash:
            return block
    else:
        if block_hash == evm.block.hash:
            return evm.block
        raise BlockNotFound("Could not find block for provided hash")


def _send_evm_transaction(tester_module, evm, transaction):
    from ethereum import tester

    try:
        # record the current gas price so that it can be reset after sending
        # the transaction.
        pre_transaction_gas_price = tester.gas_price
        pre_transaction_gas_limit = tester.gas_limit
        # set the evm gas price to the one specified by the transaction.
        tester.gas_price = transaction.get('gas_price', tester.gas_price)
        tester.gas_limit = transaction['gas']

        # get the private key of the sender.
        try:
            sender = tester.keys[tester.accounts.index(transaction['from'])]
        except ValueError:
            sender = evm.extra_accounts[transaction['from']]

        output = evm.send(
            sender=sender,
            to=transaction.get('to', b''),
            value=transaction.get('value', 0),
            evmdata=transaction.get('data', b''),
        )
    finally:
        # revert the tester gas price back to the original value.
        tester.gas_price = pre_transaction_gas_price
        tester.gas_limit = pre_transaction_gas_limit

    return output


def _estimate_evm_transaction(tester_module, evm, transaction):
    transaction_for_estimate = assoc(transaction, 'gas', 50000000)
    return _send_evm_transaction(tester_module, evm, transaction_for_estimate)


def _call_evm_transaction(tester_module, evm, transaction):
    if 'gas' not in transaction:
        transaction_for_call = assoc(transaction, 'gas', 50000000)
    else:
        transaction_for_call = transaction
    return _send_evm_transaction(tester_module, evm, transaction_for_call)


class PyEthereum16Backend(BaseChainBackend):
    def __init__(self):
        if not is_pyethereum16_available():
            version = get_pyethereum_version()
            if version is None:
                raise pkg_resources.DistributionNotFound(
                    "The `ethereum` package is not available.  The "
                    "`PyEthereum16Backend` requires a 1.6.x version of the "
                    "ethereum package to be installed."
                )
            elif version not in Spec('>=1.6.0,<1.7.0'):
                raise pkg_resources.DistributionNotFound(
                    "The `PyEthereum16Backend` requires a 1.6.x version of the "
                    "`ethereum` package.  Found {0}".format(version)
                )
        self.reset_to_genesis()

    #
    # Fork Rules
    #
    def set_fork_block(self, fork_name, fork_block):
        if fork_name == FORK_HOMESTEAD:
            self.evm.env.config['HOMESTEAD_FORK_BLKNUM'] = fork_block
        elif fork_name == FORK_DAO:
            self.evm.env.config['DAO_FORK_BLKNUM'] = fork_block
        elif fork_name == FORK_ANTI_DOS:
            self.evm.env.config['ANTI_DOS_FORK_BLKNUM'] = fork_block
        elif fork_name == FORK_STATE_CLEANUP:
            self.evm.env.config['CLEARING_FORK_BLKNUM'] = fork_block
        else:
            raise UnknownFork("Unknown fork name: {0}".format(fork_name))

    def get_fork_block(self, fork_name):
        if fork_name == FORK_HOMESTEAD:
            return self.evm.env.config['HOMESTEAD_FORK_BLKNUM']
        elif fork_name == FORK_DAO:
            return self.evm.env.config['DAO_FORK_BLKNUM']
        elif fork_name == FORK_ANTI_DOS:
            return self.evm.env.config['ANTI_DOS_FORK_BLKNUM']
        elif fork_name == FORK_STATE_CLEANUP:
            return self.evm.env.config['CLEARING_FORK_BLKNUM']
        else:
            raise UnknownFork("Unknown fork name: {0}".format(fork_name))

    #
    # Snapshots
    #
    def take_snapshot(self):
        return (self.evm.block.number, self.evm.snapshot())

    def revert_to_snapshot(self, snapshot):
        from ethereum import tester

        block_number, snapshot_data = snapshot

        # Remove all blocks after our saved block number.
        del self.evm.blocks[block_number:]

        self.evm.revert(snapshot_data)

        if self.evm.blocks:
            parent = self.evm.blocks[-1]
            block = self.evm.block.init_from_parent(
                parent,
                tester.DEFAULT_ACCOUNT,
                timestamp=parent.timestamp + 6,
            )

            self.evm.block = block
            self.evm.blocks.append(block)
        else:
            self.evm.blocks.append(self.evm.block)

    def reset_to_genesis(self):
        from ethereum import tester
        self.evm = tester.state()
        self.evm.extra_accounts = {}
        self.mine_blocks()

    #
    # Meta
    #
    def time_travel(self, to_timestamp):
        from ethereum import tester

        self.evm.block.finalize()
        self.evm.block.commit_state()
        self.evm.db.put(
            self.evm.block.hash,
            rlp.encode(self.evm.block),
        )

        block = self.evm.block.init_from_parent(
            self.evm.block,
            tester.DEFAULT_ACCOUNT,
            timestamp=to_timestamp,
        )

        self.evm.block = block
        self.evm.blocks.append(block)
        return to_timestamp

    #
    # Mining
    #
    @to_tuple
    def mine_blocks(self, num_blocks=1, coinbase=None):
        from ethereum import tester

        if coinbase is None:
            coinbase = tester.DEFAULT_ACCOUNT

        self.evm.mine(
            number_of_blocks=num_blocks,
            coinbase=coinbase,
        )
        for block in self.evm.blocks[-1 * num_blocks - 1:-1]:
            yield block.hash

    #
    # Accounts
    #
    @to_tuple
    def get_accounts(self):
        from ethereum import tester

        for account in tester.accounts:
            yield account

        for account in self.evm.extra_accounts.keys():
            yield account

    def add_account(self, private_key):
        account = private_key_to_address(private_key)
        self.evm.extra_accounts[account] = private_key

    #
    # Chain data
    #
    def get_block_by_number(self, block_number, full_transactions=False):
        if full_transactions:
            transaction_serialize_fn = serialize_transaction
        else:
            transaction_serialize_fn = serialize_transaction_hash

        block = _get_block_by_number(
            self.evm,
            block_number,
        )
        is_pending = block == self.evm.block
        return serialize_block(self.evm, block, transaction_serialize_fn, is_pending)

    def get_block_by_hash(self, block_hash, full_transactions=False):
        if full_transactions:
            transaction_serialize_fn = serialize_transaction
        else:
            transaction_serialize_fn = serialize_transaction_hash

        block = _get_block_by_hash(
            self.evm,
            block_hash,
        )
        is_pending = block == self.evm.block
        return serialize_block(self.evm, block, transaction_serialize_fn, is_pending)

    def get_latest_block(self, full_transactions=False):
        if full_transactions:
            transaction_serialize_fn = serialize_transaction
        else:
            transaction_serialize_fn = serialize_transaction_hash

        return serialize_block(self.evm, self.evm.block, transaction_serialize_fn)

    def get_transaction_by_hash(self, transaction_hash):
        block, transaction, transaction_index = _get_transaction_by_hash(
            self.evm,
            transaction_hash,
        )
        is_pending = block.number == self.evm.block.number
        return serialize_transaction(block, transaction, transaction_index, is_pending)

    def get_transaction_receipt(self, transaction_hash):
        block, transaction, transaction_index = _get_transaction_by_hash(
            self.evm,
            transaction_hash,
        )
        is_pending = block.number == self.evm.block.number
        return serialize_transaction_receipt(
            block,
            transaction,
            block.get_receipt(transaction_index),
            transaction_index,
            is_pending,
        )

    #
    # Account state
    #
    def get_nonce(self, account, block_number="latest"):
        block = _get_block_by_number(self.evm, block_number)
        return block.get_nonce(remove_0x_prefix(account))

    def get_balance(self, account, block_number="latest"):
        block = _get_block_by_number(self.evm, block_number)
        return block.get_balance(remove_0x_prefix(account))

    def get_code(self, account, block_number="latest"):
        block = _get_block_by_number(self.evm, block_number)
        return block.get_code(remove_0x_prefix(account))

    #
    # Transactions
    #
    def send_raw_transaction(self, raw_transaction):
        from ethereum import (
            processblock,
            tester,
        )
        from ethereum.transactions import Transaction
        rlp_transaction = rlp.decode(raw_transaction, Transaction)
        # Manipulate `tester.state` directly
        success, _ = processblock.apply_transaction(self.evm.block, rlp_transaction)
        if not success:
            raise tester.TransactionFailed
        self.evm.last_tx = rlp_transaction
        return rlp_transaction.hash

    def send_transaction(self, transaction):
        from ethereum import tester
        validate_transaction(transaction)
        _send_evm_transaction(
            tester_module=tester,
            evm=self.evm,
            transaction=transaction,
        )
        return self.evm.last_tx.hash

    @replace_exceptions({Pyeth16TransactionFailed: TransactionFailed})
    def call(self, transaction, block_number="latest"):
        from ethereum import tester
        validate_transaction(transaction)

        if block_number != "latest":
            raise NotImplementedError("Block number must be 'latest'.")

        snapshot = self.take_snapshot()
        try:
            output = _call_evm_transaction(
                tester_module=tester,
                evm=self.evm,
                transaction=transaction,
            )
        except TransactionFailed as e:
            raise e
        finally:
            self.revert_to_snapshot(snapshot)
        return output

    @replace_exceptions({Pyeth16TransactionFailed: TransactionFailed})
    def estimate_gas(self, transaction):
        from ethereum import tester
        validate_transaction(transaction)

        snapshot = self.take_snapshot()
        try:
            _estimate_evm_transaction(
                tester_module=tester,
                evm=self.evm,
                transaction=transaction,
            )
            txn_hash = self.evm.last_tx.hash
            receipt = self.get_transaction_receipt(txn_hash)
        except TransactionFailed as e:
            raise e
        finally:
            self.revert_to_snapshot(snapshot)
        return receipt['gas_used']
