# By: Riasat Ullah
# This module provides a set of methods to work with user credentials.

from dbqueries import db_users
from exceptions.user_exceptions import InvalidRequest, LockedAccount
from utils import constants, errors, logging, var_names
from validations import string_validator
import configuration
import datetime
import hashlib
import psycopg2
import random
import string
import uuid


def user_login(conn, user_id, password, access_method, ip_address, timestamp):
    '''
    This function manages a user's login attempt.
    :param conn: db connection
    :param user_id: (int) user_id of the user
    :param password: password of the user
    :param access_method: the system the user tried to log in through (APP, WEB)
    :param ip_address: ip address of the user
    :param timestamp: timestamp of the login attempt
    :return: (boolean) True if the login attempt was successful, False otherwise
    :errors: AssertionError, InvalidRequest, KeyError, LockedAccount, ValueError, DatabaseError
    '''
    current_timestamp = timestamp
    assert string_validator.is_valid_ip_address(ip_address)
    assert access_method in configuration.allowed_login_methods

    try:
        # Check if the provided password matches the user's stored password.
        # If account is locked a LockedAccount exception will be raised.
        if matches(conn, user_id, password, current_timestamp):
            # If the password matches then create an entry for this successful login.
            db_users.store_login_attempt(conn, user_id, current_timestamp, access_method, ip_address, True)
            return True
        else:
            logging.error('Password did not match. Failed login attempt.')
            # If the password does not match, create an entry for this failed login.
            db_users.store_login_attempt(conn, user_id, current_timestamp, access_method, ip_address, False)

            # Check if there were too many failed login attempts made on the account.
            # If there were too many, then lock the account.
            check_endtime = timestamp
            check_starttime = timestamp - datetime.timedelta(minutes=configuration.failed_attempts_checking_minutes)
            failed_logins = len(db_users.failed_login_attempts(conn, user_id, check_starttime, check_endtime))

            if failed_logins >= configuration.allowed_failed_login_attempts:
                logging.info('Too many failed login attempt. Locking account...')
                db_users.lock_account(conn, user_id, current_timestamp, constants.excessive_login_fails)
            return False
    except KeyError:
        # This will be raised when no password is found for the provided email address.
        raise KeyError(errors.err_invalid_email_or_password)
    except LockedAccount:
        raise LockedAccount(errors.err_user_account_locked)
    except psycopg2.DatabaseError:
        raise


def matches(conn, user_id, password, timestamp):
    '''
    Checks if the password provided for the user_id is correct or not.
    :param conn: db connection
    :param user_id: (int) user_id provided
    :param password: password provided
    :param timestamp: timestamp of when the request is being made
    :return: (boolean) True if matches; False otherwise
    :errors: Errors from dependencies - AssertionError, LockedAccount, KeyError, ValueError, DatabaseError
    '''
    db_password, salt = db_users.get_password(conn, user_id, timestamp)
    provided_password, salt = convert_text_to_hash(password, salt)
    if db_password == provided_password:
        return True
    else:
        return False


def convert_text_to_hash(text, with_salt=None):
    '''
    Converts a string to hash given a salt.
    :param text: the string to hash
    :param with_salt: the salt to hash with
    :return: (tuple) hashed text, salt
    '''
    if with_salt is None:
        salt = uuid.uuid4().hex
    else:
        salt = with_salt
    hash_text = hashlib.sha512(text.encode('utf-8') +
                               salt.encode('utf-8')).hexdigest()
    return hash_text, salt


def generate_password(pwd_length=None):
    '''
    Generates a random password.
    :param pwd_length: length of the password
    :return: (string); password (actual text; not hash)
    '''
    if pwd_length is None:
        pwd_length = random.randint(11, 16)
    options = {0: list(string.ascii_lowercase)[random.randint(0, 26)],
               1: list(string.ascii_uppercase)[random.randint(0, 26)],
               2: random.randint(0, 10)}
    pwd = ''
    for i in range(0, pwd_length + 1):
        pwd += options[random.randint(0, 3)]
    return pwd


def verify_password_reset_attempt(conn, timestamp, user_id, ip_address):
    '''
    This function should primarily be used to verify a user's details before allowing
    the user to reset their password in the event that the user forgets it.
    :param conn: db connection
    :param user_id: ID of the user
    :param timestamp: timestamp of the request
    :param ip_address: ip address from where the request was generated from
    :return: (boolean) True if the details verify; False otherwise
    :errors: AssertionError, InvalidRequest
        from dependencies -> AssertionError, DatabaseError
    '''
    assert isinstance(timestamp, datetime.datetime)
    assert string_validator.is_valid_ip_address(ip_address)

    check_endtime = timestamp
    check_starttime = timestamp - datetime.timedelta(minutes=configuration.failed_attempts_checking_minutes)
    attempts = len(db_users.password_reset_attempts(conn, user_id, check_starttime, check_endtime))

    if attempts > configuration.allowed_password_retrieval_attempts:
        logging.error('Too many failed user_id retrieval attempts were detected. Locking account...')
        db_users.lock_account(conn, user_id, timestamp, constants.excessive_password_reset_fails)
        return False
    else:
        logging.info('Password reset attempt has been stored')
        db_users.store_password_reset_attempt(conn, user_id, timestamp, ip_address)
        return True


def verification_code_matches(conn, timestamp, email, code, verification_type=constants.new_account_verification):
    '''
    Checks if the verification code associated with a new account matches or not.
    :param conn: db connection
    :param timestamp: timestamp when the check is being requested
    :param email: email address that is associated with the new account
    :param code: verification code associated with the new account
    :param verification_type: the type of verification the code is being saved for
    :return: (boolean) True if matches; False otherwise
    :errors: AssertionError, KeyError, ValueError, DatabaseError
    '''
    # get the stored hash and salt of the verification code
    email = email.lower()
    db_code, salt = db_users.get_verification_code(conn, timestamp, email, verification_type)
    provided_code, salt = convert_text_to_hash(code, salt)
    if db_code == provided_code:
        db_users.update_verification_code_entry(conn, timestamp, email, expire=True, is_verified=True,
                                                verification_type=verification_type)
        return True
    else:
        db_users.update_verification_code_entry(conn, timestamp, email, verification_type=verification_type)
        return False
