# authentic2 - authentic2 authentication for FranceConnect
# Copyright (C) 2020 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

import datetime
import json
import re
import urllib.parse
from unittest import mock

import pytest
import requests
from django.contrib.auth import get_user_model
from django.core.exceptions import PermissionDenied
from django.urls import reverse
from django.utils.timezone import now

from authentic2.a2_rbac.models import OrganizationalUnit as OU
from authentic2.a2_rbac.utils import get_default_ou
from authentic2.apps.journal.models import Event
from authentic2.custom_user.models import DeletedUser
from authentic2.models import Attribute, Service
from authentic2_auth_fc import models
from authentic2_auth_fc.backends import FcBackend
from authentic2_auth_fc.utils import requests_retry_session

from ..utils import get_link_from_mail, login

User = get_user_model()


def path(url):
    return urllib.parse.urlparse(url).path


def test_fc_url_on_login(app, franceconnect):
    url = reverse('fc-login-or-link')
    response = app.get(url, status=302)
    assert response.location.startswith('https://fcp.integ01')
    assert 'fc-state' in app.cookies


def test_retry_authorization_if_state_is_lost(settings, app, franceconnect, hooks):
    response = app.get('/fc/callback/?next=/idp/&service=default%20portail', status=302)
    # clear fc-state cookie
    app.cookiejar.clear()
    response = franceconnect.handle_authorization(app, response.location, status=302)
    assert response.location.startswith('https://fcp.integ01')


def test_login_with_condition(settings, app, franceconnect):
    # open the page first time so session cookie can be set
    response = app.get('/login/')
    assert 'fc-button' in response

    # make sure FC block is first
    assert response.text.index('div id="fc-button"') < response.text.index('name="login-password-submit"')

    settings.AUTH_FRONTENDS_KWARGS = {'fc': {'show_condition': 'remote_addr==\'0.0.0.0\''}}
    response = app.get('/login/')
    assert 'fc-button' not in response


def test_login_autorun(settings, app, franceconnect):
    # hide password block
    settings.AUTH_FRONTENDS_KWARGS = {'password': {'show_condition': 'remote_addr==\'0.0.0.0\''}}
    response = app.get('/login/')
    assert response.location == reverse('fc-login-or-link')


def test_create(settings, app, franceconnect, hooks):
    # test direct creation

    response = app.get('/login/?service=portail&next=/idp/')
    response = response.click(href='callback')

    assert User.objects.count() == 0
    assert Event.objects.which_references(Service.objects.get()).count() == 0
    response = franceconnect.handle_authorization(app, response.location, status=302)
    assert 'fc-state' not in app.cookies
    assert User.objects.count() == 1
    # check login for service=portail was registered
    assert Event.objects.which_references(Service.objects.get()).count() == 1

    user = User.objects.get()
    assert user.verified_attributes.first_name == 'Ÿuñe'
    assert user.verified_attributes.last_name == 'Frédérique'
    assert path(response.location) == '/idp/'
    assert hooks.event[1]['kwargs']['name'] == 'login'
    assert hooks.event[1]['kwargs']['service'] == 'portail'
    # we must be connected
    assert app.session['_auth_user_id']
    assert app.session.get_expire_at_browser_close()
    assert models.FcAccount.objects.count() == 1

    # test unlink cancel case
    response = app.get('/accounts/')
    response = response.click('Delete link')
    assert len(response.pyquery('[name=cancel][formnovalidate]')) == 1
    response = response.form.submit(name='cancel')
    response = response.follow()

    # test unlink submit case
    response = app.get('/accounts/')
    response = response.click('Delete link')
    response.form.set('new_password1', 'ikKL1234')
    response.form.set('new_password2', 'ikKL1234')
    response = response.form.submit(name='unlink')
    assert models.FcAccount.objects.count() == 0
    response = franceconnect.handle_logout(app, response.location)
    assert path(response.location) == '/accounts/'
    response = response.follow()
    assert 'The link with the FranceConnect account has been deleted' in response


def test_create_expired(settings, app, franceconnect, hooks):
    # test direct creation failure on an expired id_token
    franceconnect.exp = now() - datetime.timedelta(seconds=30)

    response = app.get('/login/?service=portail&next=/idp/')
    response = response.click(href='callback')

    assert User.objects.count() == 0
    response = franceconnect.handle_authorization(app, response.location, status=302)
    assert User.objects.count() == 0


