from abc import ABC
from dataclasses import asdict
from smtplib import SMTPException
from typing import List
from uuid import UUID

from asgiref.sync import sync_to_async
from django.contrib.auth import get_user_model
from django.contrib.auth.forms import PasswordChangeForm, SetPasswordForm
from django.core.exceptions import ObjectDoesNotExist
from django.core.signing import BadSignature, SignatureExpired
from django.db import transaction
import strawberry
from strawberry.types import Info

from gqlauth.captcha.models import Captcha as CaptchaModel
from gqlauth.captcha.types_ import CaptchaType
from gqlauth.core.constants import Messages, TokenAction
from gqlauth.core.directives import BaseAuthDirective, IsVerified
from gqlauth.core.exceptions import (
    EmailAlreadyInUse,
    PasswordAlreadySetError,
    TokenScopeError,
    UserAlreadyVerified,
    UserNotVerified,
)
from gqlauth.core.shortcuts import get_user_by_email
from gqlauth.core.types_ import AuthOutput, MutationNormalOutput
from gqlauth.core.utils import (
    get_payload_from_token,
    get_user,
    inject_fields,
    revoke_user_refresh_token,
)
from gqlauth.jwt.types_ import (
    ObtainJSONWebTokenInput,
    ObtainJSONWebTokenType,
    RevokeRefreshTokenType,
    TokenType,
    VerifyTokenInput,
    VerifyTokenType,
)
from gqlauth.models import RefreshToken, UserStatus
from gqlauth.settings import gqlauth_settings as app_settings
from gqlauth.user.forms import (
    EmailForm,
    PasswordLessRegisterForm,
    RegisterForm,
    UpdateAccountForm,
)
from gqlauth.user.helpers import check_captcha, check_secondary_email, confirm_password
from gqlauth.user.signals import user_registered, user_verified

UserModel = get_user_model()


class BaseMixin(ABC):
    directives: List[BaseAuthDirective] = []

    def resolve_mutation(self, *args, **kwargs):
        raise NotImplementedError


class Captcha:
    """
    Creates a brand-new captcha.
    Returns a base64 encoded string of the captcha.
    And uuid representing the captcha id in the database.
    When you will try to log in or register You will
    need submit that uuid With the user input.
    **The captcha will be invoked when the timeout expires**.
    """

    @strawberry.mutation(description=__doc__)
    def field(self) -> CaptchaType:
        return CaptchaModel.create_captcha()

    @strawberry.mutation(description=__doc__)
    @sync_to_async
    def afield(self) -> CaptchaType:
        return CaptchaModel.create_captcha()


class RegisterMixin(BaseMixin):
    """
    Register user with fields defined in the settings.
    If the email field of the user model is part of the
    registration fields (default), check if there is
    no user with that email or as a secondary email.

    If it exists, it does not register the user,
    even if the email field is not defined as unique
    (default of the default django user model).

    When creating the user, it also creates a `UserStatus`
    related to that user, making it possible to track
    if the user is archived, verified and has a secondary
    email.

    Send account verification email.

    If allowed to not verified users login, return token.
    """

    @strawberry.input
    @inject_fields(app_settings.REGISTER_MUTATION_FIELDS)
    class RegisterInput:
        if not app_settings.ALLOW_PASSWORDLESS_REGISTRATION:
            password1: str
            password2: str

        if app_settings.REGISTER_REQUIRE_CAPTCHA:
            identifier: UUID
            userEntry: str

    form = (
        PasswordLessRegisterForm if app_settings.ALLOW_PASSWORDLESS_REGISTRATION else RegisterForm
    )

    @classmethod
    def resolve_mutation(cls, info, input_: RegisterInput) -> MutationNormalOutput:
        if app_settings.LOGIN_REQUIRE_CAPTCHA:
            check_res = check_captcha(input_)
            if check_res != Messages.CAPTCHA_VALID:
                return MutationNormalOutput(success=False, errors={"captcha": check_res})

        try:
            with transaction.atomic():
                f = cls.form(asdict(input_))
                if f.is_valid():
                    email = getattr(input_, "email", False)
                    UserStatus.clean_email(email)
                    user = f.save()
                    if email:
                        send_activation = app_settings.SEND_ACTIVATION_EMAIL is True
                        send_password_set = (
                            app_settings.ALLOW_PASSWORDLESS_REGISTRATION is True
                            and app_settings.SEND_PASSWORD_SET_EMAIL is True
                        )
                        if send_activation:
                            user.status.send_activation_email(info)

                        if send_password_set:
                            user.status.send_password_set_email(info)

                    user_registered.send(sender=cls, user=user)
                    return MutationNormalOutput(success=True)
                else:
                    return MutationNormalOutput(success=False, errors=f.errors.get_json_data())
        except EmailAlreadyInUse:
            return MutationNormalOutput(
                success=False,
                # if the email was set as a secondary email,
                # the RegisterForm will not catch it,
                # so we need to run UserStatus.clean_email(email)
                errors={UserModel.EMAIL_FIELD: Messages.EMAIL_IN_USE},
            )
        except SMTPException:
            return MutationNormalOutput(success=False, errors=Messages.EMAIL_FAIL)


