import secrets
from datetime import timedelta
import pyotp
from django.contrib.auth.hashers import make_password, check_password
from django.db import transaction
from django.utils import timezone
from rest_framework.exceptions import AuthenticationFailed, ValidationError
from .models import User
from .security_models import AccountSecurity, AccountSession
from .security_crypto import encrypt, decrypt
from . import security_challenges as challenges


def verify_factor(user, code=''):
    # Cache accounting deliberately survives an enclosing challenge rollback.
    from django.core.cache import cache
    bucket=f'account-mfa-attempts:{user.pk}'
    if AccountSecurity.objects.filter(user=user,totp_enabled=True).exists():
        cache.add(bucket,0,timeout=600)
        try:
            attempts=cache.incr(bucket)
        except ValueError:
            cache.add(bucket,0,timeout=600)
            attempts=cache.incr(bucket)
        if attempts>5:
            raise AuthenticationFailed('Second factor temporarily locked. Try later.')
    error=None
    with transaction.atomic():
        security=AccountSecurity.objects.select_for_update().filter(user=user).first()
        if security is None or not security.totp_enabled:
            return
        now=timezone.now()
        if security.locked_until and security.locked_until>now:
            raise AuthenticationFailed('Second factor temporarily locked. Try later.')
        if security.locked_until:
            security.failed_attempts=0;security.locked_until=None
        normalized=str(code or '').replace('-','').replace(' ','')
        counter=int(now.timestamp())//30
        matched=None
        if len(normalized)==6 and normalized.isascii() and normalized.isdigit():
            totp=pyotp.TOTP(decrypt(security.totp_secret))
            for candidate in [counter-1,counter,counter+1]:
                if candidate>security.last_totp_counter and secrets.compare_digest(totp.at(candidate*30),normalized):
                    matched=candidate;break
        recovery=next((i for i,h in enumerate(security.recovery_hashes) if len(normalized)==20 and check_password(normalized,h)),None)
        if matched is None and recovery is None:
            security.failed_attempts+=1
            if security.failed_attempts>=5:
                security.locked_until=now+timedelta(minutes=10)
            error=AuthenticationFailed('A valid second-factor or unused recovery code is required.',code='mfa_required')
        else:
            if matched is not None:
                security.last_totp_counter=matched
            if recovery is not None:
                security.recovery_hashes=[h for i,h in enumerate(security.recovery_hashes) if i!=recovery]
            security.failed_attempts=0;security.locked_until=None
        security.save()
    if error:
        raise error
    cache.set(bucket,0,timeout=600)


def step_up(user, password, otp=''):
    fresh=User.objects.get(pk=user.pk)
    if not fresh.is_active or not fresh.is_email_verified or not fresh.check_password(password or ''):
        raise ValidationError('Current password is incorrect.')
    try:
        verify_factor(fresh,otp)
    except AuthenticationFailed as exc:
        raise ValidationError(str(exc.detail)) from exc
    return fresh


def begin(user):
    if AccountSecurity.objects.filter(user=user,totp_enabled=True).exists():
        raise ValidationError('Disable the existing authenticator before replacing it.')
    secret=pyotp.random_base32()
    row=challenges.create('TOTP_ENROLL',{'secret':encrypt(secret)},user)
    return {'challenge_id':str(row.pk),'secret':secret,
            'uri':pyotp.TOTP(secret).provisioning_uri(name=user.email,issuer_name='FTG')}


def finish(user, challenge_id, code, current_session=None):
    def operation(row):
        User.objects.select_for_update().get(pk=user.pk)
        security,_=AccountSecurity.objects.select_for_update().get_or_create(user=user)
        if security.totp_enabled:
            raise ValidationError('Authenticator is already enabled.')
        secret=decrypt(row.payload['secret']);counter=int(timezone.now().timestamp())//30
        matched=next((c for c in [counter-1,counter,counter+1] if secrets.compare_digest(pyotp.TOTP(secret).at(c*30),str(code))),None)
        if matched is None:
            raise ValidationError('Invalid authenticator code.')
        codes=[secrets.token_hex(10) for _ in range(8)]
        security.totp_secret=encrypt(secret);security.totp_enabled=True;security.last_totp_counter=matched
        security.recovery_hashes=[make_password(c) for c in codes];security.failed_attempts=0;security.locked_until=None
        security.save()
        AccountSession.objects.filter(user=user,revoked_at__isnull=True).exclude(pk=current_session).update(revoked_at=timezone.now())
        from .security_models import AuthChallenge
        AuthChallenge.objects.filter(user=user,consumed_at__isnull=True).exclude(pk=row.pk).update(consumed_at=timezone.now())
        return {'enabled':True,'recovery_codes':codes}
    return challenges.consume(challenge_id,'TOTP_ENROLL',operation,user)