def test_login_email_is_unique(settings, app, franceconnect, caplog):
    settings.A2_EMAIL_IS_UNIQUE = True
    user = User(email='john.doe@example.com', first_name='John', last_name='Doe', ou=get_default_ou())
    user.set_password('toto')
    user.save()
    franceconnect.user_info['email'] = user.email

    assert User.objects.count() == 1
    franceconnect.login_with_fc_fixed_params(app)
    assert User.objects.count() == 1
    assert app.session['_auth_user_id'] == str(user.pk)


def test_link_after_login_with_password(app, franceconnect, simple_user):
    assert models.FcAccount.objects.count() == 0

    response = login(app, simple_user, path='/accounts/')
    response = response.click(href='/fc/callback/')

    franceconnect.callback_params = {'next': '/accounts/'}
    response = franceconnect.handle_authorization(app, response.location, status=302)
    assert models.FcAccount.objects.count() == 1
    response = response.follow()
    assert response.pyquery('.fc').text() == 'Linked FranceConnect accounts\nŸuñe Frédérique Delete link'


def test_unlink_after_login_with_password(app, franceconnect, simple_user):
    models.FcAccount.objects.create(user=simple_user, user_info='{}')

    response = login(app, simple_user, path='/accounts/')
    response = response.click('Delete link')
    assert 'new_password1' not in response.form.fields
    response = response.form.submit(name='unlink').follow()
    assert 'The link with the FranceConnect account has been deleted' in response.text
    # no logout from FC since we are not logged to it
    assert response.request.path == '/accounts/'


def test_unlink_after_login_with_fc(app, franceconnect, simple_user):
    models.FcAccount.objects.create(user=simple_user, sub=franceconnect.sub, user_info='{}')

    response = franceconnect.login_with_fc(app, path='/accounts/')
    response = response.click('Delete link')
    response.form.set('new_password1', 'ikKL1234')
    response.form.set('new_password2', 'ikKL1234')
    response = response.form.submit(name='unlink')
    assert models.FcAccount.objects.count() == 0
    response = franceconnect.handle_logout(app, response.location)
    assert path(response.location) == '/accounts/'
    response = response.follow()
    assert 'The link with the FranceConnect account has been deleted' in response


def test_login_email_is_unique_and_already_linked(settings, app, franceconnect, caplog):
    settings.A2_EMAIL_IS_UNIQUE = True

    # setup an already linked user account
    user = User.objects.create(email='john.doe@example.com', first_name='John', last_name='Doe')
    models.FcAccount.objects.create(user=user, sub='4567', token='xxx', user_info='{}')
    response = app.get('/login/?service=portail&next=/idp/')
    response = response.click(href='callback')
    response = franceconnect.handle_authorization(app, response.location, status=302)
    assert models.FcAccount.objects.count() == 1
    assert 'is already used' in app.cookies['messages']
    assert '_auth_user_id' not in app.session


def test_requests_proxies_support(settings, app):
    session = requests_retry_session()
    assert session.proxies == {}
    other_session = requests.Session()
    other_session.proxies = {'http': 'http://example.net'}
    session = requests_retry_session(session=other_session)
    assert session is other_session
    assert session.proxies == {'http': 'http://example.net'}

    settings.REQUESTS_PROXIES = {'https': 'http://pubproxy.com/api/proxy'}
    session = requests_retry_session()
    assert session.proxies == {'https': 'http://pubproxy.com/api/proxy'}

    with mock.patch('authentic2_auth_fc.utils.requests.Session.send') as mocked_send:
        mocked_send.return_value = mock.Mock(status_code=200, content='whatever')
        session.get('https://example.net/')
        assert mocked_send.call_args[1]['proxies'] == {'https': 'http://pubproxy.com/api/proxy'}


def test_no_password_with_fc_account_can_reset_password(app, db, mailoutbox):
    user = User(email='john.doe@example.com')
    user.set_unusable_password()
    user.save()
    # No FC account, forbidden to set a password
    response = app.get('/login/')
    response = response.click('Reset it!').maybe_follow()
    response.form['email'] = user.email
    assert len(mailoutbox) == 0
    response = response.form.submit()
    assert len(mailoutbox) == 1
    url = get_link_from_mail(mailoutbox[0])
    response = app.get(url).follow().follow()
    assert '_auth_user_id' not in app.session
    assert 'not possible to reset' in response

    # With FC account, can set a password
    models.FcAccount.objects.create(user=user, sub='xxx', token='aaa')
    response = app.get('/login/')
    response = response.click('Reset it!').maybe_follow()
    response.form['email'] = user.email
    assert len(mailoutbox) == 1
    response = response.form.submit()
    assert len(mailoutbox) == 2
    url = get_link_from_mail(mailoutbox[1])
    response = app.get(url, status=200)
    response.form.set('new_password1', 'ikKL1234')
    response.form.set('new_password2', 'ikKL1234')
    response = response.form.submit().follow()
    assert '_auth_user_id' in app.session


