#!/usr/bin/env python

from pprint import pprint
from web3 import Web3, HTTPProvider
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='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 proveder, 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='excplicitly 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('dymka')
    if args.verbose:
        defaultLevel -= 10*args.verbose
    if args.quiet:
        defaultLevel += 10*args.quiet
    log.setLevel(defaultLevel)
    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)
            elif args.from_account.startswith('0x'):
                self.address_from = args.from_account
            else:
                self.privKey = args.from_account
        if self.privKey:
            self.address_from = self.w3.eth.account.from_key(self.privKey).address

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

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

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

    @command
    def checksum(self):
        return self.processAccounts(Web3.toChecksumAddress, self.address_from, args.to)

    @command
    def balance(self):
        return self.processAccounts(self.w3.eth.getBalance, self.address_from, args.to)

    @command
    def nonce(self):
        return self.processAccounts(self.w3.eth.getTransactionCount,
                                    self.address_from, args.to)

    @command
    def exec(self):
        function = args.arguments[0]
        arguments = getOptionalArguments(1)
        return self.provider.make_request(function, *[arguments])

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

    @command
    def receipt(self):
        hash = args.arguments[0]
        receipt = self.w3.eth.getTransactionReceipt(hash)
        return dict(receipt)

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

    @classmethod
    def getContractAddress(cls):
        if args.address:
            return args.address
        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 {envname} set.')

    @staticmethod
    def getContractData():
        fname = args.contractJson
        if not fname:
            fname = args.contract + '.json'
            if not os.path.isfile(fname):
                fname = args.contract
        data = getJson(fname)
        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'Ambigous or empty contract list {contracts} in json {fname} '
                             f'for contract {args.contract}.')
        contract = contracts_section[contracts[0]]
        return contract['abi'], contract['bin']

    @command
    def call(self):
        function_name = args.arguments[0]
        abi, _ = self.getContractData()
        contract = self.w3.eth.contract(abi=abi,
                                        address=self.getContractAddress())
        arguments = getOptionalArguments(1)
        func = contract.functions[function_name]
        tx = func(*arguments)
        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):
        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'] = dict(self.w3.eth.getTransactionReceipt(hash))
        return status

    @command
    def deploy(self):
        abi, bin = self.getContractData()
        contract = self.w3.eth.contract(abi=abi, bytecode=bin)
        arguments = getOptionalArguments(0)
        ctor = contract.constructor(*arguments)
        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]
        abi, _ = self.getContractData()
        contract = self.w3.eth.contract(abi=abi,
                                        address=self.getContractAddress())
        arguments = getOptionalArguments(1)
        func = contract.functions[function_name]
        func_bound = func(*arguments)
        return func_bound.buildTransaction(opts)

    @command
    def send(self):
        opts = self.getOpts()
        tx = opts if not args.contract else self.buildContractSendTx(opts)
        return self.transact(tx)


if __name__ == "__main__":
    if args.command == 'version':
        print('Version: 1.0.0')
        exit(0)
    try:
        d = Dymka()
        pprint(dispatch[args.command](d))
    except Exception as e:
        if log.isEnabledFor(logging.DEBUG):
            traceback.print_exc()
        else:
            log.error(e)
