# authentic2 - versatile identity manager
# Copyright (C) 2010-2019 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 importlib
import json
import random
import uuid

import py
import pytest
import responses
from django.contrib.auth import get_user_model
from jwcrypto.jwk import JWK, JWKSet

from authentic2.a2_rbac.models import OrganizationalUnit
from authentic2.a2_rbac.utils import get_default_ou
from authentic2.utils import crypto
from authentic2_auth_oidc.models import OIDCAccount, OIDCClaimMapping, OIDCProvider
from tests.utils import call_command, check_log

User = get_user_model()


def test_oidc_register_issuer(db, tmpdir, monkeypatch):
    oidc_conf_f = py.path.local(__file__).dirpath('openid_configuration.json')
    with oidc_conf_f.open() as f:
        oidc_conf = json.load(f)

    def register_issuer(
        name,
        client_id,
        client_secret,
        issuer=None,
        openid_configuration=None,
        verify=True,
        timeout=None,
        ou=None,
    ):
        ou = OrganizationalUnit.objects.get(default=True)
        # skipping jwkset retrieval in mocked function
        jwkset = JWKSet()
        jwkset.add(JWK.generate(kty='RSA', size=512, kid='auie'))
        jwkset.add(JWK.generate(kty='EC', size=256, kid='tsrn'))
        return OIDCProvider.objects.create(
            name=name,
            slug='test',
            client_id='abc',
            client_secret='def',
            enabled=True,
            ou=ou,
            issuer=issuer,
            strategy='create',
            jwkset_json=jwkset.export(as_dict=True),
            authorization_endpoint=openid_configuration['authorization_endpoint'],
            token_endpoint=openid_configuration['token_endpoint'],
            userinfo_endpoint=openid_configuration['userinfo_endpoint'],
            end_session_endpoint=openid_configuration['end_session_endpoint'],
        )

    oidc_cmd = importlib.import_module('authentic2_auth_oidc.management.commands.oidc-register-issuer')
    monkeypatch.setattr(oidc_cmd, 'register_issuer', register_issuer)

    oidc_conf = py.path.local(__file__).dirpath('openid_configuration.json').strpath
    call_command(
        'oidc-register-issuer',
        '--openid-configuration',
        oidc_conf,
        '--issuer',
        'issuer',
        '--client-id',
        'auie',
        '--client-secret',
        'tsrn',
        'somename',
    )

    provider = OIDCProvider.objects.get(name='somename')
    assert provider.issuer == 'issuer'


