"""
The JSON half of authentication — what the desktop app calls.

The HTML half (the browser sign-in page that mints the code) is in `views.py`.
"""

import logging

from django.contrib.auth import authenticate
from drf_spectacular.utils import extend_schema
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.generics import RetrieveUpdateAPIView
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
from rest_framework_simplejwt.token_blacklist.models import BlacklistedToken, OutstandingToken
from rest_framework_simplejwt.tokens import RefreshToken
from rest_framework_simplejwt.views import TokenRefreshView

from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError as DjangoValidationError

from .auth_codes import generate_code, generate_handoff_code, hash_code, verify_challenge
from .cookies import attach_refresh, clear_refresh, read_refresh
from .emails import send_in_background
from .forms import clear_failures, is_locked_out, record_failure
from .models import CustomUser, DesktopAuthCode, EmailToken, OAuthHandoffCode
from .oauth import PROVIDERS, is_configured
from .serializers import (
    ActivateSerializer,
    DesktopAuthorizeSerializer,
    DesktopTokenSerializer,
    EmailTokenSerializer,
    LoginSerializer,
    LogoutSerializer,
    MeSerializer,
    OAuthExchangeSerializer,
    PasswordResetConfirmSerializer,
    PasswordResetRequestSerializer,
    RegisterSerializer,
)

logger = logging.getLogger(__name__)

# ONE MESSAGE FOR EVERY FAILURE. Unknown code, expired code, already-used code
# and wrong verifier are four different facts, and telling them apart tells an
# attacker which half of the exchange they got right.
INVALID_CODE = {'detail': 'That code is not valid. Start signing in again.'}

# The authorize endpoint's refusal. It discloses nothing an attacker does not
# already hold — the caller wrote the body it just sent — so one message
# covers a missing state, a missing challenge and a `plain` method alike.
INVALID_REQUEST = {'detail': 'This sign-in request is incomplete. Press Sign in again in Lifey.'}

# Same rule as the browser sign-in form (users/forms.py): a wrong password and
# an unknown address must read identically, or the endpoint becomes a way to
# ask whether somebody has an account.
INVALID_CREDENTIALS = {'detail': 'Email or password is incorrect.'}
LOCKED_OUT = {'detail': 'Too many attempts. Try again in 15 minutes.'}

# Same rule again for email links: expired, already used, unknown and wrong
# purpose are four facts and one message.
INVALID_LINK = {'detail': 'That link is no longer valid. Ask for a new one.'}

# What a password reset request answers, always — whether the address has an
# account, has none, or signs in with Google.
RESET_SENT = 'If that address has a Lifey account with a password, a reset link is on its way.'


def _issue(user):
    refresh = RefreshToken.for_user(user)
    return {'access': str(refresh.access_token), 'refresh': str(refresh)}


@extend_schema(
    summary='Exchange a one-time authorization code for tokens.',
    request=DesktopTokenSerializer,
    responses={200: {'type': 'object', 'properties': {
        'access': {'type': 'string'},
        'refresh': {'type': 'string'},
    }}},
)
@api_view(['POST'])
@permission_classes([AllowAny])
def desktop_token(request):
    """
    `{code, code_verifier}` -> `{access, refresh}`. Single use.

    Both entry points land here: the deep link, and the paste-the-code
    fallback. They share one code path deliberately — the fallback is the only
    way to sign in on a Linux AppImage, so it cannot be the path that gets
    less testing.
    """
    body = DesktopTokenSerializer(data=request.data)
    if not body.is_valid():
        return Response(INVALID_CODE, status=status.HTTP_400_BAD_REQUEST)

    entry = DesktopAuthCode.objects.claim(body.validated_data['code'])
    if entry is None:
        return Response(INVALID_CODE, status=status.HTTP_400_BAD_REQUEST)

    # Burn BEFORE deciding, so a wrong verifier costs the attempt either way.
    entry.burn()

    if not verify_challenge(body.validated_data['code_verifier'], entry.code_challenge):
        logger.warning('Desktop auth: verifier did not match challenge (user %s)', entry.user_id)
        return Response(INVALID_CODE, status=status.HTTP_400_BAD_REQUEST)

    return Response(_issue(entry.user))


