from __future__ import unicode_literals
import binascii
from datetime import datetime
import operator
import json
import logging
import requests
import six

from .. import exceptions
from ..account import Account
from ..address import address, Address, SubAddress
from ..block import Block
from ..const import NET_MAIN, NET_TEST, NET_STAGE
from ..numbers import from_atomic, to_atomic, PaymentID
from ..seed import Seed
from ..transaction import Transaction, IncomingPayment, OutgoingPayment


_log = logging.getLogger(__name__)


RESTRICTED_MAX_TRANSACTIONS = 100


class JSONRPCDaemon(object):
    """
    JSON RPC backend for Monero daemon

    :param protocol: `http` or `https`
    :param host: host name or IP
    :param port: port number
    :param path: path for JSON RPC requests (should not be changed)
    :param timeout: request timeout
    :param verify_ssl_certs: verify SSL certificates when connecting
    :param proxy_url: a proxy to use
    """

    _net = None

    def __init__(self, protocol='http', host='127.0.0.1', port=18081, path='/json_rpc',
            user='', password='', timeout=30, verify_ssl_certs=True, proxy_url=None):
        self.url = '{protocol}://{host}:{port}'.format(
                protocol=protocol,
                host=host,
                port=port)
        _log.debug("JSONRPC daemon backend URL: {url}".format(url=self.url))
        self.user = user
        self.password = password
        self.timeout = timeout
        self.verify_ssl_certs = verify_ssl_certs
        self.proxies = {protocol: proxy_url}

    def _set_net(self, info):
        if info['mainnet']:
            self._net = NET_MAIN
        if info['testnet']:
            self._net = NET_TEST
        if info['stagenet']:
            self._net = NET_STAGE

    def info(self):
        info = self.raw_jsonrpc_request('get_info')
        self._set_net(info)
        return info

    def net(self):
        if self._net:
            return self._net
        self.info()
        return self._net

    def send_transaction(self, blob, relay=True):
        res = self.raw_request('/sendrawtransaction', {
            'tx_as_hex': six.ensure_text(binascii.hexlify(blob)),
            'do_not_relay': not relay})
        if res['status'] == 'OK':
            return res
        raise exceptions.TransactionBroadcastError(
                "{status}: {reason}".format(**res),
                details=res)

    def mempool(self):
        res = self.raw_request('/get_transaction_pool', {})
        txs = []
        for tx in res.get('transactions', []):
            txs.append(Transaction(
                hash=tx['id_hash'],
                fee=from_atomic(tx['fee']),
                timestamp=datetime.fromtimestamp(tx['receive_time']),
                blob=binascii.unhexlify(tx['tx_blob']),
                json=json.loads(tx['tx_json']),
                confirmations=0))
        return txs

    def headers(self, start_height, end_height=None):
        end_height = end_height or start_height
        res = self.raw_jsonrpc_request('get_block_headers_range', {
                'start_height': start_height,
                'end_height': end_height})
        if res['status'] == 'OK':
            return res['headers']
        raise exceptions.BackendException(res['status'])

    def block(self, bhash=None, height=None):
        data = {}
        if bhash:
            data['hash'] = bhash
        if height:
            data['height'] = height
        res = self.raw_jsonrpc_request('get_block', data)
        if res['status'] == 'OK':
            bhdr = res['block_header']
            sub_json = json.loads(res['json'])
            data = {
                'blob': res['blob'],
                'hash': bhdr['hash'],
                'height': bhdr['height'],
                'timestamp': datetime.fromtimestamp(bhdr['timestamp']),
                'version': (bhdr['major_version'], bhdr['minor_version']),
                'difficulty': bhdr['difficulty'],
                'nonce': bhdr['nonce'],
                'orphan': bhdr['orphan_status'],
                'prev_hash': bhdr['prev_hash'],
                'reward': from_atomic(bhdr['reward']),
                'transactions': self.transactions(
                    [bhdr['miner_tx_hash']] + sub_json['tx_hashes']),
            }
            return Block(**data)
        raise exceptions.BackendException(res['status'])

    def transactions(self, hashes):
        """
        Returns a list of transactions for given hashes. Automatically chunks the request
        into amounts acceptable by a restricted RPC server.
        """
        hashes = list(hashes)
        result = []
        while len(hashes):
            result.extend(self._do_get_transactions(hashes[:RESTRICTED_MAX_TRANSACTIONS]))
            hashes = hashes[RESTRICTED_MAX_TRANSACTIONS:]
        return result

    def _do_get_transactions(self, hashes):
        res = self.raw_request('/get_transactions', {
                'txs_hashes': hashes,
                'decode_as_json': True})
        if res['status'] != 'OK':
            raise exceptions.BackendException(res['status'])
        txs = []
        for tx in res.get('txs', []):
            as_json = json.loads(tx['as_json'])
            fee = as_json.get('rct_signatures', {}).get('txnFee')
            txs.append(Transaction(
                hash=tx['tx_hash'],
                fee=from_atomic(fee) if fee else None,
                height=None if tx['in_pool'] else tx['block_height'],
                timestamp=datetime.fromtimestamp(
                    tx['block_timestamp']) if 'block_timestamp' in tx else None,
                blob=binascii.unhexlify(tx['as_hex']),
                json=as_json))
        return txs

    def raw_request(self, path, data):
        hdr = {'Content-Type': 'application/json'}
        _log.debug(u"Request: {path}\nData: {data}".format(
            path=path,
            data=json.dumps(data, indent=2, sort_keys=True)))
        auth = requests.auth.HTTPDigestAuth(self.user, self.password)
        rsp = requests.post(
            self.url + path, headers=hdr, data=json.dumps(data), auth=auth,
            timeout=self.timeout, verify=self.verify_ssl_certs, proxies=self.proxies)
        if rsp.status_code != 200:
            raise RPCError("Invalid HTTP status {code} for path {path}.".format(
                code=rsp.status_code,
                path=path))
        result = rsp.json()
        _ppresult = json.dumps(result, indent=2, sort_keys=True)
        _log.debug(u"Result:\n{result}".format(result=_ppresult))
        return result

    def raw_jsonrpc_request(self, method, params=None):
        hdr = {'Content-Type': 'application/json'}
        data = {'jsonrpc': '2.0', 'id': 0, 'method': method, 'params': params or {}}
        _log.debug(u"Method: {method}\nParams:\n{params}".format(
            method=method,
            params=json.dumps(params, indent=2, sort_keys=True)))
        auth = requests.auth.HTTPDigestAuth(self.user, self.password)
        rsp = requests.post(
            self.url + '/json_rpc', headers=hdr, data=json.dumps(data), auth=auth,
            timeout=self.timeout, verify=self.verify_ssl_certs, proxies=self.proxies)

        if rsp.status_code == 401:
            raise Unauthorized("401 Unauthorized. Invalid RPC user name or password.")
        elif rsp.status_code != 200:
            raise RPCError("Invalid HTTP status {code} for method {method}.".format(
                code=rsp.status_code,
                method=method))
        result = rsp.json()
        _ppresult = json.dumps(result, indent=2, sort_keys=True)
        _log.debug(u"Result:\n{result}".format(result=_ppresult))

        if 'error' in result:
            err = result['error']
            _log.error(u"JSON RPC error:\n{result}".format(result=_ppresult))
            raise RPCError(
                "Method '{method}' failed with RPC Error of unknown code {code}, "
                "message: {message}".format(method=method, data=data, result=result, **err))
        return result['result']