class VerifyAccountMixin(BaseMixin):
    """
    Verify user account.

    Receive the token that was sent by email.
    If the token is valid, make the user verified
    by making the `user.status.verified` field true.
    """

    @strawberry.input
    class VerifyAccountInput:
        token: str

    @classmethod
    def resolve_mutation(cls, info: Info, input_: VerifyAccountInput) -> MutationNormalOutput:
        try:
            UserStatus.verify(input_.token)
            return MutationNormalOutput(success=True)
        except UserAlreadyVerified:
            return MutationNormalOutput(success=False, errors=Messages.ALREADY_VERIFIED)
        except SignatureExpired:
            return MutationNormalOutput(success=False, errors=Messages.EXPIRED_TOKEN)
        except (BadSignature, TokenScopeError):
            return MutationNormalOutput(success=False, errors=Messages.INVALID_TOKEN)


class VerifySecondaryEmailMixin(BaseMixin):
    """
    Verify user secondary email.

    Receive the token that was sent by email.
    User is already verified when using this mutation.

    If the token is valid, add the secondary email
    to `user.status.secondary_email` field.

    Note that until the secondary email is verified,
    it has not been saved anywhere beyond the token,
    so it can still be used to create a new account.
    After being verified, it will no longer be available.
    """

    @strawberry.input
    class VerifySecondaryEmailInput:
        token: str

    @classmethod
    def resolve_mutation(cls, _, input_: VerifySecondaryEmailInput) -> MutationNormalOutput:
        try:
            token = input_.token
            UserStatus.verify_secondary_email(token)
            return MutationNormalOutput(success=True)
        except EmailAlreadyInUse:
            # while the token was sent and the user haven't
            # verified, the email was free. If other account
            # was created with it, it is already in use.
            return MutationNormalOutput(success=False, errors=Messages.EMAIL_IN_USE)
        except SignatureExpired:
            return MutationNormalOutput(success=False, errors=Messages.EXPIRED_TOKEN)
        except (BadSignature, TokenScopeError):
            return MutationNormalOutput(success=False, errors=Messages.INVALID_TOKEN)


class ResendActivationEmailMixin(BaseMixin):
    """
    Sends activation email.

    It is called resend because theoretically
    the first activation email was sent when
    the user registered.

    If there is no user with the requested email,
    a successful response is returned.
    """

    @strawberry.input
    class ResendActivationEmailInput:
        email: str

    @classmethod
    def resolve_mutation(cls, info, input_: ResendActivationEmailInput) -> MutationNormalOutput:
        try:
            email = input_.email
            f = EmailForm({"email": email})
            if f.is_valid():
                user = get_user_by_email(email)
                user.status.resend_activation_email(info)
                return MutationNormalOutput(success=True)
            return MutationNormalOutput(success=False, errors=f.errors.get_json_data())
        except ObjectDoesNotExist:
            return MutationNormalOutput(success=True)  # even if user is not registered
        except SMTPException:
            return MutationNormalOutput(success=False, errors=Messages.EMAIL_FAIL)
        except UserAlreadyVerified:
            return MutationNormalOutput(success=False, errors={"email": Messages.ALREADY_VERIFIED})


