from datetime import timedelta

from django.conf import settings
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.utils import timezone

from .auth_codes import (
    CODE_SWEEP_SECONDS,
    CODE_TTL_SECONDS,
    HANDOFF_TTL_SECONDS,
    RESET_TTL_SECONDS,
    VERIFY_TTL_SECONDS,
    generate_link_token,
    hash_code,
    hash_token,
)
from .managers import CustomUserManager


class CustomUser(AbstractUser):
    """
    Identity is an EMAIL ADDRESS.

    The Lifey app asks for an email and has no concept of a username, so
    carrying both would mean two identity concepts and a `createsuperuser`
    prompt that disagrees with every sign-in form. `username` is dropped
    rather than left nullable: a column nothing reads is a column that
    eventually gets read.

    Never use Django's built-in User directly — switching after the first
    migration requires a full database reset.
    """

    username = None
    email = models.EmailField('email address', unique=True)

    # WHAT AN ACCOUNT IS, AND WHAT A LIFEY ACCOUNT IS, ARE TWO DIFFERENT
    # FACTS. An address can exist here because somebody signed in on the
    # website, joined the waitlist or subscribed to the newsletter — none of
    # which is a decision to use Lifey. `lifey_plan` is that decision, and it
    # is empty until the user picks a plan on the pricing section. The website
    # sends a user with no plan to pricing instead of into the app.
    PLAN_BETA = 'beta'
    PLAN_CHOICES = [(PLAN_BETA, 'Free beta')]

    lifey_plan = models.CharField(max_length=20, choices=PLAN_CHOICES, blank=True, default='')
    lifey_activated_at = models.DateTimeField(null=True, blank=True)

    # Set when the account was created by Google or Microsoft rather than by a
    # password form. It is a display fact, not a permission: the password half
    # of the account is closed by `set_unusable_password()`, not by this.
    oauth_provider = models.CharField(max_length=20, blank=True, default='')

    # WHAT THIS GATES, AND WHAT IT DOES NOT. It gates activation: an account
    # cannot take a plan until the address behind it answers. It does NOT gate
    # sign-in, because an account locked out by an email that went to spam is
    # a support ticket, and the session is not the thing worth protecting here.
    # A Google or Microsoft account arrives verified, because the provider has
    # already proved the address and asking again would be theatre.
    email_verified = models.BooleanField(default=False)

    USERNAME_FIELD = 'email'
    REQUIRED_FIELDS = []

    objects = CustomUserManager()

    def __str__(self):
        return self.email

    @property
    def lifey_activated(self):
        return bool(self.lifey_plan)

    def activate_lifey(self, plan):
        """
        Idempotent. Re-picking a plan must not re-date the activation, because
        `lifey_activated_at` is what "member since" reads.
        """
        if self.lifey_plan == plan:
            return self

        self.lifey_plan = plan
        if self.lifey_activated_at is None:
            self.lifey_activated_at = timezone.now()
        self.save(update_fields=['lifey_plan', 'lifey_activated_at'])
        return self


class DesktopAuthCodeManager(models.Manager):

    def sweep(self):
        """
        Delete codes old enough that no legitimate exchange could still want
        them. Called on every mint, which bounds the table without a cron job.
        """
        cutoff = timezone.now() - timedelta(seconds=CODE_SWEEP_SECONDS)
        self.filter(created_at__lt=cutoff).delete()

    def claim(self, code):
        """
        Find a live, unused code and return it, or None.

        `used_at` and the TTL are both checked HERE rather than by the caller,
        so there is one definition of "this code is still good". The caller
        still has to verify the PKCE challenge and burn the row.
        """
        cutoff = timezone.now() - timedelta(seconds=CODE_TTL_SECONDS)
        return self.filter(
            code_hash=hash_code(code),
            used_at__isnull=True,
            created_at__gte=cutoff,
        ).select_related('user').first()


class DesktopAuthCode(models.Model):
    """
    A one-time authorization code, bound to the PKCE challenge that minted it.

    A REAL TABLE, NOT A CACHE ENTRY. There is no Redis on this box, and the
    production cache is the database anyway — so a row with an explicit
    lifetime is both simpler and more inspectable than a key with a TTL.

    The code itself is never stored. `code_hash` is what is written, because a
    row here is a login for sixty seconds and a database read should not be one.
    """

    code_hash = models.CharField(max_length=64, unique=True)
    state = models.CharField(max_length=128)
    code_challenge = models.CharField(max_length=128)
    user = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name='desktop_auth_codes',
    )
    created_at = models.DateTimeField(auto_now_add=True)
    used_at = models.DateTimeField(null=True, blank=True)

    objects = DesktopAuthCodeManager()

    class Meta:
        indexes = [models.Index(fields=['created_at'])]

    def __str__(self):
        return f'{self.user_id} @ {self.created_at:%Y-%m-%d %H:%M:%S}'

    def burn(self):
        """
        Mark the code used. CALLED ON FAILURE AS WELL AS ON SUCCESS — a code
        whose verifier did not match has been presented by somebody who does
        not hold the verifier, and giving them a second guess is the one thing
        single-use is for.
        """
        self.used_at = timezone.now()
        self.save(update_fields=['used_at'])


