from cytoolz import (
    pipe,
    dissoc,
    partial,
    merge,
    identity,
)

from hexbytes import (
    HexBytes
)

import rlp

import math

from decimal import (
    Decimal
)

from eth_account.datastructures import (
    SignedTransaction
)

from eth_account._utils.signing import (
    sign_transaction_hash
)

from eth_account._utils.transactions import (
    chain_id_to_v
)

from eth_utils.curried import (
    hexstr_if_str,
    to_bytes,
    keccak,
    apply_formatters_to_dict,
    to_int,
    apply_formatters_to_sequence,
    apply_formatter_to_array
)

from .signing import (
    sanitize_transaction
)

from .staking_structures import (
    FORMATTERS,
    StakingSettings,
    Directive,
    CreateValidator,
    EditValidator,
    DelegateOrUndelegate,
    CollectRewards
)

from .util import (
    convert_one_to_hex
)

def _convert_staking_percentage_to_number(value):        # https://github.com/harmony-one/sdk/blob/99a827782fabcd5f91f025af0d8de228956d42b4/packages/harmony-staking/src/stakingTransaction.ts#L335
    """
    Convert from staking percentage to integer
    For example, 0.1 becomes 1000000000000000000

    Parameters
    ---------
    value: :obj:`str` or :obj:`Decimal`
        the value to convert

    Returns
    -------
    int, converted as above

    Raises
    ------
    AssertionError, if data types are not as expected
    ValueError, if the input type is not supported
    """
    assert isinstance(value, (str, Decimal)), 'Only strings or decimals are supported'
    if isinstance(value, Decimal):
        value = str(value)
    value1 = value;
    if value[0] == '-':
        raise ValueError('Negative numbers are not accepted')
    if value[0] == '+':
        value1 = value[1:]
    if len(value1) == 0:
        raise ValueError('StakingDecimal string is empty')
    spaced = value1.split(' ')
    if len(spaced) > 1:
        raise ValueError('Bad decimal string')
    splitted = value1.split('.')
    combined_str = splitted[0]
    if len(splitted) == 2:
        length = len(splitted[1])
        if length == 0 or len(combined_str) == 0:
            raise ValueError('Bad StakingDecimal length')
        if splitted[1][0] == '-':
            raise ValueError('Bad StakingDecimal string')
        combined_str += splitted[1]
    elif len(splitted) > 2:
        raise ValueError('Too many periods to be a StakingDecimal string')
    if length > StakingSettings.PRECISION:
        raise ValueError('Too much precision, must be less than {StakingSettings.PRECISION}')
    zeroes_to_add = StakingSettings.PRECISION - length
    combined_str += '0' * zeroes_to_add             # This will not have any periods, so it is effectively a large integer
    val = int(combined_str)
    assert val <= StakingSettings.MAX_DECIMAL, 'Staking percentage is too large'
    return val

def _get_account_and_transaction(transaction_dict, private_key):
    """
    Create account from private key and sanitize the transaction
        Sanitization involves removal of 'from' key
        And conversion of chainId key from str to int (if present)

    Parameters
    ----------
    transaction_dict: :obj:`dict`
        See sign_staking_transaction
    private_key: obj:`str`
        Private key for the account

    Returns
    -------
    a tuple containing account :obj:`eth_account.Account`
        and sanitize_transaction :obj:`dict`

    Raises
    ------
    AssertionError, if chainId is not present in util.chain_id_to_int
    TypeError, if the value of 'from' key is not the same as account address
    """
    account, sanitized_transaction = sanitize_transaction(transaction_dict, private_key)    # remove from, convert chain id (if present) to integer
    sanitized_transaction['directive'] = sanitized_transaction['directive'].value           # convert to value, like in TypeScript
    return account, sanitized_transaction