@extend_schema(
    summary='Mint a desktop authorization code for the signed-in user.',
    request=DesktopAuthorizeSerializer,
    responses={200: {'type': 'object', 'properties': {'code': {'type': 'string'}}}},
)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def desktop_authorize(request):
    """
    `{state, code_challenge, code_challenge_method}` -> `{code}`.

    The same mint `views.py::_mint` does, for a caller that holds a JWT
    instead of a session cookie. It exists so lifey.planysoft.com can be the
    product's only sign-in surface: that page signs the user in over
    `POST /api/auth/login/` or the OAuth handoff, and then needs a way to hand
    the desktop app back a code. It has no session here, so it cannot reach
    the browser flow's `_mint`.

    ONE PROPERTY IS WEAKER HERE THAN IN THE BROWSER FLOW, and it is the reason
    `_remember_request` keeps the state and the challenge in the session. On
    this path they round-trip through a static page, so whatever renders that
    page can alter them. It is survivable because the code is still worthless
    without the verifier, which never leaves the app's main process: swapping
    the challenge only helps somebody who is already serving the user a page
    they control, and that person has already won. Do not read this as
    permission to round-trip the verifier.

    NO REDIRECT URI IS ACCEPTED, for the reason `_remember_request` gives.
    `CALLBACK_URL` stays server-side and the page hardcodes the same value.
    """
    body = DesktopAuthorizeSerializer(data=request.data)
    if not body.is_valid():
        return Response(INVALID_REQUEST, status=status.HTTP_400_BAD_REQUEST)

    DesktopAuthCode.objects.sweep()

    code = generate_code()
    DesktopAuthCode.objects.create(
        code_hash=hash_code(code),
        state=body.validated_data['state'],
        code_challenge=body.validated_data['code_challenge'],
        user=request.user,
    )

    # The plaintext is returned ONCE and is never stored. Sixty seconds from
    # here the row is dead, which is why the page asks for it at the moment it
    # shows the hand-back screen rather than at sign-in.
    return Response({'code': code})


@extend_schema(
    summary='Mint a PWA handoff code for the signed-in user.',
    request=None,
    responses={200: {'type': 'object', 'properties': {'code': {'type': 'string'}}}},
)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def pwa_handoff(request):
    """
    `{}` -> `{code}`. The website's "Continue in the browser" button.

    Same table and the same redeem endpoint (`oauth_exchange`) as the Google
    and Microsoft callback in `oauth_views.py` — the only thing that differs
    is who mints it. This is for a caller ALREADY signed in on
    lifey.planysoft.com, by password or by a provider, who wants to open
    `mylifey.planysoft.com/app/` without doing either again. `desktop_authorize`
    above is the PKCE-bearing sibling of this for the desktop app; a browser
    tab cannot hold a verifier across the origin hop
    (docs/pwa-boundary.md §2), which is the whole reason this one carries
    none — the code is the credential, same as the social handoff.
    """
    OAuthHandoffCode.objects.sweep()

    code = generate_handoff_code()
    OAuthHandoffCode.objects.create(
        code_hash=hash_code(code),
        user=request.user,
        provider='handoff',
    )

    return Response({'code': code})


@extend_schema(
    summary='Revoke a refresh token.',
    request=LogoutSerializer,
    responses={205: None},
)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def logout(request):
    """
    Blacklist the refresh token so signing out on one machine does not leave a
    thirty-day credential alive on it.

    A token that is already invalid still returns success: the caller is trying
    to end a session, and reporting failure would leave the app unable to
    finish signing out of a session that is already gone.

    THE TOKEN MAY BE IN A COOKIE. A PWA has no copy of its own refresh token to
    put in the body — that is the whole point of the cookie — so a body-only
    logout would blacklist nothing and leave a thirty-day credential alive on a
    shared machine. `read_refresh` covers both callers; the cookie is cleared
    either way, since a stale one must not outlive the session it names.
    """
    token = read_refresh(request)
    if token:
        try:
            RefreshToken(token).blacklist()
        except TokenError:
            pass

    return clear_refresh(Response(status=status.HTTP_205_RESET_CONTENT))