def test_login_with_missing_required_attributes(settings, app, franceconnect):
    Attribute.objects.create(label='Title', name='title', required=True, kind='title')
    Attribute.objects.create(label='Phone', name='phone', required=True, kind='phone_number')

    assert User.objects.count() == 0
    assert models.FcAccount.objects.count() == 0

    franceconnect.user_info['phone'] = '0102030405'
    settings.A2_FC_USER_INFO_MAPPINGS = {'phone': {'ref': 'phone'}}

    response = app.get('/login/?service=portail&next=/idp/')
    response = response.click(href='callback')
    response = franceconnect.handle_authorization(app, response.location)

    assert path(response.location) == '/accounts/edit/'
    assert User.objects.count() == 1
    assert models.FcAccount.objects.count() == 1
    assert 'The following fields are mandatory for account creation: Title' in app.cookies['messages']


def test_can_change_password(settings, app, franceconnect):
    user = User.objects.create(email='john.doe@example.com')
    models.FcAccount.objects.create(user=user, sub=franceconnect.sub)

    response = franceconnect.login_with_fc(app, path='/accounts/')
    assert len(response.pyquery('[href*="password/change"]')) == 0
    response = response.click('Logout')
    response = franceconnect.handle_logout(app, response.location).follow()
    assert '_auth_user_id' not in app.session

    # Login with password
    user.username = 'test'
    user.set_password('test')
    user.save()

    response = login(app, user, path='/accounts/')
    assert len(response.pyquery('[href*="password/change"]')) > 0
    response = response.click('Logout').follow()

    # Relogin with FC
    response = franceconnect.login_with_fc(app, path='/accounts/')
    assert len(response.pyquery('[href*="password/change"]')) == 0

    # Unlink
    response = response.click('Delete link')
    response.form.set('new_password1', 'ikKL1234')
    response.form.set('new_password2', 'ikKL1234')
    response = response.form.submit(name='unlink')
    assert models.FcAccount.objects.count() == 0
    response = franceconnect.handle_logout(app, response.location)
    assert path(response.location) == '/accounts/'
    response = response.follow()
    assert 'The link with the FranceConnect account has been deleted' in response
    assert len(response.pyquery('[href*="password/change"]')) > 0


def test_invalid_next_url(app, franceconnect):
    assert app.get('/fc/callback/?code=coin&state=JJJ72QQQ').location == '/'


def test_manager_user_sidebar(app, superuser, simple_user):
    login(app, superuser, '/manage/')
    response = app.get('/manage/users/%s/' % simple_user.id)
    assert 'FranceConnect' not in response

    fc_account = models.FcAccount(user=simple_user)
    fc_account.save()

    response = app.get('/manage/users/%s/' % simple_user.id)
    assert 'FranceConnect' in response


def test_user_info_incomplete(settings, app, franceconnect):
    franceconnect.user_info = {}
    franceconnect.login_with_fc_fixed_params(app)

    user = User.objects.get()
    assert app.session['_auth_user_id'] == str(user.pk)
    fc_account = models.FcAccount.objects.get(user=user)
    assert fc_account.sub == franceconnect.sub
    assert fc_account.get_user_info() == {'sub': franceconnect.sub}


def test_user_info_incomplete_already_linked(settings, app, franceconnect, simple_user):
    user = User.objects.create()
    models.FcAccount.objects.create(user=user, sub=franceconnect.sub)
    franceconnect.user_info = {}
    franceconnect.callback_params = {'next': '/accounts/'}

    response = login(app, simple_user, path='/accounts/')
    response = response.click(href='callback')
    response = franceconnect.handle_authorization(app, response.location, status=302)
    assert 'FranceConnect account  is already' in app.cookies['messages']