class SendPasswordResetEmailMixin(BaseMixin):
    """
    Send password reset email.

    For non verified users, send an activation
    email instead.

    Accepts both primary and secondary email.

    If there is no user with the requested email,
    a successful response is returned.
    """

    @strawberry.input
    class SendPasswordResetEmailInput:
        email: str

    @classmethod
    def resolve_mutation(cls, info, input_: SendPasswordResetEmailInput) -> MutationNormalOutput:
        try:
            email = input_.email
            f = EmailForm({"email": email})
            if f.is_valid():
                user = get_user_by_email(email)
                user.status.send_password_reset_email(info, [email])
                return MutationNormalOutput(success=True)
            return MutationNormalOutput(success=False, errors=f.errors.get_json_data())
        except ObjectDoesNotExist:
            return MutationNormalOutput(success=True)  # even if user is not registered
        except SMTPException:
            return MutationNormalOutput(success=False, errors=Messages.EMAIL_FAIL)
        except UserNotVerified:
            user = get_user_by_email(input_.email)
            try:
                user.status.resend_activation_email(info)
                return MutationNormalOutput(
                    success=False,
                    errors={"email": Messages.NOT_VERIFIED_PASSWORD_RESET},
                )
            except SMTPException:
                return MutationNormalOutput(success=False, errors=Messages.EMAIL_FAIL)


class PasswordResetMixin(BaseMixin):
    """
    Change user password without old password.

    Receive the token that was sent by email.

    If token and new passwords are valid, update
    user password and in case of using refresh
    tokens, revoke all of them.

    Also, if user has not been verified yet, verify it.
    """

    @strawberry.input
    class PasswordResetInput:
        token: str
        new_password1: str
        new_password2: str

    form = SetPasswordForm

    @classmethod
    def resolve_mutation(cls, _, input_: PasswordResetInput) -> MutationNormalOutput:
        try:
            payload = get_payload_from_token(
                input_.token,
                TokenAction.PASSWORD_RESET,
                app_settings.EXPIRATION_PASSWORD_RESET_TOKEN,
            )
            user = UserModel._default_manager.get(**payload)

            f = cls.form(user, asdict(input_))
            if f.is_valid():
                revoke_user_refresh_token(user)
                user = f.save()
                if user.status.verified is False:
                    user.status.verified = True
                    user.status.save(update_fields=["verified"])
                    user_verified.send(sender=cls, user=user)

                return MutationNormalOutput(success=True)
            return MutationNormalOutput(success=False, errors=f.errors.get_json_data())
        except SignatureExpired:
            return MutationNormalOutput(success=False, errors=Messages.EXPIRED_TOKEN)
        except (BadSignature, TokenScopeError):
            return MutationNormalOutput(success=False, errors=Messages.INVALID_TOKEN)


class PasswordSetMixin(BaseMixin):
    """
    Set user password - for password-less registration

    Receive the token that was sent by email.

    If token and new passwords are valid, set
    user password and in case of using refresh
    tokens, revoke all of them.

    Also, if user has not been verified yet, verify it.
    """

    @strawberry.input
    class PasswordSetInput:
        token: str
        new_password1: str
        new_password2: str

    form = SetPasswordForm

    @classmethod
    def resolve_mutation(cls, _, input_: PasswordSetInput) -> MutationNormalOutput:
        try:
            token = input_.token
            payload = get_payload_from_token(
                token,
                TokenAction.PASSWORD_SET,
                app_settings.EXPIRATION_PASSWORD_SET_TOKEN,
            )
            user = UserModel._default_manager.get(**payload)
            f = cls.form(user, asdict(input_))
            if f.is_valid():
                # Check if user has already set a password
                if user.has_usable_password():
                    raise PasswordAlreadySetError
                revoke_user_refresh_token(user)
                user = f.save()

                if user.status.verified is False:
                    user.status.verified = True
                    user.status.save(update_fields=["verified"])

                return MutationNormalOutput(success=True)
            return MutationNormalOutput(success=False, errors=f.errors.get_json_data())
        except SignatureExpired:
            return MutationNormalOutput(success=False, errors=Messages.EXPIRED_TOKEN)
        except (BadSignature, TokenScopeError):
            return MutationNormalOutput(success=False, errors=Messages.INVALID_TOKEN)
        except PasswordAlreadySetError:
            return MutationNormalOutput(success=False, errors=Messages.PASSWORD_ALREADY_SET)