@extend_schema(
    summary='Sign in with an email and password.',
    request=LoginSerializer,
    responses={200: {'type': 'object', 'properties': {
        'access': {'type': 'string'},
        'refresh': {'type': 'string'},
    }}},
)
@api_view(['POST'])
@permission_classes([AllowAny])
def login(request):
    """
    `{email, password}` -> `{access, refresh}`.

    The website's JSON login, as opposed to the desktop app's browser+PKCE
    flow in `views.py` — a plain fetch caller has no way to open a browser
    tab and receive a deep link back, so it needs a direct exchange instead.
    Shares its lockout counter with the browser form (`users/forms.py`), keyed
    on the email being attacked rather than the caller's IP, which an
    attacker rotates for free.
    """
    body = LoginSerializer(data=request.data)
    if not body.is_valid():
        return Response(INVALID_CREDENTIALS, status=status.HTTP_400_BAD_REQUEST)

    email = body.validated_data['email'].strip().lower()
    password = body.validated_data['password']

    if is_locked_out(email):
        return Response(LOCKED_OUT, status=status.HTTP_429_TOO_MANY_REQUESTS)

    user = authenticate(request, username=email, password=password)

    if user is None:
        record_failure(email)
        return Response(INVALID_CREDENTIALS, status=status.HTTP_400_BAD_REQUEST)

    if not user.is_active:
        return Response({'detail': 'This account is not active.'}, status=status.HTTP_400_BAD_REQUEST)

    clear_failures(email)

    # `user` rides along so the website can decide between the app and the
    # pricing section without a second round trip on its slowest screen. The
    # desktop client reads `access` and `refresh` and ignores the rest.
    payload = _issue(user)
    payload['user'] = MeSerializer(user).data
    return attach_refresh(Response(payload), request)


@extend_schema(
    summary='Create an account and sign in.',
    request=RegisterSerializer,
    responses={201: {'type': 'object', 'properties': {
        'access': {'type': 'string'},
        'refresh': {'type': 'string'},
        'user': {'type': 'object'},
    }}},
)
@api_view(['POST'])
@permission_classes([AllowAny])
def register(request):
    """
    `{email, password}` -> `{access, refresh, user}`.

    Signs the new account in rather than returning 201 and nothing: the user
    has just proved the password twice over, and a sign-up that ends at a
    sign-in form is a sign-up that loses people at the last step.

    THE NEW USER HAS NO PLAN. `user.lifey_activated` is false here and the
    website reads it to open pricing instead of the app.
    """
    body = RegisterSerializer(data=request.data)
    body.is_valid(raise_exception=True)

    user = body.save()
    logger.info('Account registered via the website (%s)', user.pk)

    _send_verification(user)

    payload = _issue(user)
    payload['user'] = MeSerializer(user).data
    return attach_refresh(Response(payload, status=status.HTTP_201_CREATED), request)


@extend_schema(
    summary='Activate this account on a Lifey plan.',
    request=ActivateSerializer,
    responses={200: MeSerializer},
)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def activate(request):
    """
    `{plan}` -> the user. Idempotent, and the only writer of `lifey_plan`.

    A separate step from registration because they are separate decisions: an
    address can reach this database from the waitlist or the newsletter, and
    neither of those is somebody saying they want to use the app.

    THIS IS WHERE EMAIL VERIFICATION IS ENFORCED, and it is the only place.
    Sign-in is deliberately not gated: an account shut out by a confirmation
    mail that went to spam is a support ticket, while an unverified account
    with no plan costs nothing and can still be recovered by the user.
    """
    body = ActivateSerializer(data=request.data)
    body.is_valid(raise_exception=True)

    if not request.user.email_verified:
        return Response(
            {'detail': 'Confirm your email address first. Check your inbox for the link.',
             'code': 'email_not_verified'},
            status=status.HTTP_403_FORBIDDEN,
        )

    user = request.user.activate_lifey(body.validated_data['plan'])
    return Response(MeSerializer(user).data)


def _send_verification(user):
    """Mint a confirmation token and post it. Older live ones are invalidated."""
    token = EmailToken.objects.issue(user, EmailToken.PURPOSE_VERIFY)
    send_in_background(user, EmailToken.PURPOSE_VERIFY, token)


@extend_schema(
    summary='Send the email confirmation link again.',
    request=None,
    responses={200: {'type': 'object', 'properties': {'detail': {'type': 'string'}}}},
)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def verify_email_request(request):
    """
    No body: the address is the account's, like the waitlist join.

    Sending to an already-confirmed account is a success with no email, not an
    error. The caller wanted the address confirmed; it is.
    """
    if request.user.email_verified:
        return Response({'detail': 'That address is already confirmed.'})

    _send_verification(request.user)
    return Response({'detail': 'Confirmation link sent. Check your inbox.'})


