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

from django.conf import settings
from django.contrib.postgres.fields import JSONField
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import ugettext_lazy as _
from jwcrypto.jwk import InvalidJWKValue, JWKSet

from django_rbac.utils import get_ou_model_name

from . import managers


def validate_jwkset(data):
    data = json.dumps(data)
    try:
        JWKSet.from_json(data)
    except InvalidJWKValue as e:
        raise ValidationError(_('Invalid JWKSet: %s') % e)


class OIDCProvider(models.Model):
    STRATEGY_CREATE = 'create'
    STRATEGY_FIND_UUID = 'find-uuid'
    STRATEGY_FIND_USERNAME = 'find-username'
    STRATEGY_NONE = 'none'

    STRATEGIES = [
        (STRATEGY_CREATE, _('create')),
        (STRATEGY_FIND_UUID, _('use sub to find existing user through UUID')),
        (STRATEGY_FIND_USERNAME, _('use sub to find existing user through username')),
        (STRATEGY_NONE, _('none')),
    ]
    ALGO_NONE = 0
    ALGO_RSA = 1
    ALGO_HMAC = 2
    ALGO_EC = 3
    ALGO_CHOICES = [
        (ALGO_NONE, _('none')),
        (ALGO_RSA, _('RSA')),
        (ALGO_HMAC, _('HMAC')),
        (ALGO_EC, _('EC')),
    ]

    name = models.CharField(unique=True, max_length=128, verbose_name=_('name'))
    slug = models.SlugField(unique=True, max_length=256, verbose_name=_('slug'), blank=True, null=True)
    issuer = models.CharField(max_length=256, verbose_name=_('issuer'), unique=True, db_index=True)
    client_id = models.CharField(max_length=128, default=uuid.uuid4, verbose_name=_('client id'))
    client_secret = models.CharField(max_length=128, default=uuid.uuid4, verbose_name=_('client secret'))
    # endpoints
    authorization_endpoint = models.URLField(max_length=128, verbose_name=_('authorization endpoint'))
    token_endpoint = models.URLField(max_length=128, verbose_name=_('token endpoint'))
    userinfo_endpoint = models.URLField(max_length=128, verbose_name=_('userinfo endpoint'))
    end_session_endpoint = models.URLField(
        max_length=128, blank=True, null=True, verbose_name=_('end session endpoint')
    )
    token_revocation_endpoint = models.URLField(
        max_length=128, blank=True, null=True, verbose_name=_('token revocation endpoint')
    )
    scopes = models.CharField(max_length=128, blank=True, verbose_name=_('scopes'))
    jwkset_json = JSONField(
        verbose_name=_('JSON WebKey set'), null=True, blank=True, validators=[validate_jwkset]
    )
    idtoken_algo = models.PositiveIntegerField(
        default=ALGO_RSA, choices=ALGO_CHOICES, verbose_name=_('IDToken signature algorithm')
    )
    claims_parameter_supported = models.BooleanField(
        verbose_name=_('Claims parameter supported'), default=False
    )

    # ou where new users should be created
    strategy = models.CharField(max_length=32, choices=STRATEGIES, verbose_name=_('strategy'))
    ou = models.ForeignKey(
        to=get_ou_model_name(), verbose_name=_('organizational unit'), on_delete=models.CASCADE
    )

    # policy
    max_auth_age = models.PositiveIntegerField(
        verbose_name=_('max authentication age'), blank=True, null=True
    )

    # hide OP from login page
    show = models.BooleanField(verbose_name=_('show on login page'), blank=True, default=True)

    # metadata
    created = models.DateTimeField(verbose_name=_('created'), auto_now_add=True)
    modified = models.DateTimeField(verbose_name=_('modified'), auto_now=True)

    objects = managers.OIDCProviderManager()

    @property
    def jwkset(self):
        if self.jwkset_json:
            return JWKSet.from_json(json.dumps(self.jwkset_json))
        return None

    def __str__(self):
        return self.name

    def authorization_claims_parameter(self):
        idtoken_claims = {}
        userinfo_claims = {}
        for claim_mapping in self.claim_mappings.all():
            d = idtoken_claims if claim_mapping.idtoken_claim else userinfo_claims
            value = d.setdefault(claim_mapping.claim, {}) or {}
            if claim_mapping.required:
                value['essential'] = True
            d[claim_mapping.claim] = value or None
        return {
            'id_token': idtoken_claims,
            'userinfo': userinfo_claims,
        }

    def __repr__(self):
        return '<OIDCProvider %r>' % self.issuer


class OIDCClaimMapping(models.Model):
    NOT_VERIFIED = 0
    VERIFIED_CLAIM = 1
    ALWAYS_VERIFIED = 2
    VERIFIED_CHOICES = [
        (NOT_VERIFIED, _('not verified')),
        (VERIFIED_CLAIM, _('verified claim')),
        (ALWAYS_VERIFIED, _('always verified')),
    ]

    provider = models.ForeignKey(
        to='OIDCProvider', verbose_name=_('provider'), related_name='claim_mappings', on_delete=models.CASCADE
    )
    claim = models.CharField(max_length=128, verbose_name=_('claim'))
    attribute = models.CharField(max_length=64, verbose_name=_('attribute'))
    verified = models.PositiveIntegerField(
        default=NOT_VERIFIED, choices=VERIFIED_CHOICES, verbose_name=_('verified')
    )
    required = models.BooleanField(blank=True, default=False, verbose_name=_('required'))
    idtoken_claim = models.BooleanField(verbose_name=_('idtoken claim'), default=False, blank=True)
    created = models.DateTimeField(verbose_name=_('created'), auto_now_add=True)
    modified = models.DateTimeField(verbose_name=_('modified'), auto_now=True)

    objects = managers.OIDCClaimMappingManager()

    def natural_key(self):
        return (self.claim, self.attribute, self.verified, self.required)

    def __str__(self):
        s = f'{self.claim} -> {self.attribute}'
        if self.verified:
            s += ', verified'
        if self.required:
            s += ', required'
        if self.idtoken_claim:
            s += ', idtoken'
        return s

    def __repr__(self):
        return '<OIDCClaimMapping %r:%r on provider %r verified:%s required:%s >' % (
            self.claim,
            self.attribute,
            self.provider and self.provider.issuer,
            self.verified,
            self.required,
        )


class OIDCAccount(models.Model):
    created = models.DateTimeField(verbose_name=_('created'), auto_now_add=True)
    modified = models.DateTimeField(verbose_name=_('modified'), auto_now=True)
    provider = models.ForeignKey(
        to='OIDCProvider', verbose_name=_('provider'), related_name='accounts', on_delete=models.CASCADE
    )
    user = models.OneToOneField(
        to=settings.AUTH_USER_MODEL,
        verbose_name=_('user'),
        related_name='oidc_account',
        on_delete=models.CASCADE,
    )
    sub = models.CharField(verbose_name=_('sub'), max_length=256)

    def __str__(self):
        return f'{self.sub} on {self.provider and self.provider.issuer} linked to {self.user}'

    def __repr__(self):
        return '<OIDCAccount %r on %r>' % (self.sub, self.provider and self.provider.issuer)

    class Meta:
        unique_together = [
            ('provider', 'sub'),
        ]
