"""
Email confirmation and password reset.

What these pin down: the gate is activation and nothing else, a reset ends
every session the old password could still reach, and neither endpoint answers
the question "does this address have an account".
"""

from django.contrib.auth import get_user_model
from django.core import mail
from django.core.cache import cache
from django.test import TestCase, override_settings
from rest_framework_simplejwt.token_blacklist.models import BlacklistedToken
from rest_framework_simplejwt.tokens import AccessToken, RefreshToken

from users.models import EmailToken

User = get_user_model()

PASSWORD = 'a-long-enough-passphrase'


class EmailLinkTestCase(TestCase):
    """
    Two things every test here needs.

    `LIFEY_EMAIL_SYNC` sends on this thread, so `mail.outbox` is readable the
    moment the request returns rather than whenever a daemon thread gets
    scheduled. The cache clear resets the per-IP rate limiter, which counts
    every test in this file as the same caller and would otherwise start
    answering 429 partway through the run.
    """

    def setUp(self):
        cache.clear()
        super().setUp()


def link_token(purpose):
    """Pull the plaintext token out of the email that was just sent."""
    body = mail.outbox[-1].alternatives[0][0]
    marker = f'#{purpose}='
    start = body.index(marker) + len(marker)
    return body[start:body.index('"', start)]


@override_settings(LIFEY_EMAIL_SYNC=True)
class RegistrationSendsConfirmationTests(EmailLinkTestCase):

    def test_registering_sends_one_confirmation_email(self):
        response = self.client.post('/api/auth/register/', {
            'email': 'fresh@example.com',
            'password': PASSWORD,
        }, content_type='application/json')

        self.assertEqual(response.status_code, 201)
        self.assertFalse(response.json()['user']['email_verified'])

        self.assertEqual(len(mail.outbox), 1)
        self.assertIn('Confirm', mail.outbox[0].subject)
        self.assertEqual(EmailToken.objects.filter(purpose='verify').count(), 1)

    def test_the_plaintext_token_is_never_stored(self):
        self.client.post('/api/auth/register/', {
            'email': 'fresh@example.com', 'password': PASSWORD,
        }, content_type='application/json')

        token = link_token('verify')
        self.assertFalse(EmailToken.objects.filter(token_hash=token).exists())


@override_settings(LIFEY_EMAIL_SYNC=True)
class ConfirmEmailTests(EmailLinkTestCase):

    def setUp(self):
        super().setUp()
        self.client.post('/api/auth/register/', {
            'email': 'fresh@example.com', 'password': PASSWORD,
        }, content_type='application/json')
        self.user = User.objects.get(email='fresh@example.com')
        self.token = link_token('verify')

    def _confirm(self, token):
        return self.client.post(
            '/api/auth/verify-email/confirm/', {'token': token}, content_type='application/json',
        )

    def test_the_link_confirms_without_a_session(self):
        # No Authorization header: the link opens in whichever browser the
        # inbox is on, which is rarely the one that signed up.
        self.assertEqual(self._confirm(self.token).status_code, 200)

        self.user.refresh_from_db()
        self.assertTrue(self.user.email_verified)

    def test_a_link_works_once(self):
        self._confirm(self.token)
        self.assertEqual(self._confirm(self.token).status_code, 400)

    def test_an_unknown_link_reads_like_a_used_one(self):
        self._confirm(self.token)

        used = self._confirm(self.token)
        unknown = self._confirm('not-a-real-token')

        self.assertEqual(used.status_code, unknown.status_code)
        self.assertEqual(used.json(), unknown.json())

    def test_a_confirmation_token_cannot_reset_a_password(self):
        response = self.client.post('/api/auth/password-reset/confirm/', {
            'token': self.token, 'password': 'another-long-passphrase',
        }, content_type='application/json')

        self.assertEqual(response.status_code, 400)
        self.assertTrue(self.client.login(email='fresh@example.com', password=PASSWORD))

    def test_asking_again_invalidates_the_previous_link(self):
        auth = {'HTTP_AUTHORIZATION': f'Bearer {AccessToken.for_user(self.user)}'}
        self.client.post('/api/auth/verify-email/request/', **auth)

        self.assertEqual(self._confirm(self.token).status_code, 400)
        self.assertEqual(self._confirm(link_token('verify')).status_code, 200)