@extend_schema(
    summary='Confirm an email address with the link token.',
    request=EmailTokenSerializer,
    responses={200: {'type': 'object', 'properties': {'detail': {'type': 'string'}}}},
)
@api_view(['POST'])
@permission_classes([AllowAny])
def verify_email_confirm(request):
    """
    `{token}` -> confirmed. Single use, 24 hours.

    ALLOWED WITHOUT A SESSION on purpose. Mail is read on the phone and the
    account was created on the laptop, and requiring the link to open in the
    signed-in browser would break that every time. The token is the proof.
    """
    body = EmailTokenSerializer(data=request.data)
    if not body.is_valid():
        return Response(INVALID_LINK, status=status.HTTP_400_BAD_REQUEST)

    entry = EmailToken.objects.claim(body.validated_data['token'], EmailToken.PURPOSE_VERIFY)
    if entry is None:
        return Response(INVALID_LINK, status=status.HTTP_400_BAD_REQUEST)

    entry.burn()

    if not entry.user.email_verified:
        entry.user.email_verified = True
        entry.user.save(update_fields=['email_verified'])
        logger.info('Email confirmed (user %s)', entry.user_id)

    return Response({'detail': 'Your email address is confirmed.'})


@extend_schema(
    summary='Ask for a password reset link.',
    request=PasswordResetRequestSerializer,
    responses={200: {'type': 'object', 'properties': {'detail': {'type': 'string'}}}},
)
@api_view(['POST'])
@permission_classes([AllowAny])
def password_reset_request(request):
    """
    `{email}` -> the same answer whatever happens.

    UNKNOWN ADDRESS, KNOWN ADDRESS AND GOOGLE-ONLY ACCOUNT ALL READ ALIKE.
    Three answers would turn this endpoint into a way to ask who has an
    account, which is the enumeration the sign-in form already refuses to
    give away.
    """
    body = PasswordResetRequestSerializer(data=request.data)

    # Even a malformed address gets the neutral answer. A 400 on
    # "not an email" is fine, but a 400 on "no such user" is the leak.
    if body.is_valid():
        email = body.validated_data['email'].strip().lower()
        user = CustomUser.objects.filter(email=email, is_active=True).first()

        if user is not None and user.has_usable_password():
            token = EmailToken.objects.issue(user, EmailToken.PURPOSE_RESET)
            send_in_background(user, EmailToken.PURPOSE_RESET, token)
            logger.info('Password reset requested (user %s)', user.pk)

    return Response({'detail': RESET_SENT})


@extend_schema(
    summary='Set a new password with the reset link token.',
    request=PasswordResetConfirmSerializer,
    responses={200: {'type': 'object', 'properties': {'detail': {'type': 'string'}}}},
)
@api_view(['POST'])
@permission_classes([AllowAny])
def password_reset_confirm(request):
    """
    `{token, password}` -> the password is changed. Single use, one hour.

    Three side effects beyond the password, each of which would be a bug if it
    were missing:

    - **The address is marked confirmed.** Reading that email proved it.
    - **The lockout counter is cleared**, so somebody who forgot their password
      ten times is not locked out of the password they just set.
    - **Every refresh token is blacklisted.** A reset is what somebody does
      when they think another person has their password, and leaving that
      person's thirty-day session alive makes the reset decorative.
    """
    body = PasswordResetConfirmSerializer(data=request.data)
    if not body.is_valid():
        # A password complaint has to survive, or the form cannot say what was
        # wrong with it. Everything else collapses to the one link message.
        if 'password' in body.errors:
            return Response({'password': body.errors['password']}, status=status.HTTP_400_BAD_REQUEST)
        return Response(INVALID_LINK, status=status.HTTP_400_BAD_REQUEST)

    entry = EmailToken.objects.claim(body.validated_data['token'], EmailToken.PURPOSE_RESET)
    if entry is None:
        return Response(INVALID_LINK, status=status.HTTP_400_BAD_REQUEST)

    user = entry.user
    password = body.validated_data['password']

    try:
        validate_password(password, user=user)
    except DjangoValidationError as error:
        # NOT burnt: the link is still the only way in and the user has only
        # chosen a password the rules refuse. Burning here would send them
        # back to the inbox for a typo.
        return Response({'password': list(error.messages)}, status=status.HTTP_400_BAD_REQUEST)

    entry.burn()

    user.set_password(password)
    user.email_verified = True
    user.save(update_fields=['password', 'email_verified'])

    clear_failures(user.email)
    _revoke_all_sessions(user)

    logger.info('Password reset completed (user %s)', user.pk)
    return Response({'detail': 'Your password is set. Sign in with it now.'})