class JSONRPCWallet(object):
    """
    JSON RPC backend for Monero wallet (``monero-wallet-rpc``)

    :param protocol: `http` or `https`
    :param host: host name or IP
    :param port: port number
    :param path: path for JSON RPC requests (should not be changed)
    :param user: username to authenticate with over RPC
    :param password: password to authenticate with over RPC
    :param timeout: request timeout
    :param verify_ssl_certs: verify ssl certs for request
    :param proxy_url: a proxy to use
    """
    _master_address = None

    def __init__(self, protocol='http', host='127.0.0.1', port=18088, path='/json_rpc',
            user='', password='', timeout=30, verify_ssl_certs=True, proxy_url=None):
        self.url = '{protocol}://{host}:{port}/json_rpc'.format(
                protocol=protocol,
                host=host,
                port=port)
        _log.debug("JSONRPC wallet backend URL: {url}".format(url=self.url))
        self.user = user
        self.password = password
        self.timeout = timeout
        self.verify_ssl_certs = verify_ssl_certs
        self.proxies = {protocol: proxy_url}
        _log.debug("JSONRPC wallet backend auth: '{user}'/'{stars}'".format(
            user=user, stars=('*' * len(password)) if password else ''))

    def height(self):
        return self.raw_request('getheight')['height']

    def spend_key(self):
        return self.raw_request('query_key', {'key_type': 'spend_key'})['key']

    def view_key(self):
        return self.raw_request('query_key', {'key_type': 'view_key'})['key']

    def seed(self):
        return Seed(self.raw_request('query_key', {'key_type': 'mnemonic'})['key'])

    def accounts(self):
        accounts = []
        _accounts = self.raw_request('get_accounts')
        idx = 0
        self._master_address = Address(_accounts['subaddress_accounts'][0]['base_address'])
        for _acc in _accounts['subaddress_accounts']:
            assert idx == _acc['account_index']
            accounts.append(Account(self, _acc['account_index'], label=_acc.get('label')))
            idx += 1
        return accounts

    def new_account(self, label=None):
        _account = self.raw_request('create_account', {'label': label})
        # NOTE: the following should re-read label by _account.get('label') but the RPC
        # doesn't return that detail here
        return Account(self, _account['account_index'], label=label), SubAddress(_account['address'])

    def addresses(self, account=0, addr_indices=None):
        qdata = {'account_index': account}
        if addr_indices:
            qdata['address_index'] = addr_indices
        _addresses = self.raw_request('getaddress', qdata)
        addresses = [None] * (max(map(operator.itemgetter('address_index'), _addresses['addresses'])) + 1)
        for _addr in _addresses['addresses']:
            addresses[_addr['address_index']] = address(
                _addr['address'],
                label=_addr.get('label', None))
        return addresses

    def new_address(self, account=0, label=None):
        _address = self.raw_request(
            'create_address', {'account_index': account, 'label': label})
        return SubAddress(_address['address']), _address['address_index']

    def balances(self, account=0):
        _balance = self.raw_request('getbalance', {'account_index': account})
        return (from_atomic(_balance['balance']), from_atomic(_balance['unlocked_balance']))

    def transfers_in(self, account, pmtfilter):
        params = {'account_index': account, 'pending': False}
        method = 'get_transfers'
        if pmtfilter.tx_ids:
            method = 'get_transfer_by_txid'
        if pmtfilter.unconfirmed:
            params['in'] = pmtfilter.confirmed
            params['out'] = False
            params['pool'] = True
        else:
            if pmtfilter.payment_ids:
                method = 'get_bulk_payments'
                params['payment_ids'] = list(map(str, pmtfilter.payment_ids))
            else:
                params['in'] = pmtfilter.confirmed
                params['out'] = False
                params['pool'] = False
        if method == 'get_transfers':
            if pmtfilter.min_height:
                # NOTE: the API uses (min, max] range which is confusing
                params['min_height'] = pmtfilter.min_height - 1
                params['filter_by_height'] = True
            if pmtfilter.max_height:
                params['max_height'] = pmtfilter.max_height
                params['filter_by_height'] = True
            _pmts = self.raw_request(method, params)
            pmts = _pmts.get('in', [])
        elif method == 'get_transfer_by_txid':
            pmts = []
            for txid in pmtfilter.tx_ids:
                params['txid'] = txid
                try:
                    _pmts = self.raw_request(method, params, squelch_error_logging=True)
                except exceptions.TransactionNotFound:
                    continue
                pmts.extend(_pmts['transfers'])
                # Issue #71: incoming payments to self will have excess 'destinations' key. Remove.
                for pmt in pmts:
                    try:
                        del pmt['destinations']
                    except KeyError:
                        pass
        else:
            # NOTE: the API uses (min, max] range which is confusing
            params['min_block_height'] = (pmtfilter.min_height or 1) - 1
            _pmts = self.raw_request(method, params)
            pmts = _pmts.get('payments', [])
        if pmtfilter.unconfirmed:
            pmts.extend(_pmts.get('pool', []))
        return list(pmtfilter.filter(map(self._inpayment, pmts)))

    def transfers_out(self, account, pmtfilter):
        if pmtfilter.tx_ids:
            pmts = []
            for txid in pmtfilter.tx_ids:
                try:
                    _pmts = self.raw_request(
                        'get_transfer_by_txid',
                        {'account_index': account, 'txid': txid},
                        squelch_error_logging=True)
                except exceptions.TransactionNotFound:
                    continue
                pmts.extend(_pmts['transfers'])
        else:
            _pmts = self.raw_request('get_transfers', {
                'account_index': account,
                'in': False,
                'out': pmtfilter.confirmed,
                'pool': False,
                'pending': pmtfilter.unconfirmed})
            pmts = _pmts.get('out', [])
            if pmtfilter.unconfirmed:
                pmts.extend(_pmts.get('pending', []))
        return list(pmtfilter.filter(map(self._outpayment, pmts)))

    def _paymentdict(self, data):
        pid = data.get('payment_id', None)
        laddr = data.get('address', None)
        if laddr:
            laddr = address(laddr)
        result = {
            'payment_id': None if pid is None else PaymentID(pid),
            'amount': from_atomic(data['amount']),
            'timestamp': datetime.fromtimestamp(data['timestamp']) if 'timestamp' in data else None,
            'note': data.get('note', None),
            'transaction': self._tx(data),
            'local_address': laddr,
        }
        if 'destinations' in data:
            result['destinations'] = [
                (address(x['address']), from_atomic(x['amount']))
                for x in data.get('destinations')
            ]
        return result


    def _inpayment(self, data):
        return IncomingPayment(**self._paymentdict(data))

    def _outpayment(self, data):
        return OutgoingPayment(**self._paymentdict(data))

    def _tx(self, data):
        return Transaction(**{
            'hash': data.get('txid', data.get('tx_hash')),
            'fee': from_atomic(data['fee']) if 'fee' in data else None,
            'key': data.get('key'),
            'height': data.get('height', data.get('block_height')) or None,
            'timestamp': datetime.fromtimestamp(data['timestamp']) if 'timestamp' in data else None,
            'blob': binascii.unhexlify(data.get('blob', '')),
            'confirmations': data.get('confirmations', None)
        })

    def export_outputs(self):
        return self.raw_request('export_outputs')['outputs_data_hex']

    def import_outputs(self, outputs_hex):
        return self.raw_request(
            'import_outputs',
            {'outputs_data_hex': outputs_hex})['num_imported']

    def export_key_images(self):
        return self.raw_request('export_key_images')['signed_key_images']

    def import_key_images(self, key_images):
        _data = self.raw_request(
            'import_key_images',
            {'signed_key_images': key_images})
        return (_data['height'], from_atomic(_data['spent']), from_atomic(_data['unspent']))

    def transfer(self, destinations, priority,
            payment_id=None, unlock_time=0, account=0,
            relay=True):
        data = {
            'account_index': account,
            'destinations': list(map(
                lambda dst: {'address': str(address(dst[0])), 'amount': to_atomic(dst[1])},
                destinations)),
            'priority': priority,
            'unlock_time': 0,
            'get_tx_keys': True,
            'get_tx_hex': True,
            'new_algorithm': True,
            'do_not_relay': not relay,
        }
        if payment_id is not None:
            data['payment_id'] = str(PaymentID(payment_id))
        _transfers = self.raw_request('transfer_split', data)
        _pertx = [dict(_tx) for _tx in map(
            lambda vs: zip(('txid', 'amount', 'fee', 'key', 'blob', 'payment_id'), vs),
            zip(*[_transfers[k] for k in (
                'tx_hash_list', 'amount_list', 'fee_list', 'tx_key_list', 'tx_blob_list')]))]
        for d in _pertx:
            d['payment_id'] = payment_id
        return [self._tx(data) for data in _pertx]

    def sweep_all(self, destination, priority, payment_id=None, subaddr_indices=None,
            unlock_time=0, account=0, relay=True):
        if not subaddr_indices:
            # retrieve indices of all subaddresses with positive unlocked balance
            bals = self.raw_request('get_balance', {'account_index': account})
            subaddr_indices = []
            for subaddr in bals['per_subaddress']:
                if subaddr.get('unlocked_balance', 0):
                    subaddr_indices.append(subaddr['address_index'])
        data = {
            'account_index': account,
            'address': str(address(destination)),
            'subaddr_indices': list(subaddr_indices),
            'priority': priority,
            'unlock_time': 0,
            'get_tx_keys': True,
            'get_tx_hex': True,
            'do_not_relay': not relay,
        }
        if payment_id is not None:
            data['payment_id'] = str(PaymentID(payment_id))
        _transfers = self.raw_request('sweep_all', data)
        _pertx = [dict(_tx) for _tx in map(
            lambda vs: zip(('txid', 'amount', 'fee', 'key', 'blob', 'payment_id'), vs),
            zip(*[_transfers[k] for k in (
                'tx_hash_list', 'amount_list', 'fee_list', 'tx_key_list', 'tx_blob_list')]))]
        for d in _pertx:
            d['payment_id'] = payment_id
        return list(zip(
                [self._tx(data) for data in _pertx],
                map(from_atomic, _transfers['amount_list'])))

    def raw_request(self, method, params=None, squelch_error_logging=False):
        hdr = {'Content-Type': 'application/json'}
        data = {'jsonrpc': '2.0', 'id': 0, 'method': method, 'params': params or {}}
        _log.debug(u"Method: {method}\nParams:\n{params}".format(
            method=method,
            params=json.dumps(params, indent=2, sort_keys=True)))
        auth = requests.auth.HTTPDigestAuth(self.user, self.password)
        rsp = requests.post(
            self.url, headers=hdr, data=json.dumps(data), auth=auth,
            timeout=self.timeout, verify=self.verify_ssl_certs, proxies=self.proxies)

        if rsp.status_code == 401:
            raise Unauthorized("401 Unauthorized. Invalid RPC user name or password.")
        elif rsp.status_code != 200:
            raise RPCError("Invalid HTTP status {code} for method {method}.".format(
                code=rsp.status_code,
                method=method))
        result = rsp.json()
        _ppresult = json.dumps(result, indent=2, sort_keys=True)
        _log.debug(u"Result:\n{result}".format(result=_ppresult))

        if 'error' in result:
            err = result['error']
            if not squelch_error_logging:
                _log.error(u"JSON RPC error:\n{result}".format(result=_ppresult))
            if err['code'] in _err2exc:
                raise _err2exc[err['code']](err['message'])
            else:
                raise RPCError(
                    "Method '{method}' failed with RPC Error of unknown code {code}, "
                    "message: {message}".format(method=method, data=data, result=result, **err))
        return result['result']


class RPCError(exceptions.BackendException):
    pass


class Unauthorized(RPCError):
    pass


class MethodNotFound(RPCError):
    pass


_err2exc = {
    -2: exceptions.WrongAddress,
    -4: exceptions.GenericTransferError,
    -5: exceptions.WrongPaymentId,
    -8: exceptions.TransactionNotFound,
    -9: exceptions.SignatureCheckFailed,
    -14: exceptions.AccountIndexOutOfBound,
    -15: exceptions.AddressIndexOutOfBound,
    -16: exceptions.TransactionNotPossible,
    -17: exceptions.NotEnoughMoney,
    -20: exceptions.AmountIsZero,
    -29: exceptions.WalletIsWatchOnly,
    -37: exceptions.NotEnoughUnlockedMoney,
    -38: exceptions.NoDaemonConnection,
    -43: exceptions.WalletIsNotDeterministic, # https://github.com/monero-project/monero/pull/4653
    -32601: MethodNotFound,
}