@override_settings(LIFEY_EMAIL_SYNC=True)
class ActivationGateTests(EmailLinkTestCase):

    def setUp(self):
        super().setUp()
        self.client.post('/api/auth/register/', {
            'email': 'fresh@example.com', 'password': PASSWORD,
        }, content_type='application/json')
        self.user = User.objects.get(email='fresh@example.com')
        self.auth = {'HTTP_AUTHORIZATION': f'Bearer {AccessToken.for_user(self.user)}'}

    def _activate(self):
        return self.client.post(
            '/api/auth/activate/', {'plan': 'beta'}, content_type='application/json', **self.auth,
        )

    def test_activation_is_refused_until_the_address_is_confirmed(self):
        response = self._activate()

        self.assertEqual(response.status_code, 403)
        self.assertEqual(response.json()['code'], 'email_not_verified')

        self.user.refresh_from_db()
        self.assertEqual(self.user.lifey_plan, '')

    def test_signing_in_is_NOT_gated(self):
        # Deliberate: a confirmation mail in a spam folder must not cost
        # somebody their account.
        response = self.client.post('/api/auth/login/', {
            'email': 'fresh@example.com', 'password': PASSWORD,
        }, content_type='application/json')

        self.assertEqual(response.status_code, 200)

    def test_activation_works_once_confirmed(self):
        self.client.post(
            '/api/auth/verify-email/confirm/', {'token': link_token('verify')},
            content_type='application/json',
        )

        self.assertEqual(self._activate().status_code, 200)


@override_settings(LIFEY_EMAIL_SYNC=True)
class PasswordResetTests(EmailLinkTestCase):

    def setUp(self):
        super().setUp()
        self.user = User.objects.create_user(email='member@example.com', password=PASSWORD)

    def _request(self, email):
        return self.client.post(
            '/api/auth/password-reset/request/', {'email': email}, content_type='application/json',
        )

    def _confirm(self, token, password):
        return self.client.post(
            '/api/auth/password-reset/confirm/',
            {'token': token, 'password': password},
            content_type='application/json',
        )

    def test_a_known_and_an_unknown_address_answer_identically(self):
        known = self._request('member@example.com')
        unknown = self._request('nobody@example.com')

        self.assertEqual(known.status_code, unknown.status_code)
        self.assertEqual(known.json(), unknown.json())
        # One email, for the address that exists.
        self.assertEqual(len(mail.outbox), 1)

    def test_a_google_only_account_gets_no_email_and_the_same_answer(self):
        social = User.objects.create_user(email='social@example.com', password=PASSWORD)
        social.set_unusable_password()
        social.save(update_fields=['password'])

        response = self._request('social@example.com')

        self.assertEqual(response.json()['detail'], self._request('nobody@example.com').json()['detail'])
        self.assertEqual(len(mail.outbox), 0)

    def test_the_link_sets_the_new_password_and_confirms_the_address(self):
        self._request('member@example.com')

        response = self._confirm(link_token('reset'), 'a-brand-new-passphrase')
        self.assertEqual(response.status_code, 200)

        self.user.refresh_from_db()
        self.assertTrue(self.user.check_password('a-brand-new-passphrase'))
        self.assertFalse(self.user.check_password(PASSWORD))
        # Reading the email proved the address.
        self.assertTrue(self.user.email_verified)

    def test_a_reset_ends_every_existing_session(self):
        refresh = RefreshToken.for_user(self.user)

        self._request('member@example.com')
        self._confirm(link_token('reset'), 'a-brand-new-passphrase')

        self.assertTrue(BlacklistedToken.objects.filter(token__jti=refresh['jti']).exists())

    def test_a_weak_new_password_is_refused_and_the_link_survives(self):
        self._request('member@example.com')
        token = link_token('reset')

        self.assertEqual(self._confirm(token, '123').status_code, 400)
        # The link is not burnt by a password the rules refuse: the user would
        # otherwise have to go back to their inbox over a typo.
        self.assertEqual(self._confirm(token, 'a-brand-new-passphrase').status_code, 200)

    def test_a_link_works_once(self):
        self._request('member@example.com')
        token = link_token('reset')

        self.assertEqual(self._confirm(token, 'a-brand-new-passphrase').status_code, 200)
        self.assertEqual(self._confirm(token, 'yet-another-passphrase').status_code, 400)

    def test_asking_twice_leaves_only_the_newest_link_alive(self):
        self._request('member@example.com')
        first = link_token('reset')
        self._request('member@example.com')
        second = link_token('reset')

        self.assertEqual(self._confirm(first, 'a-brand-new-passphrase').status_code, 400)
        self.assertEqual(self._confirm(second, 'a-brand-new-passphrase').status_code, 200)