def _revoke_all_sessions(user):
    """Blacklist every outstanding refresh token this user holds."""
    for outstanding in OutstandingToken.objects.filter(user=user):
        BlacklistedToken.objects.get_or_create(token=outstanding)


@extend_schema(
    summary='List the social sign-in providers that are configured.',
    responses={200: {'type': 'object', 'properties': {
        'providers': {'type': 'array', 'items': {'type': 'object'}},
    }}},
)
@api_view(['GET'])
@permission_classes([AllowAny])
def oauth_providers(request):
    """
    What the sign-in page reads to decide whether to enable the Google and
    Microsoft buttons. A button that leads to `invalid_client` is worse than a
    button that says the provider is not connected yet.
    """
    return Response({'providers': [
        {'id': key, 'label': config['label'], 'available': is_configured(key)}
        for key, config in PROVIDERS.items()
    ]})


@extend_schema(
    summary='Exchange a social sign-in handoff code for tokens.',
    request=OAuthExchangeSerializer,
    responses={200: {'type': 'object', 'properties': {
        'access': {'type': 'string'},
        'refresh': {'type': 'string'},
        'user': {'type': 'object'},
    }}},
)
@api_view(['POST'])
@permission_classes([AllowAny])
def oauth_exchange(request):
    """
    `{code}` -> `{access, refresh, user}`. Single use, sixty seconds.

    The second half of `oauth_views.oauth_callback`. Same rule as the desktop
    exchange: unknown, expired and already-used all answer identically, and the
    row is burnt before the answer is decided.
    """
    body = OAuthExchangeSerializer(data=request.data)
    if not body.is_valid():
        return Response(INVALID_CODE, status=status.HTTP_400_BAD_REQUEST)

    entry = OAuthHandoffCode.objects.claim(body.validated_data['code'])
    if entry is None:
        return Response(INVALID_CODE, status=status.HTTP_400_BAD_REQUEST)

    entry.burn()

    if not entry.user.is_active:
        return Response({'detail': 'This account is not active.'}, status=status.HTTP_400_BAD_REQUEST)

    payload = _issue(entry.user)
    payload['user'] = MeSerializer(entry.user).data
    return attach_refresh(Response(payload), request)


class MeView(RetrieveUpdateAPIView):
    """`GET`/`PATCH /api/auth/me/` — the signed-in user, and only ever them."""

    serializer_class = MeSerializer
    permission_classes = [IsAuthenticated]

    def get_object(self):
        return self.request.user


class CookieTokenRefreshView(TokenRefreshView):
    """
    `/api/token/refresh/`, taught to read the cookie when there is no body.

    A PWA cannot send `{refresh: ...}` — it has never seen the string. The
    cookie is httpOnly precisely so the page cannot read it, which means the
    only thing that can present it is the browser, automatically, and the only
    thing that can find it is the server.

    IT STILL ANSWERS THE DESKTOP CLIENT UNCHANGED. A request carrying a body
    never reaches the cookie branch, the response is the same `{access}` it has
    always been, and `tests/test_refresh_contract.py` runs against this class
    now rather than against the stock one.

    THE ROTATION RULE IS UNCHANGED AND STILL MATTERS. With
    `ROTATE_REFRESH_TOKENS` off there is no new token to write back, so a
    successful refresh does not touch the cookie at all — the one already in
    the browser stays valid for its full thirty days. If rotation is ever turned
    on, this view is where the replacement has to be written back into the
    cookie, and `attach_refresh` is what would do it.
    """

    def post(self, request, *args, **kwargs):
        if not request.data.get('refresh'):
            cookie = read_refresh(request)
            if cookie:
                """
                Copied into a mutable dict rather than mutated in place:
                `request.data` on a JSON request is the parsed body and DRF
                hands out the same object to anything that reads it afterwards,
                including the serializer's own error rendering.
                """
                data = {**request.data, 'refresh': cookie}
                serializer = self.get_serializer(data=data)
                try:
                    serializer.is_valid(raise_exception=True)
                except TokenError as error:
                    raise InvalidToken(error.args[0]) from error
                return Response(serializer.validated_data, status=status.HTTP_200_OK)

        return super().post(request, *args, **kwargs)
