"""Python API for user accounts.

Account information includes a student's username, password, and email
address, but does NOT include user profile information (i.e., demographic
information and preferences).

"""
from django.conf import settings
from django.db import transaction, IntegrityError
from django.core.validators import validate_email, validate_slug, ValidationError
from django.contrib.auth.forms import PasswordResetForm

from user_api.models import User, UserProfile, Registration, PendingEmailChange
from user_api.helpers import intercept_errors


USERNAME_MIN_LENGTH = 2
USERNAME_MAX_LENGTH = 30

EMAIL_MIN_LENGTH = 3
EMAIL_MAX_LENGTH = 254

PASSWORD_MIN_LENGTH = 2
PASSWORD_MAX_LENGTH = 75


class AccountRequestError(Exception):
    """There was a problem with the request to the account API. """
    pass


class AccountInternalError(Exception):
    """An internal error occurred in the account API. """
    pass


class AccountUserAlreadyExists(AccountRequestError):
    """User with the same username and/or email already exists. """
    pass


class AccountUsernameAlreadyExists(AccountUserAlreadyExists):
    """An account already exists with the requested username. """
    pass


class AccountEmailAlreadyExists(AccountUserAlreadyExists):
    """An account already exists with the requested email. """
    pass


class AccountUsernameInvalid(AccountRequestError):
    """The requested username is not in a valid format. """
    pass


class AccountEmailInvalid(AccountRequestError):
    """The requested email is not in a valid format. """
    pass


class AccountPasswordInvalid(AccountRequestError):
    """The requested password is not in a valid format. """
    pass


class AccountUserNotFound(AccountRequestError):
    """The requested user does not exist. """
    pass


class AccountNotAuthorized(AccountRequestError):
    """The user is not authorized to perform the requested action. """
    pass


@intercept_errors(AccountInternalError, ignore_errors=[AccountRequestError])
@transaction.commit_on_success
def create_account(username, password, email):
    """Create a new user account.

    This will implicitly create an empty profile for the user.

    WARNING: This function does NOT yet implement all the features
    in `student/views.py`.  Until it does, please use this method
    ONLY for tests of the account API, not in production code.
    In particular, these are currently missing:

    * 3rd party auth
    * External auth (shibboleth)
    * Complex password policies (ENFORCE_PASSWORD_POLICY)

    In addition, we assume that some functionality is handled
    at higher layers:

    * Analytics events
    * Activation email
    * Terms of service / honor code checking
    * Recording demographic info (use profile API)
    * Auto-enrollment in courses (if invited via instructor dash)

    Args:
        username (unicode): The username for the new account.
        password (unicode): The user's password.
        email (unicode): The email address associated with the account.

    Returns:
        unicode: an activation key for the account.

    Raises:
        AccountUserAlreadyExists
        AccountUsernameInvalid
        AccountEmailInvalid
        AccountPasswordInvalid

    """
    # Validate the username, password, and email
    # This will raise an exception if any of these are not in a valid format.
    _validate_username(username)
    _validate_password(password, username)
    _validate_email(email)

    # Create the user account, setting them to "inactive" until they activate their account.
    user = User(username=username, email=email, is_active=False)
    user.set_password(password)

    try:
        user.save()
    except IntegrityError:
        raise AccountUserAlreadyExists

    # Create a registration to track the activation process
    # This implicitly saves the registration.
    registration = Registration()
    registration.register(user)

    # Create an empty user profile with default values
    UserProfile(user=user).save()

    # Return the activation key, which the caller should send to the user
    return registration.activation_key


@intercept_errors(AccountInternalError, ignore_errors=[AccountRequestError])
def account_info(username):
    """Retrieve information about a user's account.

    Arguments:
        username (unicode): The username associated with the account.

    Returns:
        dict: User's account information, if the user was found.
        None: The user does not exist.

    """
    try:
        user = User.objects.get(username=username)
    except User.DoesNotExist:
        return None
    else:
        return {
            u'username': username,
            u'email': user.email,
            u'is_active': user.is_active,
        }


@intercept_errors(AccountInternalError, ignore_errors=[AccountRequestError])
def activate_account(activation_key):
    """Activate a user's account.

    Args:
        activation_key (unicode): The activation key the user received via email.

    Returns:
        None

    Raises:
        AccountNotAuthorized

    """
    try:
        registration = Registration.objects.get(activation_key=activation_key)
    except Registration.DoesNotExist:
        raise AccountNotAuthorized
    else:
        # This implicitly saves the registration
        registration.activate()


@intercept_errors(AccountInternalError, ignore_errors=[AccountRequestError])
def request_email_change(username, new_email, password):
    """Request an email change.

    Users must confirm the change before we update their information.

    Args:
        username (unicode): The username associated with the account.
        new_email (unicode): The user's new email address.
        password (unicode): The password the user entered to authorize the change.

    Returns:
        unicode: an activation key for the account.

    Raises:
        AccountUserNotFound
        AccountEmailAlreadyExists
        AccountEmailInvalid
        AccountNotAuthorized

    """
    try:
        user = User.objects.get(username=username)
    except User.DoesNotExist:
        raise AccountUserNotFound

    # Check the user's credentials
    if not user.check_password(password):
        raise AccountNotAuthorized

    # Validate the email, raising an exception if it is not in the correct format
    _validate_email(new_email)

    # Verify that no active account has taken the email in between
    # the request and the activation.
    # We'll check again before confirming and persisting the change,
    # but if the email is already taken by an active account, we should
    # let the user know as soon as possible.
    if User.objects.filter(email=new_email, is_active=True).exists():
        raise AccountEmailAlreadyExists

    try:
        pending_change = PendingEmailChange.objects.get(user=user)
    except PendingEmailChange.DoesNotExist:
        pending_change = PendingEmailChange(user=user)

    # Update the change (re-using the same record if it already exists)
    # This will generate a new activation key and save the record.
    return pending_change.request_change(new_email)