class EmailTokenManager(models.Manager):

    def issue(self, user, purpose):
        """
        Mint a token and invalidate every older live one for the same purpose.

        INVALIDATING THE OLD ONES IS THE POINT. Somebody who presses "send it
        again" three times should end up with one working link, not three —
        and a reset link that stays alive after a newer one was requested is a
        link an attacker who already read one email keeps forever.
        """
        self.filter(user=user, purpose=purpose, used_at__isnull=True).update(used_at=timezone.now())

        token = generate_link_token()
        self.create(token_hash=hash_token(token), user=user, purpose=purpose)
        return token

    def claim(self, token, purpose):
        """Find a live, unused token for this purpose and return it, or None."""
        if not token:
            return None

        ttl = VERIFY_TTL_SECONDS if purpose == EmailToken.PURPOSE_VERIFY else RESET_TTL_SECONDS
        cutoff = timezone.now() - timedelta(seconds=ttl)

        return self.filter(
            token_hash=hash_token(token),
            purpose=purpose,
            used_at__isnull=True,
            created_at__gte=cutoff,
        ).select_related('user').first()


class EmailToken(models.Model):
    """
    A single-use link sent to an email address: confirm it, or reset the
    password behind it.

    One table for two purposes rather than two tables, because the row is the
    same row — what differs is the lifetime and the endpoint that claims it,
    and `purpose` is checked on claim so a confirmation link can never be
    presented as a password reset.

    The token is stored hashed, for the reason `DesktopAuthCode` gives: a reset
    link is a login, and a database read must not be one.
    """

    PURPOSE_VERIFY = 'verify'
    PURPOSE_RESET = 'reset'
    PURPOSE_CHOICES = [
        (PURPOSE_VERIFY, 'Confirm email address'),
        (PURPOSE_RESET, 'Reset password'),
    ]

    token_hash = models.CharField(max_length=64, unique=True)
    purpose = models.CharField(max_length=10, choices=PURPOSE_CHOICES)
    user = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name='email_tokens',
    )
    created_at = models.DateTimeField(auto_now_add=True)
    used_at = models.DateTimeField(null=True, blank=True)

    objects = EmailTokenManager()

    class Meta:
        indexes = [models.Index(fields=['purpose', 'created_at'])]

    def __str__(self):
        return f'{self.purpose} for {self.user_id} @ {self.created_at:%Y-%m-%d %H:%M:%S}'

    def burn(self):
        self.used_at = timezone.now()
        self.save(update_fields=['used_at'])


class OAuthHandoffCodeManager(models.Manager):
    """Same two operations as `DesktopAuthCodeManager`, on a shorter clock."""

    def sweep(self):
        cutoff = timezone.now() - timedelta(seconds=CODE_SWEEP_SECONDS)
        self.filter(created_at__lt=cutoff).delete()

    def claim(self, code):
        cutoff = timezone.now() - timedelta(seconds=HANDOFF_TTL_SECONDS)
        return self.filter(
            code_hash=hash_code(code),
            used_at__isnull=True,
            created_at__gte=cutoff,
        ).select_related('user').first()


class OAuthHandoffCode(models.Model):
    """
    What the Google/Microsoft callback hands back to the WEBSITE.

    NO TOKEN IS EVER PUT IN A REDIRECT. The callback has to send the browser
    somewhere, and anything in that URL reaches browser history, the referer
    of the next request and any extension reading the address bar — so what
    crosses is a 60-second single-use code that the page immediately trades
    for tokens over POST.

    Separate from `DesktopAuthCode` rather than a nullable column on it: that
    row is bound to a PKCE challenge and this one is bound to nothing, and a
    table where the security property is "sometimes" is a table that gets
    claimed with the check skipped.
    """

    code_hash = models.CharField(max_length=64, unique=True)
    user = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name='oauth_handoff_codes',
    )
    provider = models.CharField(max_length=20)
    created_at = models.DateTimeField(auto_now_add=True)
    used_at = models.DateTimeField(null=True, blank=True)

    objects = OAuthHandoffCodeManager()

    class Meta:
        indexes = [models.Index(fields=['created_at'])]

    def __str__(self):
        return f'{self.user_id} via {self.provider} @ {self.created_at:%Y-%m-%d %H:%M:%S}'

    def burn(self):
        self.used_at = timezone.now()
        self.save(update_fields=['used_at'])