@responses.activate
@pytest.mark.parametrize('deletion_number,deletion_valid', [(2, True), (5, True), (10, False)])
def test_oidc_sync_provider(
    db, app, admin, settings, caplog, deletion_number, deletion_valid, nologtoconsole
):
    oidc_provider = OIDCProvider.objects.create(
        issuer='https://some.provider',
        name='Some Provider',
        slug='some-provider',
        ou=get_default_ou(),
    )
    OIDCClaimMapping.objects.create(
        authenticator=oidc_provider,
        attribute='username',
        idtoken_claim=False,
        claim='username',
    )
    OIDCClaimMapping.objects.create(
        authenticator=oidc_provider,
        attribute='email',
        idtoken_claim=False,
        claim='email',
    )
    # last one, with an idtoken claim
    OIDCClaimMapping.objects.create(
        authenticator=oidc_provider,
        attribute='last_name',
        idtoken_claim=True,
        claim='family_name',
    )
    # typo in template string
    OIDCClaimMapping.objects.create(
        authenticator=oidc_provider,
        attribute='first_name',
        idtoken_claim=True,
        claim='given_name',
    )
    User = get_user_model()
    for i in range(100):
        user = User.objects.create(
            first_name='John%s' % i,
            last_name='Doe%s' % i,
            username='john.doe.%s' % i,
            email='john.doe.%s@ad.dre.ss' % i,
            ou=get_default_ou(),
        )
        identifier = uuid.UUID(user.uuid).bytes
        sector_identifier = 'some.provider'
        cipher_args = [
            settings.SECRET_KEY.encode('utf-8'),
            identifier,
            sector_identifier,
        ]
        sub = crypto.aes_base64url_deterministic_encrypt(*cipher_args).decode('utf-8')
        OIDCAccount.objects.create(user=user, provider=oidc_provider, sub=sub)

    def synchronization_get_modified_response():
        # randomized batch of modified users
        modified_users = random.sample(list(User.objects.all()), 20)
        results = []
        for count, user in enumerate(modified_users):
            user_json = user.to_json()
            user_json['username'] = f'modified_{count}'
            user_json['first_name'] = 'Mod'
            user_json['last_name'] = 'Ified'
            # mocking claim resolution by oidc provider
            user_json['given_name'] = 'Mod'
            user_json['family_name'] = 'Ified'

            # add user sub to response
            try:
                account = OIDCAccount.objects.get(user=user)
            except OIDCAccount.DoesNotExist:
                pass
            else:
                user_json['sub'] = account.sub

            results.append(user_json)
        return {'results': results}

    responses.post(
        'https://some.provider/api/users/synchronization/',
        json={
            'unknown_uuids': [
                account.sub for account in random.sample(list(OIDCAccount.objects.all()), deletion_number)
            ]
        },
    )
    responses.get('https://some.provider/api/users/', json=synchronization_get_modified_response())

    with check_log(caplog, 'no provider supporting synchronization'):
        call_command('oidc-sync-provider', '-v1')

    oidc_provider.a2_synchronization_supported = True
    oidc_provider.save()

    with check_log(caplog, 'no provider supporting synchronization'):
        call_command('oidc-sync-provider', '--provider', 'whatever', '-v1')

    with check_log(caplog, 'got 20 users'):
        call_command('oidc-sync-provider', '-v1')
    if deletion_valid:
        # existing users check
        assert OIDCAccount.objects.count() == 100 - deletion_number
    else:
        assert OIDCAccount.objects.count() == 100
        assert caplog.records[3].levelname == 'ERROR'
        assert 'deletion ratio is abnormally high' in caplog.records[3].message

    # users update
    assert User.objects.filter(username__startswith='modified').count() in range(20 - deletion_number, 21)
    assert User.objects.filter(first_name='Mod', last_name='Ified').count() in range(20 - deletion_number, 21)


@responses.activate
def test_auth_oidc_refresh_jwkset_json(db, app, admin, settings, caplog):
    jwkset_url = 'https://www.example.com/common/discovery/v3.0/keys'
    kid_rsa = '123'
    kid_ec = '456'

    def generate_remote_jwkset_json():
        key_rsa = JWK.generate(kty='RSA', size=512, kid=kid_rsa)
        key_ec = JWK.generate(kty='EC', size=256, kid=kid_ec)
        jwkset = JWKSet()
        jwkset.add(key_rsa)
        jwkset.add(key_ec)
        d = jwkset.export(as_dict=True)
        # add extra key without kid to check it is just ignored by change logging
        other_key = JWK.generate(kty='EC', size=256).export(as_dict=True)
        other_key.pop('kid', None)
        d['keys'].append(other_key)
        return d

    responses.get(
        jwkset_url,
        json={
            'headers': {
                'content-type': 'application/json',
            },
            'status_code': 200,
            **generate_remote_jwkset_json(),
        },
    )

    issuer = ('https://www.example.com',)
    provider = OIDCProvider(
        ou=get_default_ou(),
        name='Foo',
        slug='foo',
        client_id='abc',
        client_secret='def',
        enabled=True,
        issuer=issuer,
        authorization_endpoint='%s/authorize' % issuer,
        token_endpoint='%s/token' % issuer,
        end_session_endpoint='%s/logout' % issuer,
        userinfo_endpoint='%s/user_info' % issuer,
        token_revocation_endpoint='%s/revoke' % issuer,
        jwkset_url=jwkset_url,
        idtoken_algo=OIDCProvider.ALGO_RSA,
        claims_parameter_supported=False,
        button_label='Connect with Foo',
    )
    provider.clean()
    provider.save()
    assert {key.get('kid') for key in provider.jwkset_json['keys']} == {'123', '456', None}

    kid_rsa = 'abcdefg'
    kid_ec = 'hijklmn'

    responses.replace(
        responses.GET,
        jwkset_url,
        json={
            'headers': {
                'content-type': 'application/json',
            },
            'status_code': 200,
            **generate_remote_jwkset_json(),
        },
    )

    call_command('oidc-refresh-jwkset-json', '-v1')
    provider.refresh_from_db()
    assert {key.get('kid') for key in provider.jwkset_json['keys']} == {'abcdefg', 'hijklmn', None}