@intercept_errors(AccountInternalError, ignore_errors=[AccountRequestError])
@transaction.commit_on_success
def confirm_email_change(activation_key):
    """Confirm an email change.

    Users can confirm the change by providing an activation key
    they received via email.

    Args:
        activation_key (unicode): The activation key the user received
            when he/she requested the email change.

    Returns:
        Tuple: (old_email, new_email)

    Raises:
        AccountNotAuthorized: The activation code is invalid.
        AccountEmailAlreadyExists: Someone else has already taken the email address.
        AccountInternalError

    """

    try:
        # Activation key has a uniqueness constraint, so we're guaranteed to get
        # at most one pending change.
        pending_change = PendingEmailChange.objects.select_related('user').get(
            activation_key=activation_key
        )
    except PendingEmailChange.DoesNotExist:
        # If there are no changes, then the activation key is invalid
        raise AccountNotAuthorized
    else:
        old_email = pending_change.user.email
        new_email = pending_change.new_email

        # Verify that no one else has taken the email in between
        # the request and the activation.
        # In our production database, email has a uniqueness constraint,
        # so there is no danger of a race condition here.
        if User.objects.filter(email=new_email).exists():
            raise AccountEmailAlreadyExists

        # Update the email history (in the user profile)
        try:
            profile = UserProfile.objects.get(user=pending_change.user)
        except UserProfile.DoesNotExist:
            raise AccountInternalError(
                "No profile exists for the user '{username}'".format(
                    username=pending_change.user.username
                )
            )
        else:
            profile.update_email(new_email)

        # Delete the pending change, so that the activation code
        # will be single-use
        pending_change.delete()

        # Return the old and new email
        # This allows the caller of the function to notify users at both
        # the new and old email, which is necessary for security reasons.
        return (old_email, new_email)


@intercept_errors(AccountInternalError, ignore_errors=[AccountRequestError])
def request_password_change(email, orig_host, is_secure):
    """Email a single-use link for performing a password reset.

    Users must confirm the password change before we update their information.

    Args:
        email (string): An email address
        orig_host (string): An originating host, extracted from a request with get_host
        is_secure (Boolean): Whether the request was made with HTTPS

    Returns:
        None

    Raises:
        AccountUserNotFound
        AccountRequestError

    """
    # Binding data to a form requires that the data be passed as a dictionary
    # to the Form class constructor.
    form = PasswordResetForm({'email': email})

    # Validate that an active user exists with the given email address.
    if form.is_valid():
        # Generate a single-use link for performing a password reset
        # and email it to the user.
        form.save(
            from_email=settings.DEFAULT_FROM_EMAIL,
            domain_override=orig_host,
            use_https=is_secure
        )
    else:
        # No active user with the provided email address exists.
        raise AccountUserNotFound


def _validate_username(username):
    """Validate the username.

    Arguments:
        username (unicode): The proposed username.

    Returns:
        None

    Raises:
        AccountUsernameInvalid

    """
    if not isinstance(username, basestring):
        raise AccountUsernameInvalid(u"Username must be a string")

    if len(username) < USERNAME_MIN_LENGTH:
        raise AccountUsernameInvalid(
            u"Username '{username}' must be at least {min} characters long".format(
                username=username,
                min=USERNAME_MIN_LENGTH
            )
        )
    if len(username) > USERNAME_MAX_LENGTH:
        raise AccountUsernameInvalid(
            u"Username '{username}' must be at most {max} characters long".format(
                username=username,
                max=USERNAME_MAX_LENGTH
            )
        )
    try:
        validate_slug(username)
    except ValidationError:
        raise AccountUsernameInvalid(
            u"Username '{username}' must contain only A-Z, a-z, 0-9, -, or _ characters"
        )


def _validate_password(password, username):
    """Validate the format of the user's password.

    Passwords cannot be the same as the username of the account,
    so we take `username` as an argument.

    Arguments:
        password (unicode): The proposed password.
        username (unicode): The username associated with the user's account.

    Returns:
        None

    Raises:
        AccountPasswordInvalid

    """
    if not isinstance(password, basestring):
        raise AccountPasswordInvalid(u"Password must be a string")

    if len(password) < PASSWORD_MIN_LENGTH:
        raise AccountPasswordInvalid(
            u"Password must be at least {min} characters long".format(
                min=PASSWORD_MIN_LENGTH
            )
        )

    if len(password) > PASSWORD_MAX_LENGTH:
        raise AccountPasswordInvalid(
            u"Password must be at most {max} characters long".format(
                max=PASSWORD_MAX_LENGTH
            )
        )

    if password == username:
        raise AccountPasswordInvalid(u"Password cannot be the same as the username")


def _validate_email(email):
    """Validate the format of the email address.

    Arguments:
        email (unicode): The proposed email.

    Returns:
        None

    Raises:
        AccountEmailInvalid

    """
    if not isinstance(email, basestring):
        raise AccountEmailInvalid(u"Email must be a string")

    if len(email) < EMAIL_MIN_LENGTH:
        raise AccountEmailInvalid(
            u"Email '{email}' must be at least {min} characters long".format(
                email=email,
                min=EMAIL_MIN_LENGTH
            )
        )

    if len(email) > EMAIL_MAX_LENGTH:
        raise AccountEmailInvalid(
            u"Email '{email}' must be at most {max} characters long".format(
                email=email,
                max=EMAIL_MAX_LENGTH
            )
        )

    try:
        validate_email(email)
    except ValidationError:
        raise AccountEmailInvalid(
            u"Email '{email}' format is not valid".format(email=email)
        )