def _sign_transaction_generic(account, sanitized_transaction, parent_serializer):
    """
    Sign a generic staking transaction, given the serializer base class and account

    Paramters
    ---------
    account: :obj:`eth_account.Account`, the account to use for signing
    sanitized_transaction: :obj:`dict`, The sanitized transaction (chainId checks and no from key)
    parent_serializer: :obj: The serializer class from staking_structures

    Returns
    -------
    SignedTransaction object, which can be posted to the chain by using
        blockchain.send_raw_transaction

    Raises
    ------
    Assertion / KeyError, if certain keys are missing from the dict
    rlp.exceptions.ObjectSerializationError, if data types are not as expected
    """
    # obtain the serializers
    if sanitized_transaction.get('chainId', 0) == 0:
        unsigned_serializer, signed_serializer = parent_serializer.Unsigned(), parent_serializer.Signed()                 # unsigned, signed
    else:
        unsigned_serializer, signed_serializer = parent_serializer.SignedChainId(), parent_serializer.SignedChainId()     # since chain_id_to_v adds v/r/s, unsigned is not used here
    # fill the transaction
    filled_transaction = pipe(                                                                                            # https://github.com/ethereum/eth-account/blob/00e7b10005c5fa7090086fcef37a76296c524e17/eth_account/_utils/transactions.py#L39
        sanitized_transaction,
        dict,
        partial(merge, {'chainId': None}),
        chain_id_to_v,                                                                                                    # will move chain id to v and add v/r/s
        apply_formatters_to_dict(FORMATTERS)
    )
    # get the unsigned transaction
    for f, _ in unsigned_serializer._meta.fields:
        assert f in filled_transaction, f'Could not find {f} in transaction'
    unsigned_transaction = unsigned_serializer.from_dict(\
        {f: filled_transaction[f] for f, _ in unsigned_serializer._meta.fields})                                          # drop extras silently
    # sign the unsigned transaction
    if 'v' in unsigned_transaction.as_dict():
        chain_id = unsigned_transaction.v
    else:
        chain_id = None
    transaction_hash = unsigned_transaction.hash()
    (v, r, s) = sign_transaction_hash(
        account._key_obj, transaction_hash, chain_id)
    chain_naive_transaction = dissoc(
        unsigned_transaction.as_dict(), 'v', 'r', 's')                                                                    # remove extra v/r/s added by chain_id_to_v
    # serialize it
    signed_transaction = signed_serializer(
        v=v + (8 if chain_id is None else 0),               # copied from https://github.com/harmony-one/sdk/blob/99a827782fabcd5f91f025af0d8de228956d42b4/packages/harmony-staking/src/stakingTransaction.ts#L207
        r=r,
        s=s,                                                # in the below statement, remove everything not expected by signed_serializer
        **{f: chain_naive_transaction[f] for f, _ in signed_serializer._meta.fields if f not in 'vrs'})
    # encode it
    encoded_transaction = rlp.encode(signed_transaction)
    # hash it
    signed_transaction_hash = keccak(encoded_transaction)
    # return is
    return SignedTransaction(
        rawTransaction=HexBytes(encoded_transaction),
        hash=HexBytes(signed_transaction_hash),
        r=r,
        s=s,
        v=v,
    )

def _sign_delegate_or_undelegate(transaction_dict, private_key, delegate):
    """
    Sign a delegate or undelegate transaction
    See sign_staking_transaction for details
    """
    # preliminary steps
    if transaction_dict['directive'] not in [ Directive.Delegate, Directive.Undelegate ]:
        raise TypeError('Only Delegate or Undelegate are supported by _sign_delegate_or_undelegate')
    # first common step
    account, sanitized_transaction = _get_account_and_transaction(transaction_dict, private_key)
    # encode the stakeMsg
    sanitized_transaction['stakeMsg'] = \
        apply_formatters_to_sequence( [
            hexstr_if_str(to_bytes),
            hexstr_if_str(to_bytes),
            hexstr_if_str(to_int)
        ], [
            convert_one_to_hex(sanitized_transaction.pop('delegatorAddress')),
            convert_one_to_hex(sanitized_transaction.pop('validatorAddress')),
            sanitized_transaction.pop('amount'),
            ]
        )
    return _sign_transaction_generic(account, sanitized_transaction, DelegateOrUndelegate)

def _sign_collect_rewards(transaction_dict, private_key):
    """
    Sign a collect rewards transaction
    See sign_staking_transaction for details
    """
    # preliminary steps
    if transaction_dict['directive'] != Directive.CollectRewards:
        raise TypeError('Only CollectRewards is supported by _sign_collect_rewards')
    # first common step
    account, sanitized_transaction = _get_account_and_transaction(transaction_dict, private_key)
    # encode the stakeMsg
    sanitized_transaction['stakeMsg'] = \
        [hexstr_if_str(to_bytes)(convert_one_to_hex(sanitized_transaction.pop('delegatorAddress')))]
    return _sign_transaction_generic(account, sanitized_transaction, CollectRewards)