class ObtainJSONWebTokenMixin(BaseMixin):
    """
    Obtain JSON web token for given user.

    Allow to perform login with different fields,
    and secondary email if set. The fields are
    defined on settings.

    Not verified users can log in by default. This
    can be changes on settings.

    If user is archived, make it unarchived and
    return `unarchiving=True` on OutputBase.
    """

    @classmethod
    def resolve_mutation(cls, info, input_: ObtainJSONWebTokenInput) -> ObtainJSONWebTokenType:
        if app_settings.LOGIN_REQUIRE_CAPTCHA:
            check_res = check_captcha(input_)
            if check_res != Messages.CAPTCHA_VALID:
                return ObtainJSONWebTokenType(success=False, errors={"captcha": check_res})

        return ObtainJSONWebTokenType.authenticate(info, input_)


class ArchiveOrDeleteMixin(BaseMixin):
    @strawberry.input
    class ArchiveOrDeleteMixinInput:
        password: str

    directives = [
        IsVerified(),
    ]

    @classmethod
    def resolve_mutation(
        cls, info, input_: ArchiveOrDeleteMixinInput
    ) -> AuthOutput[MutationNormalOutput]:
        user = get_user(info)
        if error := confirm_password(user, input_):
            return AuthOutput(node=error)
        cls.resolve_action(user)
        return AuthOutput(node=MutationNormalOutput(success=True))


class ArchiveAccountMixin(ArchiveOrDeleteMixin):
    """
    Archive account and revoke refresh tokens.
    User must be verified and confirm password.
    """

    @classmethod
    def resolve_action(cls, user):
        UserStatus.archive(user)
        revoke_user_refresh_token(user=user)


class DeleteAccountMixin(ArchiveOrDeleteMixin):
    """
    Delete account permanently or make `user.is_active=False`.

    The behavior is defined on settings.
    Anyway user refresh tokens are revoked.

    User must be verified and confirm password.
    """

    @classmethod
    def resolve_action(cls, user):
        if app_settings.ALLOW_DELETE_ACCOUNT:
            revoke_user_refresh_token(user=user)
            user.delete()


class PasswordChangeMixin(BaseMixin):
    """
    Change account password when user knows the old password.

    A new token and refresh token are sent. User must be verified.
    """

    @strawberry.input
    class PasswordChangeInput:
        old_password: str
        new_password1: str
        new_password2: str

    form = PasswordChangeForm
    directives = [
        IsVerified(),
    ]

    @classmethod
    def resolve_mutation(cls, info: Info, input_: PasswordChangeInput) -> ObtainJSONWebTokenType:
        user = get_user(info)
        if error := confirm_password(user, input_):
            return ObtainJSONWebTokenType(**asdict(error))

        args = asdict(input_)
        f = cls.form(user, args)
        if f.is_valid():
            revoke_user_refresh_token(user)
            user = f.save()
            return ObtainJSONWebTokenType.from_user(info, user)
        else:
            return ObtainJSONWebTokenType(success=False, errors=f.errors.get_json_data())


class UpdateAccountMixin(BaseMixin):
    """
    Update user model fields, defined on settings.

    User must be verified.
    """

    @strawberry.input
    @inject_fields(app_settings.UPDATE_MUTATION_FIELDS)
    class UpdateAccountInput:
        ...

    form = UpdateAccountForm
    directives = [
        IsVerified(),
    ]

    @classmethod
    def resolve_mutation(cls, info, input_: UpdateAccountInput) -> AuthOutput[MutationNormalOutput]:
        user = get_user(info)
        f = cls.form(
            asdict(input_),
            instance=user,
        )
        if f.is_valid():
            f.save()
            return AuthOutput(node=MutationNormalOutput(success=True))
        else:
            return AuthOutput(
                node=MutationNormalOutput(success=False, errors=f.errors.get_json_data())
            )


class VerifyTokenMixin(BaseMixin):
    """
    ### Checks if a token is not expired and correct.
    *Note that this is not for refresh tokens.*
    """

    @classmethod
    def resolve_mutation(cls, _: Info, input_: VerifyTokenInput) -> VerifyTokenType:
        return VerifyTokenType.from_token(input_)


