#!python
from web3 import Web3, HTTPProvider, datastructures, exceptions
from hexbytes import HexBytes
import argparse
import json
import logging
import os
import traceback

parser = argparse.ArgumentParser(description='Interact with ethereum network.',
                                 fromfile_prefix_chars='@')
parser.add_argument('command',
                    help='help version exec balance nonce deploy call send receipt ...'),
parser.add_argument('arguments', nargs='*',
                    help='optional arguments to the command')
parser.add_argument('-a', '--address',
                    help='contract address to send/call, defaults to env '
                    'WEB3_CONTRACT_{CONTRACT_NAME}')
parser.add_argument('-c', '--contract',
                    help='contract name or its json path')
parser.add_argument('-d', '--dry', action='store_true',
                    help='dry run, do not transact')
parser.add_argument('-f', '--from', dest='from_account',
                    default=os.environ.get('WEB3_FROM', None),
                    help='account keystore filename, raw private key or account address, '
                    'defaults to env WEB3_FROM')
parser.add_argument('-j', '--contractJson',
                    help='contract json path if different from contract name')
parser.add_argument('-p', '--provider',
                    default=os.environ.get('WEB3_PROVIDER', 'http://localhost:8545'),
                    help='web3 provider, defaults to env WEB3_PROVIDER or localhost:8545')
parser.add_argument('-t', '--to',
                    help='account address to transact to')
parser.add_argument('--chainId', type=int,
                    help='explicitly set chain id')
parser.add_argument('--password', type=argparse.FileType('r'),
                    help='pass phrase to unlock the from account, defaults to empty')
parser.add_argument('--gasPrice', type=int,
                    help='explicitly set gas price')
parser.add_argument('--nonce',
                    help='explicitly set nonce')
parser.add_argument('--timeout', type=int, default=120,
                    help='timeout to wait after the transaction, default is 120s')
parser.add_argument('--value', type=int,
                    help='money amount to use in transaction')

group = parser.add_mutually_exclusive_group()
group.add_argument('-v', '--verbose', action='count',
                   help='verbose output')
group.add_argument('-q', '--quiet', action='count',
                   help='quiet output')

group = parser.add_mutually_exclusive_group()
group.add_argument('-e', '--estimate', '--estimateGas', action='store_true',
                    help='estimate gas for the tx and use it unless dry run')
group.add_argument('--gas', type=int,
                    help='explicitly set gas amount')

args = parser.parse_args()


def getLogger(defaultLevel):
    log = logging.getLogger(__name__)
    if args.verbose:
        defaultLevel -= 10*args.verbose
    if args.quiet:
        defaultLevel += 10*args.quiet
    log.setLevel(max(defaultLevel, logging.DEBUG))
    stream = logging.StreamHandler()
    stream.setFormatter(logging.Formatter('%(levelname)s %(message)s'))
    log.addHandler(stream)
    return log


log = getLogger(logging.INFO)


def command(func):
    dispatch[func.__name__] = func
    return func


dispatch = {}


def getJson(fname):
    with open(fname) as file:
        return json.load(file)


def getOptionalArguments(ord):
    if len(args.arguments) > ord:
        log.debug('Raw args %s', args.arguments[ord:])
        arguments = eval('[' + ','.join(args.arguments[ord:]) + ']')
        log.debug('Arguments: %s', arguments)
        return arguments
    return []


