from datetime import datetime
from meerkat_auth.role import Role
from passlib.hash import pbkdf2_sha256
from flask import jsonify
from meerkat_auth import app
import logging
import boto3
import jwt
import re


class User:
    """
    Class to model a single User's account and manage writing, reading,
    deleting from the database.
    """

    # The regular expression defining whether an email address is valid.
    EMAIL_REGEX = re.compile(r"[^@]+@[^@]+\.[^@]+")
    # The database resource
    DB = boto3.resource(
        'dynamodb',
        endpoint_url=app.config['DB_URL'],
        region_name='eu-west-1'
    )

    def __init__(self,
                 username,
                 email,
                 password,
                 countries,
                 roles,
                 state="live",
                 updated=None,
                 creation=None,
                 data={}):
        """Create a User object."""

        # Initalise variables
        self.username = username
        self.email = email
        self.password = password
        self.countries = countries
        self.roles = roles
        self.state = state
        if not creation:
            creation = datetime.now().isoformat()
        if not updated:
            updated = datetime.now().isoformat()
        self.creation = creation
        self.updated = updated
        self.data = data

        # Create an array of role objects from the array of roles.
        self.role_objs = []
        for r in enumerate(roles):
            self.role_objs.append(Role.from_db(self.countries[r[0]], r[1]))

    def __repr__(self):
        """
        Override to create a better string representation of a User object.
        """
        access = ','.join(
            '({}|{})'.format(*t) for t in zip(self.countries, self.roles)
        )
        updated = "" if self.creation == self.updated else "|" + self.updated
        string = '<{}: {}({}) email: {} pass: {} access: [{}] time: ({}{})>'

        return string.format(
            self.__class__.__name__,
            self.username,
            self.state,
            self.email,
            self.password,
            access,
            self.creation,
            updated
        )

    def __str__(self):
        """
        Override to create a better string representation of a User object.
        """
        access = ','.join(
            '({}|{})'.format(*t) for t in zip(self.countries, self.roles)
        )

        return '<{}: {}({}) email: {} access: [{}]>'.format(
            self.__class__.__name__,
            self.username,
            self.state,
            self.email,
            access,
        )

    def __eq__(self, other):
        """Override to define equality of User objects."""
        return self.username == other.username

    def __ne__(self, other):
        """Override to define inequality of User objects."""
        return self.username != other.username

    def __hash__(self):
        """Override to define the hash of User objects."""
        return hash(self.username)

    def to_dict(self):
        """Returns the current object state as a python dict."""
        return {
            'username': self.username,
            'email': self.email,
            'password': self.password,
            'countries': self.countries,
            'roles': self.roles,
            'state': self.state,
            'creation': self.creation,
            'updated': self.updated,
            'data': self.data
        }

    def to_json(self):
        """Returns the current object state as a json string."""
        return jsonify(self.to_dict())

    def to_db(self):
        """
        Writes this user object to the database table specified by
        config['USERS']. First validates content.

        Returns:
            The amazon dynamodb response.
        """
        # Validate
        self.validate()

        # Write to DB.
        logging.info("Validated. Writing object to database.")
        users = User.DB.Table(app.config['USERS'])

        # If new user, set the state as live now it is going into the db.
        # Also add the creation timestamp.
        if self.state == "new":
            self.creation = datetime.now().isoformat()
            self.state = "live"

        logging.warning("Data type: " + str(type(self.data)))

        response = users.update_item(
            Key={
                'username': self.username
            },
            AttributeUpdates={
                'email': {'Value': self.email, 'Action': 'PUT'},
                'password': {'Value': self.password, 'Action': 'PUT'},
                'countries': {'Value': self.countries, 'Action': 'PUT'},
                'roles': {'Value': self.roles, 'Action': 'PUT'},
                'state': {'Value': self.state, 'Action': 'PUT'},
                'creation': {'Value': self.creation, 'Action': 'PUT'},
                'updated': {'Value': self.updated, 'Action': 'PUT'},
                'data': {'Value': self.data, 'Action': 'PUT'}
            }
        )
        logging.info("Response from database:\n" + str(response))

        return response

    def get_access(self):
        """
        Returns an object detailing the complete list of roles this user has
        access to in each country.

        Returns:
            A dictionary where each key is a country and each value is a list
            of roles this user has access to in that country.
        """
        access = {}
        for role in self.role_objs:
            access.setdefault(role.country, []).extend(role.all_access())
        return access

    def get_jwt(self, exp):
        """
        Returns a small secure Json Web Token (JWT) giving the username
        and the specified expiry time of the user's session.  This is
        specifically kept extremely small so it can be stored as a cookie or
        header.

        Args:
            exp (string) The expiry time of the users session.
        Returns:
            The secure jwt.
        """
        payload = {
            'exp': exp,
            'usr': self.username
        }
        return jwt.encode(
            payload,
            app.config['JWT_SECRET_KEY'],
            algorithm=app.config['JWT_ALGORITHM']
        )

    def get_user_jwt(self, exp):
        """
        Returns a large secure Json Web Token (JWT) giving all the users
        details. This is not intended for storing as a cookie or a header.

        Args:
            exp (string) The expiry time of the users session.
        Returns:
            The secure jwt.
        """
        token = jwt.encode(
            self.get_payload(exp),
            app.config['JWT_SECRET_KEY'],
            algorithm=app.config['JWT_ALGORITHM']
        )
        token = token.decode('UTF-8')
        return token

    def get_payload(self, exp):
        """
        Returns a dictionary giving all details for the user session
        If storing as a cookie or a header, params should be deleted
        as the payload is too large really.

        Args:
            exp (string) The expiry time of the user's session.
        Returns:
            The secure jwt.
        """
        return {
            'exp': exp,
            'acc': self.get_access(),
            'usr': self.username,
            'email': self.email,
            'data': self.data
        }

    def validate(self):
        """
        Checks whether the object is a valid user object that can be written
        to the database.

        Raises:
            InvalidCredentialException if a credential is invalid.\n
            InvalidRoleException if an ancestor role is not valid.
        """
        logging.info("Validating User object:\n" + repr(self))

        # Raises an InvalidCredentialException if username not valid.
        if self.state == 'new':
            User.validate_username(self.username)
        else:
            if not User.check_username(self.username):
                raise InvalidCredentialException(
                    'username',
                    self.username,
                    'Username must match a username in the database.'
                )

        # Check that the password is a hash.
        if not pbkdf2_sha256.identify(self.password):
            raise InvalidCredentialException(
                'password',
                self.password,
                ('Password must be hashed according '
                 'to the specified hashing policy.')
            )

        # Raises an InvalidRoleException if role, or ancestor doesn't exist.
        User.validate_roles(self.countries, self.roles)

        # Raises an InvalidCredentialException if the email is not valid.
        if not User.EMAIL_REGEX.match(self.email):
            raise InvalidCredentialException('email', self.email)

    @staticmethod
    def authenticate(username, password):
        """
        Checks whether the specified credentials are valid credentials for a
        user. If they are, returns the User object, if not throws an exception.

        Args:
            username (str)
            password (str)

        Returns:
            The authenticated user.

        Raises:
            InvalidCredentialException if any credentials are invalid.
        """

        # Raises an exception if the username is invalid.
        user = User.from_db(username)
        # Raises an exception if the password is invalid.
        if pbkdf2_sha256.verify(password, user.password):
            return user
        else:
            raise InvalidCredentialException('password', password)

    @staticmethod
    def from_db(username):
        """
        Creates a python object for a given username using
        data fetched from the database table specified by config['USERS'].

        Args:
            username (str)
        Returns:
            The python User object for the given username.
        """
        # Load data
        logging.info('Loading user ' + username + ' from database.')
        users = User.DB.Table(app.config['USERS'])
        response = users.get_item(
            Key={
                'username': username
            }
        )
        logging.info('Response from database:\n' + str(response))

        # Build and return object
        if not response.get("Item", None):
            raise InvalidCredentialException('username', username)
        else:
            r = response["Item"]
            logging.info("RESPONSE------------\n" + repr(r))
            user = User(
                r['username'],
                r['email'],
                r['password'],
                r['countries'],
                r['roles'],
                state=r.get('state', 'undefined'),
                updated=r.get('updated', 'undefined'),
                creation=r.get('creation', 'undefined'),
                data=r.get('data', {})
            )

            # We want NO NEW USERS in the database.  Do 2nd clean up here.
            user.state = "live" if user.state == "new" else user.state

            logging.info('Returning user:\n' + repr(user))
            return user

    @staticmethod
    def delete(username):
        """
        Deletes account data corresponding to the given username from the
        database table specified by config['USERS'].

        Args:
            username (str)

        Returns:
            The amazon dynamodb response.
        """
        logging.info('Deleting user ' + username)
        users = User.DB.Table(app.config['USERS'])
        response = users.delete_item(
            Key={
                'username': username
            }
        )
        logging.info("Response from database:\n" + str(response))
        return response

    @staticmethod
    def check_username(username):
        """
        Efficiently checks whether a specific username already exists in
        the database.

        Args:
            username (str)

        Returns:
            bool True if in db, False if not in db.
        """
        users = User.DB.Table(app.config['USERS'])
        response = users.get_item(
            Key={'username': username},
            AttributesToGet=['username'],
        )
        if response.get("Item", None):
            return True
        else:
            return False

    @staticmethod
    def validate_username(username):
        """
        Checks whether the specified new username is valid or not, and raises
        an InvalidCredentialException if it is not valid.

        Args:
            username (str) The username to check the validity of.

        Raises:
            InvalidCredentialException if the username already exists.
        """

        if User.check_username(username):
            raise InvalidCredentialException(
                'username',
                username,
                "A 'new' username must not match a username in the database."
            )

    @staticmethod
    def validate_roles(countries, roles):
        """
        Checks whether the supplied roles and countries lists are valid
        parameters for creating a new user.  Crucially the roles must exist
        in the database and have a valid ancestor list.

        Args:
            countries ([str]) A list of strings where each stirng is the name
                of a country.
            roles ([str]) A list of strings where eachs tring is the title of
                a valid role.

        Raises:
            InvalidRoleException if there is a role that does not meet
                the criteria.
        """
        for country, role in zip(countries, roles):
            Role.validate_role(country, role)

    @staticmethod
    def hash_password(password):
        """
        Hashes a string according to Meerkat's password hashing policy.

        Args:
            password (str) The unhashed password

        Returns:
            str The hashed password
        """
        return pbkdf2_sha256.encrypt(password)

    @staticmethod
    def update_user(username, email, unhashed_pass, countries, roles, data={}):
        """
        Utility function to help update user objects.
        Validates and prepares the data given, loads the user from db, edits
        the users data and writes the user to the db before returning the final
        user to the caller. Preparation includes the hashing of the new
        password.

        Args:
            username (str) Must be unique in the database.
            email (str) Must be a valid email address.
            unhashed_pass (str) The unhashed password.
            countries ([str]) A list of countries the account has access to.
            roles ([str]) A corresponding list of access roles for each country \
                with the same index in the countries argument.

        Returns:
            The user object if it has been successfully validated.

        Raises:
            InvalidCredentialException if the username or password isn't valid.
            InvalidRoleException if a role doesn't exist or doesn't have a
            valid ancestor list.
        """

        # Hash the password with a salt size of 16 bytes and 29000 rounds.
        hashed_pass = User.hash_password(unhashed_pass)

        # Create the user, write it to db and return the object.
        user = User.from_db(username)
        user.username = username
        user.email = email
        user.password = hashed_pass
        user.countries = countries
        user.roles = roles
        user.updated = datetime.now().isoformat()
        if(data):
            user.data = data

        # Validate the details
        user.validate()

        # Update and return
        user.to_db()
        return user

    @staticmethod
    def get_all(countries, attributes):
        """
        Fetches from the database the requested attributes for all users that
        belong to the specified country. If country or attributes equate to
        false then all possible options for that argument will be used.

        Args:
            country ([str]) A list of countries for which we want user
                accounts. This is an OR list - i.e. any account attached to ANY
                of the countries in the list is retruned.
            attributes ([str]) A list of user account attribute names that we
                want to download.

        Returns:
            A python dictionary storing user accounts by username.
        """
        # Set things up.
        logging.info('Loading users for ' + str(countries) + ' from database.')
        table = User.DB.Table(app.config['USERS'])

        # Allow any value for attributes and countries that equates to false.
        if not attributes:
            attributes = []
        if not countries:
            countries = []

        # If a single value is specified, not a list, turn it into a list.
        if not isinstance(countries, list):
            countries = [countries]
        if not isinstance(attributes, list):
            attributes = [attributes]

        # Return dict is indexed by username, so ensure added.
        if attributes and 'username' not in attributes:
            attributes.append('username')

        # Assemble scan arguments programatically, by building a dictionary.
        kwargs = {}

        # Include AttributesToGet if any are specified.
        # By not including them we get them all.
        if attributes:
            kwargs["AttributesToGet"] = attributes

        if not countries:
            # If no country is specified, get all users and return as list.
            return table.scan(**kwargs).get("Items", [])

        else:
            users = {}
            # Load data separately for each country
            # ...because Scan can't perform OR on CONTAINS
            for country in countries:

                kwargs["ScanFilter"] = {
                    'countries': {
                        'AttributeValueList': [country],
                        'ComparisonOperator': 'CONTAINS'
                    }
                }

                # Get and combine the users together in a no-duplications dict.
                for user in table.scan(**kwargs).get("Items", []):
                    users[user["username"]] = user

            # Convert the dict to a list by getting values.
            return list(users.values())


class InvalidCredentialException(Exception):
    """
    An exception to be raised when an invalid credential is supplied, typically
    username or password. This Exception prints out the credential in the error
    message, but only prints out the invalid value supplied if you called the
    repr() function on the object.
    """
    def __init__(self, credential, value, message=""):
        """Create the exception"""
        self.credential = credential
        self.value = value
        self.message = message

    def __str__(self):
        """Readable string to print, not including the invalid value."""
        msg = "Invalid credential supplied. Please check the {} and try again."
        return msg.format(
            self.credential
        )

    def __repr__(self):
        """
        String referencing both the invalid credential and the its invalid
        value.
        """
        return "INVALID CREDENTIALS: {} is not valid. {}".format(
            self.credential, self.message
        )