def test_save_account_on_delete_user(db):
    user = User.objects.create()
    models.FcAccount.objects.create(user=user, sub='1234')
    models.FcAccount.objects.create(user=user, sub='4567', order=1)
    user.delete()
    assert models.FcAccount.objects.count() == 0

    deleted_user = DeletedUser.objects.get()
    assert deleted_user.old_data.get('fc_accounts') == [
        {
            'sub': '1234',
        },
        {
            'sub': '4567',
        },
    ]


def test_create_missing_email(settings, app, franceconnect, hooks):
    del franceconnect.user_info['email']
    response = app.get('/login/?service=portail&next=/idp/')
    response = response.click(href='callback')

    response = franceconnect.handle_authorization(app, response.location, status=302)
    assert User.objects.count() == 1

    response = app.get('/accounts/', status=200)


def test_multiple_accounts_with_same_email(settings, app, franceconnect):
    ou = get_default_ou()
    ou.email_is_unique = True
    ou.save()

    User.objects.create(email=franceconnect.user_info['email'], ou=ou)
    User.objects.create(email=franceconnect.user_info['email'], ou=ou)

    response = franceconnect.login_with_fc(app, path='/accounts/')
    response = response.follow()

    assert 'is already used by another' in response


def test_sub_with_order_0_is_used(app, db, rf):
    usera = User.objects.create(username='a')
    userb = User.objects.create(username='b')
    models.FcAccount.objects.create(user=usera, sub='1234', order=1)
    models.FcAccount.objects.create(user=userb, sub='1234', order=0)

    assert FcBackend().authenticate(rf.get('/'), sub='1234', token={}, user_info={}) == userb


def test_inactive_raise_permission_denied(app, db, rf):
    usera = User.objects.create(is_active=False, username='a')
    models.FcAccount.objects.create(user=usera, sub='1234')

    with pytest.raises(PermissionDenied):
        FcBackend().authenticate(rf.get('/'), sub='1234', token={}, user_info={})


def test_order_1_is_returned(app, db, rf):
    usera = User.objects.create(username='a')
    models.FcAccount.objects.create(user=usera, sub='1234', order=1)

    assert FcBackend().authenticate(rf.get('/'), sub='1234', token={}, user_info={}) == usera


def test_resolve_authorization_code_http_400(app, franceconnect, caplog):
    franceconnect.token_endpoint_response = {
        'status_code': 400,
        'content': json.dumps({'error': 'invalid_request'}),
    }

    response = franceconnect.login_with_fc(app, path='/accounts/').follow()
    assert re.match(r'WARNING.*token request failed.*invalid_request', caplog.text)
    assert 'invalid_request' in response


def test_resolve_authorization_code_http_400_error_description(app, franceconnect, caplog):
    franceconnect.token_endpoint_response = {
        'status_code': 400,
        'content': json.dumps({'error': 'invalid_request', 'error_description': 'Requête invalide'}),
    }

    response = franceconnect.login_with_fc(app, path='/accounts/').follow()
    assert re.match(r'WARNING.*token request failed.*invalid_request', caplog.text)
    assert 'invalid_request' not in response
    assert 'Requête invalide' in response


def test_resolve_authorization_code_not_json(app, franceconnect, caplog):
    franceconnect.token_endpoint_response = 'not json'
    franceconnect.login_with_fc(app, path='/accounts/').follow()
    assert re.match(r'WARNING.*resolve_authorization_code.*not JSON.*not json', caplog.text)


def test_get_user_info_http_400(app, franceconnect, caplog):
    franceconnect.user_info_endpoint_response = {
        'status_code': 400,
        'content': json.dumps({'error': 'invalid_request'}),
    }

    franceconnect.login_with_fc(app, path='/accounts/').follow()
    assert re.match(r'WARNING.*get_user_info.*is not 200.*status_code=400.*invalid_request', caplog.text)


def test_get_user_info_http_400_text_content(app, franceconnect, caplog):
    franceconnect.user_info_endpoint_response = {
        'status_code': 400,
        'content': 'coin',
    }
    franceconnect.login_with_fc(app, path='/accounts/').follow()
    assert re.match(r'WARNING.*get_user_info.*is not 200.*status_code=400.*coin', caplog.text)


def test_get_user_info_not_json(app, franceconnect, caplog):
    franceconnect.user_info_endpoint_response = {
        'status_code': 200,
        'content': 'coin',
    }
    franceconnect.login_with_fc(app, path='/accounts/').follow()
    assert re.match(r'WARNING.*get_user_info.*not JSON.*coin', caplog.text)


