###############################################################################
#
# The MIT License (MIT)
#
# Copyright (c) Crossbar.io Technologies GmbH
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
###############################################################################

import os
import io
import sys
import uuid
import struct
import binascii
import configparser
from typing import Optional, List, Dict

import click
import nacl
import web3
import numpy as np
from time import time_ns
from eth_utils.conversions import hexstr_if_str, to_hex

from autobahn.websocket.util import parse_url
from autobahn.xbr._wallet import pkm_from_argon2_secret

_HAS_COLOR_TERM = False
try:
    import colorama

    # https://github.com/tartley/colorama/issues/48
    term = None
    if sys.platform == 'win32' and 'TERM' in os.environ:
        term = os.environ.pop('TERM')

    colorama.init()
    _HAS_COLOR_TERM = True

    if term:
        os.environ['TERM'] = term

except ImportError:
    pass


class Profile(object):
    """
    User profile, stored as named section in ``${HOME}/.xbrnetwork/config.ini``:

    .. code-block:: INI

        [default]
        # username used with this profile
        username=joedoe

        # user email used with the profile (e.g. for verification emails)
        email=joe.doe@example.com

        # XBR network node used as a directory server and gateway to XBR smart contracts
        network_url=ws://localhost:8090/ws

        # WAMP realm on network node, usually "xbrnetwork"
        network_realm=xbrnetwork

        # user private WAMP-cryptosign key (for client authentication)
        cskey=0xb18bbe88ca0e189689e99f87b19addfb179d46aab3d59ec5d93a15286b949eb6

        # user private Ethereum key (for signing transactions and e2e data encryption)
        ethkey=0xfbada363e724d4db2faa2eeaa7d7aca37637b1076dd8cf6fefde13983abaa2ef
    """

    def __init__(self,
                 path: Optional[str] = None,
                 name: Optional[str] = None,
                 member_adr: Optional[str] = None,
                 ethkey: Optional[bytes] = None,
                 cskey: Optional[bytes] = None,
                 username: Optional[str] = None,
                 email: Optional[str] = None,
                 network_url: Optional[str] = None,
                 network_realm: Optional[str] = None,
                 member_oid: Optional[uuid.UUID] = None,
                 vaction_oid: Optional[uuid.UUID] = None,
                 vaction_requested: Optional[np.datetime64] = None,
                 vaction_verified: Optional[np.datetime64] = None,
                 data_url: Optional[str] = None,
                 data_realm: Optional[str] = None,
                 infura_url: Optional[str] = None,
                 infura_network: Optional[str] = None,
                 infura_key: Optional[str] = None,
                 infura_secret: Optional[str] = None):
        """

        :param path:
        :param name:
        :param member_adr:
        :param ethkey:
        :param cskey:
        :param username:
        :param email:
        :param network_url:
        :param network_realm:
        :param member_oid:
        :param vaction_oid:
        :param vaction_requested:
        :param vaction_verified:
        :param data_url:
        :param data_realm:
        :param infura_url:
        :param infura_network:
        :param infura_key:
        :param infura_secret:
        """
        from txaio import make_logger
        self.log = make_logger()

        self.path = path
        self.name = name

        self.member_adr = member_adr
        self.ethkey = ethkey
        self.cskey = cskey
        self.username = username
        self.email = email
        self.network_url = network_url
        self.network_realm = network_realm
        self.member_oid = member_oid
        self.vaction_oid = vaction_oid
        self.vaction_requested = vaction_requested
        self.vaction_verified = vaction_verified
        self.data_url = data_url
        self.data_realm = data_realm
        self.infura_url = infura_url
        self.infura_network = infura_network
        self.infura_key = infura_key
        self.infura_secret = infura_secret

    def marshal(self):
        obj = {}
        obj['member_adr'] = self.member_adr or ''
        obj['ethkey'] = '0x{}'.format(binascii.b2a_hex(self.ethkey).decode()) if self.ethkey else ''
        obj['cskey'] = '0x{}'.format(binascii.b2a_hex(self.cskey).decode()) if self.cskey else ''
        obj['username'] = self.username or ''
        obj['email'] = self.email or ''
        obj['network_url'] = self.network_url or ''
        obj['network_realm'] = self.network_realm or ''
        obj['member_oid'] = str(self.member_oid) if self.member_oid else ''
        obj['vaction_oid'] = str(self.vaction_oid) if self.vaction_oid else ''
        obj['vaction_requested'] = str(self.vaction_requested) if self.vaction_requested else ''
        obj['vaction_verified'] = str(self.vaction_verified) if self.vaction_verified else ''
        obj['data_url'] = self.data_url or ''
        obj['data_realm'] = self.data_realm or ''
        obj['infura_url'] = self.infura_url or ''
        obj['infura_network'] = self.infura_network or ''
        obj['infura_key'] = self.infura_key or ''
        obj['infura_secret'] = self.infura_secret or ''
        return obj

    @staticmethod
    def parse(path, name, items):
        member_adr = None
        ethkey = None
        cskey = None
        username = None
        email = None
        network_url = None
        network_realm = None
        member_oid = None
        vaction_oid = None
        vaction_requested = None
        vaction_verified = None
        data_url = None
        data_realm = None
        infura_network = None
        infura_key = None
        infura_secret = None
        infura_url = None
        for k, v in items:
            if k == 'network_url':
                network_url = str(v)
            elif k == 'network_realm':
                network_realm = str(v)
            elif k == 'vaction_oid':
                if type(v) == str and v != '':
                    vaction_oid = uuid.UUID(v)
                else:
                    vaction_oid = None
            elif k == 'member_adr':
                if type(v) == str and v != '':
                    member_adr = v
                else:
                    member_adr = None
            elif k == 'member_oid':
                if type(v) == str and v != '':
                    member_oid = uuid.UUID(v)
                else:
                    member_oid = None
            elif k == 'vaction_requested':
                if type(v) == int and v:
                    vaction_requested = np.datetime64(v, 'ns')
                else:
                    vaction_requested = v
            elif k == 'vaction_verified':
                if type(v) == int:
                    vaction_verified = np.datetime64(v, 'ns')
                else:
                    vaction_verified = v
            elif k == 'data_url':
                data_url = str(v)
            elif k == 'data_realm':
                data_realm = str(v)
            elif k == 'ethkey':
                ethkey = binascii.a2b_hex(v[2:])
            elif k == 'cskey':
                cskey = binascii.a2b_hex(v[2:])
            elif k == 'username':
                username = str(v)
            elif k == 'email':
                email = str(v)
            elif k == 'infura_network':
                infura_network = str(v)
            elif k == 'infura_key':
                infura_key = str(v)
            elif k == 'infura_secret':
                infura_secret = str(v)
            elif k == 'infura_url':
                infura_url = str(v)
            elif k in ['path', 'name']:
                pass
            else:
                # skip unknown attribute
                print('unprocessed config attribute "{}"'.format(k))

        return Profile(path, name,
                       member_adr, ethkey, cskey,
                       username, email,
                       network_url, network_realm,
                       member_oid,
                       vaction_oid, vaction_requested, vaction_verified,
                       data_url, data_realm,
                       infura_url, infura_network, infura_key, infura_secret)