class Dymka:
    def __init__(self):
        log.info('Using %s to connect web3...', args.provider)
        self.provider = HTTPProvider(args.provider)
        self.w3 = Web3(self.provider)
        self.privKey = None
        self.address_from = None
        if args.from_account:
            if os.path.isfile(args.from_account):
                password = args.password.read() if args.password else ''
                with open(args.from_account) as keyfile:
                    encrypted_key = keyfile.read()
                    self.privKey = self.w3.eth.account.decrypt(encrypted_key, password)
            else:
                self.privKey = args.from_account
        if self.privKey:
            try:
                self.address_from = self.w3.eth.account.from_key(self.privKey).address
            except ValueError:
                log.debug(f'Using address {args.from_account}.')
                self.address_from = args.from_account

    @command
    def show(self):
        """Shows configuration used."""
        status = {'provider': args.provider}
        if self.address_from:
            status['address'] = self.address_from
        return status

    @command
    def gas(self):
        """Returns current gas price."""
        return {'gasPrice': self.w3.eth.gasPrice}

    @staticmethod
    def processAccounts(fn, *vargs):
        """Applies functor `fn` to program arguments and function vargs."""
        return list(map(lambda a: a and {'account': a, 'result': fn(a)},
                        [*args.arguments, *filter(None, vargs)]))

    @command
    def checksum(self):
        """Calculate address string representation with correct web3 checksum."""
        return self.processAccounts(Web3.toChecksumAddress, self.address_from, args.to)

    @command
    def balance(self):
        """Returns balance of all provided arguments and --from account."""
        return self.processAccounts(self.w3.eth.getBalance, self.address_from, args.to)

    @command
    def nonce(self):
        """Returns nonce for all provided arguments and --from account."""
        return self.processAccounts(self.w3.eth.getTransactionCount,
                                    self.address_from, args.to)

    @command
    def exec(self):
        """Executes given JSON-RPC command."""
        return self.provider.make_request(args.arguments[0], *[getOptionalArguments(1)])

    @command
    def transaction(self):
        """Returns transaction with the given hash."""
        hash = args.arguments[0]
        tx = self.w3.eth.getTransaction(hash)
        return dict(tx)

    @staticmethod
    def getContractAddressEnv(name):
        return f'WEB3_CONTRACT_{name.upper()}'

    @classmethod
    def getContractAddress(cls):
        """Discovers contract address from --address or --contract arguments."""
        if args.address:
            return args.address
        if args.contract:
            envname = cls.getContractAddressEnv(args.contract)
            address = os.environ.get(envname, None)
            if address:
                log.info(f'Using contract {args.contract} at {address}.')
                return address
            raise ValueError(f'Need contract address, but neither --address nor '
                             f'{envname} is set.')
        raise ValueError('Need contract address, but neither --address nor --contract '
                         'is set.')

    def getContract(self, **kwargs):
        """Returns contract object discovered from --contract and --contractJson
        arguments.

        """
        fname = args.contractJson
        if not fname:
            fname = args.contract + '.json'
            if not os.path.isfile(fname):
                fname = args.contract
        data = getJson(fname)
        if isinstance(data, list):
            log.debug(f'Using contract ABI from {fname}.')
            return self.w3.eth.contract(abi=data, **kwargs)
        contracts_section = data['contracts']
        contracts = [key for key in contracts_section.keys()
                     if key.lower().endswith(args.contract.lower())]
        if len(contracts) != 1:
            raise ValueError(f'Ambiguous or empty contract list {contracts} in json '
                             f'{fname} for contract {args.contract}.')
        contract = contracts_section[contracts[0]]
        return self.w3.eth.contract(abi=contract['abi'],
                                    bytecode=contract['bin'],
                                    **kwargs)

    def getTxReceipt(self, hash):
        receipt = dict(self.w3.eth.getTransactionReceipt(hash))
        if args.contract:
            contract = self.getContract()
            logs = []
            for log in receipt['logs']:
                processed = False
                for evt in contract.events:
                    try:
                        logs.append(contract.events[evt.event_name]().processLog(log))
                        processed = True
                        break
                    except exceptions.MismatchedABI:
                        pass
                if not processed:
                    logs.append(log)
            receipt['logs'] = logs
        return receipt

    @command
    def receipt(self):
        """Returns transaction receipt with the given hash."""
        return self.getTxReceipt(args.arguments[0])

    @command
    def call(self):
        """
        Call the specified contract function with the specified parameters, if
        any.  Note, that this is not a transaction, but just a query. No
        blockchain data may change.

        """
        function_name = args.arguments[0]
        contract = self.getContract(address=self.getContractAddress())
        func = contract.functions[function_name]
        tx = func(*getOptionalArguments(1))
        from_account = self.address_from
        if not from_account:
            from_account = '0x0000000000000000000000000000000000000000'
            log.warn(f'From account not specified, using {from_account}.')
        return {'result': tx.call({'from': from_account})}

    def getOpts(self):
        """Prepare options for making a transaction."""
        opts = {
            'from': self.address_from,
            'nonce': args.nonce or self.w3.eth.getTransactionCount(self.address_from)
        }
        for name in ['chainId', 'gas', 'gasPrice', 'value', 'to']:
            if vars(args)[name]:
                opts[name] = vars(args)[name]
        log.info('Transaction options: %s', opts)
        return opts

    def transact(self, tx):
        status = {}
        if args.estimate:
            gas = self.w3.eth.estimateGas(tx)
            log.info('Gas estimated %s', gas)
            tx['gas'] = status['gas'] = gas
        if args.dry:
            log.info('Dry run requested, nothing more to do')
            return status
        signed = self.w3.eth.account.sign_transaction(tx, private_key=self.privKey)
        hash = self.w3.eth.sendRawTransaction(signed.rawTransaction)
        status['hash'] = hash.hex()
        log.info('Transaction hash: %s', hash.hex())
        try:
            self.w3.eth.waitForTransactionReceipt(hash, timeout=args.timeout)
        except Exception:
            raise ValueError(f'Timeout waiting {args.timeout}s for transaction {hash.hex()}')
        status['receipt'] = self.getTxReceipt(hash)
        return status

    @command
    def deploy(self):
        """
        Deploy the given contract. Pass arguments to its constructor, if
        needed. Note that full contract data JSON must be provided, not just
        ABI.

        """
        contract = self.getContract()
        ctor = contract.constructor(*getOptionalArguments(0))
        tx = ctor.buildTransaction(self.getOpts())
        status = self.transact(tx)
        if 'receipt' in status:
            address = status['receipt']['contractAddress']
            log.info(f'Evaluate: '
                     f'export {self.getContractAddressEnv(args.contract)}={address}')
        return status

    def buildContractSendTx(self, opts):
        function_name = args.arguments[0]
        contract = self.getContract(address=self.getContractAddress())
        func = contract.functions[function_name]
        func_bound = func(*getOptionalArguments(1))
        return func_bound.buildTransaction(opts)

    @command
    def send(self):
        """
        Invoke the specified function for the specified contract. Pass arguments,
        if needed.
        """
        opts = self.getOpts()
        tx = opts if not args.contract else self.buildContractSendTx(opts)
        return self.transact(tx)


class EthereumEncoder(json.JSONEncoder):
    def default(self, obj):
        if isinstance(obj, HexBytes):
            return obj.hex()
        elif isinstance(obj, datastructures.AttributeDict):
            return dict(obj)
        return json.JSONEncoder.default(self, obj)


if __name__ == "__main__":
    if args.command == 'version':
        print('Version: 1.0.4')
        exit(0)
    elif args.command == 'help':
        if args.arguments:
            print(dispatch[args.arguments[0]].__doc__)
        else:
            print(f'Specify a command for help: {list(dispatch.keys())}.')
        exit(0)
    try:
        d = Dymka()
        result = dispatch[args.command](d)
        print(json.dumps(result, cls=EthereumEncoder, indent=4))
    except Exception as e:
        if args.verbose and args.verbose > 1:
            traceback.print_exc()
        else:
            log.error(e)
        exit(1)