def test_fc_is_down(app, franceconnect, freezer, caplog):
    franceconnect.token_endpoint_response = {'status_code': 500, 'content': 'Internal server error'}

    # first error -> warning
    response = franceconnect.login_with_fc(app, path='/accounts/').follow()
    assert len(caplog.records) == 1
    assert caplog.records[-1].levelname == 'WARNING'
    assert 'Unable to connect to FranceConnect' in response

    # second error, four minutes later -> warning
    freezer.move_to(datetime.timedelta(seconds=+240))
    response = franceconnect.login_with_fc(app, path='/accounts/').follow()
    assert len(caplog.records) == 2
    assert caplog.records[-1].levelname == 'WARNING'
    assert 'Unable to connect to FranceConnect' in response

    # after 5 minutes an error is logged
    freezer.move_to(datetime.timedelta(seconds=+240))
    response = franceconnect.login_with_fc(app, path='/accounts/').follow()
    assert len(caplog.records) == 4
    assert caplog.records[-1].levelname == 'ERROR'
    assert 'Unable to connect to FranceConnect' in response

    # but only every 5 minutes
    freezer.move_to(datetime.timedelta(seconds=+60))
    response = franceconnect.login_with_fc(app, path='/accounts/').follow()
    assert len(caplog.records) == 5
    assert caplog.records[-1].levelname == 'WARNING'
    assert 'Unable to connect to FranceConnect' in response

    # a success clear the down flag
    franceconnect.token_endpoint_response = None
    response = franceconnect.login_with_fc(app, path='/accounts/')
    assert app.session['_auth_user_id']
    app.session.flush()
    assert len(caplog.records) == 7

    # such that 5 minutes later only a warning is emitted
    freezer.move_to(datetime.timedelta(seconds=310))
    franceconnect.token_endpoint_response = {'status_code': 500, 'content': 'Internal server error'}
    response = franceconnect.login_with_fc(app, path='/accounts/').follow()
    assert len(caplog.records) == 8
    assert caplog.records[-1].levelname == 'WARNING'
    assert 'Unable to connect to FranceConnect' in response


def test_authorization_error(app, franceconnect):
    error = 'unauthorized'
    error_description = 'Vous n\'êtes pas autorisé à vous connecter.'

    response = app.get(
        '/fc/callback/', params={'error': error, 'error_description': error_description, 'next': '/accounts/'}
    ).maybe_follow()
    messages = response.pyquery('.messages').text()
    assert error not in messages
    assert error_description in messages

    response = app.get('/fc/callback/', params={'error': error, 'next': '/accounts/'}).maybe_follow()
    messages = response.pyquery('.messages').text()
    assert error in messages
    assert error_description not in messages


def test_registration_page(settings, app, franceconnect, hooks):
    assert User.objects.count() == 0
    response = app.get('/accounts/register/?service=portail&next=/idp/')
    response = franceconnect.login_with_fc_fixed_params(app)

    # a new user has been created
    assert User.objects.count() == 1

    # we must be connected
    assert app.session['_auth_user_id']

    # hook must have been called
    assert hooks.calls['event'][0]['kwargs']['name'] == 'fc-create'


def test_same_email_different_sub(app, franceconnect):
    OU.objects.all().update(email_is_unique=True)

    assert User.objects.count() == 0
    franceconnect.callback_params = {}

    franceconnect.login_with_fc_fixed_params(app)

    # ok user created
    assert User.objects.count() == 1
    # logout
    app.session.flush()

    # change sub
    franceconnect.sub = '4567'

    resp = franceconnect.login_with_fc_fixed_params(app).maybe_follow()

    # email collision, sub is different, no new user created
    assert User.objects.count() == 1
    assert 'another email address' in resp


def test_update_fc_email(settings, app, franceconnect):
    settings.A2_EMAIL_IS_UNIQUE = True
    user = User(email='john.doe@example.com', first_name='John', last_name='Doe')
    user.save()
    models.FcAccount.objects.create(user=user, sub='1234')

    # user1 FC email has changed
    assert franceconnect.sub == '1234'
    assert franceconnect.user_info['given_name'] == 'Ÿuñe'
    franceconnect.user_info['email'] = 'jhonny@example.com'

    # connection using FC sub 1234 will not update user1 email
    franceconnect.login_with_fc_fixed_params(app)
    assert User.objects.get(pk=user.pk).email == 'john.doe@example.com'
    assert User.objects.get(pk=user.pk).first_name == 'Ÿuñe'
    assert app.session['_auth_user_id'] == str(user.pk)