class UserConfig(object):
    """
    Local user configuration file. The data is either a plain text (unencrypted)
    .ini file, or such a file encrypted with XSalsa20-Poly1305, and with a
    binary file header of 48 octets.
    """
    def __init__(self, config_path):
        """

        :param config_path: The user configuration file path.
        """
        from txaio import make_logger
        self.log = make_logger()
        self._config_path = os.path.abspath(config_path)
        self._profiles = {}

    @property
    def config_path(self) -> List[str]:
        """
        Return the path to the user configuration file exposed by this object.,

        :return: Local filesystem path.
        """
        return self._config_path

    @property
    def profiles(self) -> Dict[str, object]:
        """
        Access to a map of user profiles in this user configuration.

        :return: Map of user profiles.
        """
        return self._profiles

    def save(self, password: Optional[str] = None):
        """
        Save this user configuration to the underlying configuration file. The user
        configuration file can be encrypted using Argon2id when a ``password`` is given.

        :param password: The optional Argon2id password.

        :return: Number of octets written to the user configuration file.
        """
        written = 0
        config = configparser.ConfigParser()
        for profile_name, profile in self._profiles.items():
            if profile_name not in config.sections():
                config.add_section(profile_name)
                written += 1
            pd = profile.marshal()
            for option, value in pd.items():
                config.set(profile_name, option, value)

        with io.StringIO() as fp1:
            config.write(fp1)
            config_data = fp1.getvalue().encode('utf8')

        if password:
            # binary file format header (48 bytes):
            #
            # * 8 bytes: 0xdeadbeef 0x00000666 magic number (big endian)
            # * 4 bytes: 0x00000001 encryption type 1 for "argon2id"
            # * 4 bytes data length (big endian)
            # * 8 bytes created timestamp ns (big endian)
            # * 8 bytes unused (filled 0x00 currently)
            # * 16 bytes salt
            #
            salt = os.urandom(16)
            context = 'xbrnetwork-config'
            priv_key = pkm_from_argon2_secret(email='', password=password, context=context, salt=salt)
            box = nacl.secret.SecretBox(priv_key)
            config_data_ciphertext = box.encrypt(config_data)
            dl = [
                b'\xde\xad\xbe\xef',
                b'\x00\x00\x06\x66',
                b'\x00\x00\x00\x01',
                struct.pack('>L', len(config_data_ciphertext)),
                struct.pack('>Q', time_ns()),
                b'\x00' * 8,
                salt,
                config_data_ciphertext,
            ]
            data = b''.join(dl)
        else:
            data = config_data

        with open(self._config_path, 'wb') as fp2:
            fp2.write(data)

        self.log.info('configuration with {sections} sections, {bytes_written} bytes written to {written_to}',
                      sections=written, bytes_written=len(data), written_to=self._config_path)

        return len(data)

    def load(self, cb_get_password=None) -> List[str]:
        """
        Load this object from the underlying user configuration file. When the
        file is encrypted, call back into ``cb_get_password`` to get the user password.

        :param cb_get_password: Callback called when password is needed.

        :return: List of profiles loaded.
        """
        if not os.path.exists(self._config_path) or not os.path.isfile(self._config_path):
            raise RuntimeError('config path "{}" cannot be loaded: so such file'.format(self._config_path))

        with open(self._config_path, 'rb') as fp:
            data = fp.read()

        if len(data) >= 48 and data[:8] == b'\xde\xad\xbe\xef\x00\x00\x06\x66':
            # binary format detected
            header = data[:48]
            body = data[48:]

            algo = struct.unpack('>L', header[8:12])[0]
            data_len = struct.unpack('>L', header[12:16])[0]
            created = struct.unpack('>Q', header[16:24])[0]
            # created_ts = np.datetime64(created, 'ns')

            assert algo in [0, 1]
            assert data_len == len(body)
            assert created < time_ns()

            salt = header[32:48]
            context = 'xbrnetwork-config'
            if cb_get_password:
                password = cb_get_password()
            else:
                password = ''
            priv_key = pkm_from_argon2_secret(email='', password=password, context=context, salt=salt)
            box = nacl.secret.SecretBox(priv_key)
            body = box.decrypt(body)

        else:
            header = None
            body = data

        config = configparser.ConfigParser()
        config.read_string(body.decode('utf8'))

        profiles = {}
        for profile_name in config.sections():
            citems = config.items(profile_name)
            profile = Profile.parse(self._config_path, profile_name, citems)
            profiles[profile_name] = profile
        self._profiles = profiles

        loaded_profiles = sorted(self.profiles.keys())
        return loaded_profiles