def _sign_create_validator(transaction_dict, private_key):
    """
    Sign a create validator transaction
    See sign_staking_transaction for details
    """
    # preliminary steps
    if transaction_dict['directive'] != Directive.CreateValidator:
        raise TypeError('Only CreateValidator is supported by _sign_create_or_edit_validator')
    # first common step
    account, sanitized_transaction = _get_account_and_transaction(transaction_dict, private_key)
    # encode the stakeMsg
    description = [
            sanitized_transaction.pop('name'),
            sanitized_transaction.pop('identity'),
            sanitized_transaction.pop('website'),
            sanitized_transaction.pop('security-contact'),
            sanitized_transaction.pop('details'),
        ]
    commission = apply_formatter_to_array( hexstr_if_str(to_int),       # formatter
        [
            _convert_staking_percentage_to_number(sanitized_transaction.pop('rate')),
            _convert_staking_percentage_to_number(sanitized_transaction.pop('max-rate')),
            _convert_staking_percentage_to_number(sanitized_transaction.pop('max-change-rate')),
        ]
    )
    commission = [ [element] for element in commission ]
    bls_keys = apply_formatter_to_array( hexstr_if_str(to_bytes),       # formatter
        sanitized_transaction.pop('bls-public-keys')
    )
    sanitized_transaction['stakeMsg'] = \
        apply_formatters_to_sequence( [
            hexstr_if_str(to_bytes),        # address
            identity,                       # description
            identity,                       # commission rates
            hexstr_if_str(to_int),          # min self delegation (in ONE), decimals are silently dropped
            hexstr_if_str(to_int),          # max total delegation (in ONE), decimals are silently dropped
            identity,                       # bls public keys
            hexstr_if_str(to_int),          # amount (the Hexlify in the SDK drops the decimals, which is what we will do too)
        ], [
            convert_one_to_hex(sanitized_transaction.pop('validatorAddress')),
            description,
            commission,
            math.floor(sanitized_transaction.pop('min-self-delegation')),       # Decimal floors it correctly
            math.floor(sanitized_transaction.pop('max-total-delegation')),
            bls_keys,
            math.floor(sanitized_transaction.pop('amount')),
            ]
        )
    return _sign_transaction_generic(account, sanitized_transaction, CreateValidator)

def _sign_edit_validator(transaction_dict, private_key):
    """
    Sign an edit validator transaction
    See sign_staking_transaction for details
    """
    # preliminary steps
    if transaction_dict['directive'] != Directive.EditValidator:
        raise TypeError('Only EditValidator is supported by _sign_create_or_edit_validator')
    # first common step
    account, sanitized_transaction = _get_account_and_transaction(transaction_dict, private_key)
    # encode the stakeMsg
    description = [
            sanitized_transaction.pop('name'),
            sanitized_transaction.pop('identity'),
            sanitized_transaction.pop('website'),
            sanitized_transaction.pop('security-contact'),
            sanitized_transaction.pop('details'),
        ]
    sanitized_transaction['stakeMsg'] = \
        apply_formatters_to_sequence( [
            hexstr_if_str(to_bytes),        # address
            identity,                       # description
            identity,                       # new rate (it's in a list so can't do hexstr_if_str)
            hexstr_if_str(to_int),          # min self delegation (in ONE), decimals are silently dropped
            hexstr_if_str(to_int),          # max total delegation (in ONE), decimals are silently dropped
            hexstr_if_str(to_bytes),        # key to remove
            hexstr_if_str(to_bytes),        # key to add
        ], [
            convert_one_to_hex(sanitized_transaction.pop('validatorAddress')),
            description,
            [ _convert_staking_percentage_to_number(sanitized_transaction.pop('rate')) ],
            math.floor(sanitized_transaction.pop('min-self-delegation')),       # Decimal floors it correctly
            math.floor(sanitized_transaction.pop('max-total-delegation')),
            sanitized_transaction.pop('bls-key-to-remove'),
            sanitized_transaction.pop('bls-key-to-add')
            ]
        )
    return _sign_transaction_generic(account, sanitized_transaction, EditValidator)