class RefreshTokenMixin(BaseMixin):
    """
    ### refreshToken to generate a new login token:
    *Use this only if `JWT_LONG_RUNNING_REFRESH_TOKEN` is True*

    using the refresh-token you already got during authorization, and
    obtain a brand-new token (and possibly a new refresh token if you revoked the previous).
    This is an alternative to log in when your token expired.
    """

    @strawberry.input(
        description="If `revoke_refresh_token` is true,"
        " revokes to refresh token an creates a new one."
    )
    class RefreshTokenInput:
        refresh_token: str
        revoke_refresh_token: bool = strawberry.field(
            description="revokes the previous refresh token, and will generate new one.",
            default=False,
        )

    @classmethod
    def resolve_mutation(cls, info, input_: RefreshTokenInput) -> ObtainJSONWebTokenType:
        try:
            res = RefreshToken.objects.get(token=input_.refresh_token)
        except RefreshToken.DoesNotExist:
            return ObtainJSONWebTokenType(success=False, errors=Messages.INVALID_TOKEN)
        user = res.user
        if res.is_expired_():
            return ObtainJSONWebTokenType(success=False, errors=Messages.EXPIRED_TOKEN)
        ret = ObtainJSONWebTokenType(
            success=True, token=TokenType.from_user(info, user), refresh_token=res
        )
        if input_.revoke_refresh_token:
            res.revoke()
            ret.refresh_token = RefreshToken.from_user(user)
        return ret


class RevokeTokenMixin(BaseMixin):
    """
    ### Suspends a refresh token.
    *token must exist to be revoked.*
    """

    @strawberry.input
    class RevokeTokenInput:
        refresh_token: str

    @classmethod
    def resolve_mutation(cls, _: Info, input_: RevokeTokenInput) -> RevokeRefreshTokenType:
        try:
            refresh_token = RefreshToken.objects.get(
                token=input_.refresh_token, revoked__isnull=True
            )
            refresh_token.revoke()
            return RevokeRefreshTokenType(success=True, refresh_token=refresh_token)
        except RefreshToken.DoesNotExist:
            return RevokeRefreshTokenType(success=False, errors=Messages.INVALID_TOKEN)


class SendSecondaryEmailActivationMixin(BaseMixin):
    """
    Send activation to secondary email.

    User must be verified and confirm password.
    """

    directives = [
        IsVerified(),
    ]

    @strawberry.input
    class SendSecondaryEmailActivationInput:
        password: str

    @classmethod
    def resolve_mutation(
        cls, info, input_: SendSecondaryEmailActivationInput
    ) -> MutationNormalOutput:
        user = get_user(info)
        if error := confirm_password(user, input_):
            return error
        try:
            email = input_.get("email")
            f = EmailForm({"email": email})
            if f.is_valid():
                user.status.send_secondary_email_activation(info, email)
                return MutationNormalOutput(success=True)
            return MutationNormalOutput(success=False, errors=f.errors.get_json_data())
        except EmailAlreadyInUse:
            # while the token was sent and the user haven't verified,
            # the email was free. If other account was created with if
            # it is already in use
            return MutationNormalOutput(success=False, errors={"email": Messages.EMAIL_IN_USE})
        except SMTPException:
            return MutationNormalOutput(success=False, errors=Messages.EMAIL_FAIL)


class SwapEmailsMixin(BaseMixin):
    """
    Swap between primary and secondary emails.

    Require password confirmation.
    """

    @strawberry.input
    class SwapEmailsInput:
        password: str

    @classmethod
    def resolve_mutation(cls, info: Info, input_: SwapEmailsInput) -> MutationNormalOutput:
        user = get_user(info)
        if not user.check_password(input_.password):
            return MutationNormalOutput(success=False, errors=Messages.INVALID_PASSWORD)
        if error := check_secondary_email(user):
            return error
        user.status.swap_emails()
        return MutationNormalOutput(success=True)


class RemoveSecondaryEmailMixin(BaseMixin):
    """
    Remove user secondary email.

    Require password confirmation.
    """

    @strawberry.input
    class RemoveSecondaryEmailInput:
        password: str

    @classmethod
    def resolve_mutation(
        cls, info: Info, input_: RemoveSecondaryEmailInput
    ) -> MutationNormalOutput:
        user = get_user(info)

        if error := confirm_password(user, input_):
            return error

        if error := check_secondary_email(user):
            return error

        user.status.remove_secondary_email()
        return MutationNormalOutput(success=True)