if 'CROSSBAR_FABRIC_URL' in os.environ:
    _DEFAULT_CFC_URL = os.environ['CROSSBAR_FABRIC_URL']
else:
    _DEFAULT_CFC_URL = u'wss://master.xbr.network/ws'


def style_error(text):
    if _HAS_COLOR_TERM:
        return click.style(text, fg='red', bold=True)
    else:
        return text


def style_ok(text):
    if _HAS_COLOR_TERM:
        return click.style(text, fg='green', bold=True)
    else:
        return text


class WampUrl(click.ParamType):
    """
    WAMP transport URL validator.
    """

    name = 'WAMP transport URL'

    def __init__(self):
        click.ParamType.__init__(self)

    def convert(self, value, param, ctx):
        try:
            parse_url(value)
        except Exception as e:
            self.fail(style_error(str(e)))
        else:
            return value


def prompt_for_wamp_url(msg, default=None):
    """
    Prompt user for WAMP transport URL (eg "wss://planet.xbr.network/ws").
    """
    value = click.prompt(msg, type=WampUrl(), default=default)
    return value


class EthereumAddress(click.ParamType):
    """
    Ethereum address validator.
    """

    name = 'Ethereum address'

    def __init__(self):
        click.ParamType.__init__(self)

    def convert(self, value, param, ctx):
        try:
            value = web3.Web3.toChecksumAddress(value)
            adr = binascii.a2b_hex(value[2:])
            if len(value) != 20:
                raise ValueError('Ethereum addres must be 20 bytes (160 bit), but was {} bytes'.format(len(adr)))
        except Exception as e:
            self.fail(style_error(str(e)))
        else:
            return value