def sign_staking_transaction(transaction_dict, private_key):
    """
    Sign a supplied transaction_dict with the private_key

    Parameters
    ----------
    transaction_dict: :obj:`dict`, a dictionary with the following keys
        directive :obj:`staking_structures.Directive`, type of transaction
        nonce: :obj:`int`, nonce of transaction
        gasPrice: :obj:`int`, gas price for the transaction
        gasLimit: :obj:`int`, gas limit for the transaction
        chainId: :obj:`int`, chain id for the transaction, optional
            see util.chain_id_to_int for options
        The following keys depend on the directive:
        CollectRewards:
            delegatorAddress: :obj:`str`, Address of the delegator
        Delegate/Undelegate:
            delegatorAddress: :obj:`str`, Address of the delegator
            validatorAddress: :obj:`str`, Address of the validator
            amount: :obj:`int`, Amount to (un)delegate in ATTO
        CreateValidator:
            validatorAddress: :obj:`str`, Address of the validator
            name: ;obj:`str`, Name of the validator
            identity: :obj:`str`, Identity of the validator, must be unique
            website: :obj:`str`, Website of the validator
            security-contact: :obj:`str`, Security contact
            details: :obj:`str` Validator details
            rate: :obj:'Decimal' or :obj:`str` Staking commission rate
            max-rate: :obj:'Decimal' or :obj:`str` Maximum staking commission rate
            max-change-rate: :obj:'Decimal' or :obj:`str` Maximum change in
                    staking commission rate per epoch
            bls-public-keys: :obj:`list` List of strings of BLS public keys
            min-self-delegation: :obj:`int` or :obj:`Decimal` Validator min
                self delegation in ATTO
            max-total-delegation: :obj:`int` or :obj:`Decimal` Validator max
                total delegation in ATTO
        EditValidator:
            validatorAddress: :obj:`str`, Address of the validator
            name: ;obj:`str`, Name of the validator
            identity: :obj:`str`, Identity of the validator, must be unique
            website: :obj:`str`, Website of the validator
            security-contact: :obj:`str`, Security contact
            details: :obj:`str` Validator details
            rate: :obj:'Decimal' or :obj:`str` Staking commission rate
            min-self-delegation: :obj:`int` or :obj:`Decimal` Validator min
                self delegation in ATTO
            max-total-delegation: :obj:`int` or :obj:`Decimal` Validator max
                total delegation in ATTO
            bls-key-to-remove: :obj:`str` BLS Public key to remove
            bls-key-to-add: :obj:`str` BLS Public key to add
    private_key: :obj:`str`, the private key to sign the transaction with

    Raises
    ------
    AssertionError, if inputs are not as expected
    KeyError, if inputs are missing
    ValueError, if specifically staking rates are malformed
    rlp.exceptions.ObjectSerializationError, if input data types are not as expected

    Returns
    -------
    SignedTransaction object, the hash of which can be used to send the transaction
        using transaction.send_raw_transaction

    API Reference
    -------------
    https://github.com/harmony-one/sdk/blob/99a827782fabcd5f91f025af0d8de228956d42b4/packages/harmony-staking/src/stakingTransaction.ts
    """
    assert isinstance(transaction_dict, dict), 'Only dictionaries are supported'        # OrderedDict is a subclass
    assert 'directive' in transaction_dict, 'Staking transaction type not specified'
    assert isinstance(transaction_dict['directive'], Directive), 'Unknown staking transaction type'
    if transaction_dict['directive'] == Directive.CollectRewards:
        return _sign_collect_rewards(transaction_dict, private_key)
    elif transaction_dict['directive'] == Directive.Delegate:
        return _sign_delegate_or_undelegate(transaction_dict, private_key, True)
    elif transaction_dict['directive'] == Directive.Undelegate:
        return _sign_delegate_or_undelegate(transaction_dict, private_key, False)
    elif transaction_dict['directive'] == Directive.CreateValidator:
        return _sign_create_validator(transaction_dict, private_key)
    elif transaction_dict['directive'] == Directive.EditValidator:
        return _sign_edit_validator(transaction_dict, private_key)