def prompt_for_ethereum_address(msg):
    """
    Prompt user for an Ethereum (public) address.
    """
    value = click.prompt(msg, type=EthereumAddress())
    return value


class PrivateKey(click.ParamType):
    """
    Private key (32 bytes in HEX) validator.
    """

    name = 'Private key'

    def __init__(self, key_len):
        click.ParamType.__init__(self)
        self._key_len = key_len

    def convert(self, value, param, ctx):
        try:
            value = hexstr_if_str(to_hex, value)
            if value[:2] in ['0x', '\\x']:
                key = binascii.a2b_hex(value[2:])
            else:
                key = binascii.a2b_hex(value)
            if len(key) != self._key_len:
                raise ValueError('key length must be {} bytes, but was {} bytes'.format(self._key_len, len(key)))
        except Exception as e:
            self.fail(style_error(str(e)))
        else:
            return value


def prompt_for_key(msg, key_len, default=None):
    """
    Prompt user for a binary key of given length (in HEX).
    """
    value = click.prompt(msg, type=PrivateKey(key_len), default=default)
    return value


# default configuration stored in $HOME/.xbrnetwork/config.ini
_DEFAULT_CONFIG = """[default]
# username used with this profile
username={username}

# user email used with the profile (e.g. for verification emails)
email={email}

# XBR network node used as a directory server and gateway to XBR smart contracts
network_url={network_url}

# WAMP realm on network node, usually "xbrnetwork"
network_realm={network_realm}

# user private WAMP-cryptosign key (for client authentication)
cskey={cskey}

# user private Ethereum key (for signing transactions and e2e data encryption)
ethkey={ethkey}
"""

# # default XBR market URL to connect to
# market_url={market_url}
# market_realm={market_realm}
# # Infura blockchain gateway configuration
# infura_url={infura_url}
# infura_network={infura_network}
# infura_key={infura_key}
# infura_secret={infura_secret}


def load_or_create_profile(dotdir=None, profile=None, default_url=None, default_realm=None, default_email=None, default_username=None):
    dotdir = dotdir or '~/.xbrnetwork'
    profile = profile or 'default'
    default_url = default_url or 'wss://planet.xbr.network/ws'
    default_realm = default_realm or 'xbrnetwork'

    config_dir = os.path.expanduser(dotdir)
    if not os.path.isdir(config_dir):
        os.mkdir(config_dir)
        click.echo('created new local user directory {}'.format(style_ok(config_dir)))

    config_path = os.path.join(config_dir, 'config.ini')
    if not os.path.isfile(config_path):
        click.echo('creating new user profile "{}"'.format(style_ok(profile)))
        with open(config_path, 'w') as f:
            network_url = prompt_for_wamp_url('enter the WAMP router URL of the network directory node', default=default_url)
            network_realm = click.prompt('enter the WAMP realm to join on the network directory node', type=str, default=default_realm)
            cskey = prompt_for_key('your private WAMP client key', 32, default='0x' + binascii.b2a_hex(os.urandom(32)).decode())
            ethkey = prompt_for_key('your private Etherum key', 32, default='0x' + binascii.b2a_hex(os.urandom(32)).decode())
            email = click.prompt('user email used for with profile', type=str, default=default_email)
            username = click.prompt('user name used with this profile', type=str, default=default_username)
            f.write(_DEFAULT_CONFIG.format(network_url=network_url, network_realm=network_realm, ethkey=ethkey,
                                           cskey=cskey, email=email, username=username))
            click.echo('created new local user configuration {}'.format(style_ok(config_path)))

    config_obj = UserConfig(config_path)
    config_obj.load()
    profile_obj = config_obj.profiles.get(profile, None)

    if not profile_obj:
        raise click.ClickException('no such profile: "{}"'.format(profile))

    return profile_obj
