# 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 logging

from django.contrib import messages
from django.core.exceptions import MultipleObjectsReturned
from django.db.transaction import atomic
from django.utils.translation import ugettext as _
from mellon.adapters import DefaultAdapter, UserCreationError
from mellon.utils import get_setting

from authentic2.a2_rbac.models import OrganizationalUnit as OU
from authentic2.a2_rbac.models import Role
from authentic2.a2_rbac.utils import get_default_ou
from authentic2.backends import get_user_queryset
from authentic2.utils import misc as utils_misc
from authentic2.utils.evaluate import evaluate_condition

logger = logging.getLogger('authentic2.auth_saml')


class MappingError(Exception):
    details = None

    def __init__(self, message, details=None):
        self.details = details or {}
        super().__init__(message)

    def __str__(self):
        s = str(self.args[0])
        if self.details:
            s += ' ' + repr(self.details)
        return s


class SamlConditionContextProxy:
    def __init__(self, saml_attributes):
        self.saml_attributes = saml_attributes

    def __getitem__(self, key):
        if key.endswith('__list'):
            return self.saml_attributes[key[: -len('__list')]]
        else:
            v = self.saml_attributes[key]
            if isinstance(v, list):
                return v[0] if v else None
            else:
                return v


class AuthenticAdapter(DefaultAdapter):
    def create_user(self, user_class):
        user = user_class()
        user.set_unusable_password()
        user.save()
        return user

    def finish_create_user(self, idp, saml_attributes, user):
        try:
            self.provision_a2_attributes(user, idp, saml_attributes)
        except MappingError as e:
            logger.warning('auth_saml: user creation failed on a mandatory mapping action, %s', e)
            if self.request:
                messages.error(self.request, _('user creation failed on a mandatory mapping action: %s') % e)
            raise UserCreationError
        if not user.ou:
            user.ou = get_default_ou()
            user.save()

    def provision(self, user, idp, saml_attributes):
        super().provision(user, idp, saml_attributes)
        try:
            self.provision_a2_attributes(user, idp, saml_attributes)
        except MappingError as e:
            logger.warning('auth_saml: failure during attribute provisionning %s', e)

    @atomic
    def provision_a2_attributes(self, user, idp, saml_attributes):
        """Copy incoming SAML attributes to user attributes, A2_ATTRIBUTE_MAPPING must be a list of
        dictionaries like:

           {
               'attribute': 'email',
               'saml_attribute': 'email',
               # optional:
               'mandatory': False,
           }

         If an attribute is not mandatory any error is just logged, if the attribute is
         mandatory, login will fail.
        """

        saml_attributes = saml_attributes.copy()
        attribute_mapping = get_setting(idp, 'A2_ATTRIBUTE_MAPPING', [])

        if not attribute_mapping:
            return

        if not isinstance(attribute_mapping, list):
            raise MappingError('invalid A2_ATTRIBUTE_MAPPING')

        if self.apply_attribute_mapping(user, idp, saml_attributes, attribute_mapping):
            user.save()

    def apply_attribute_mapping(self, user, idp, saml_attributes, attribute_mapping):
        user_modified = False
        for mapping in attribute_mapping:
            if not isinstance(mapping, dict):
                raise MappingError('invalid mapping action', details={'mapping': mapping})
            action = mapping.get('action', 'set-attribute')
            mandatory = mapping.get('mandatory', False) is True
            method = None
            if isinstance(action, str):
                try:
                    method = getattr(self, 'action_' + action.replace('-', '_'))
                except AttributeError:
                    pass
            try:
                if not method:
                    raise MappingError('invalid action')
                logger.debug('auth_saml: applying provisionning mapping %s', mapping)
                if method(user, idp, saml_attributes, mapping):
                    user_modified = True
            except MappingError as e:
                e.details['mapping'] = mapping
                if mandatory:
                    # it's mandatory, provisionning should fail completely
                    raise e
                else:
                    logger.warning('auth_saml: mapping action failed: %s', e)
        return user_modified

    def action_rename(self, user, idp, saml_attributes, mapping):
        from_name = mapping.get('from')
        if not from_name or not isinstance(from_name, str):
            raise MappingError('missing from in rename')
        to_name = mapping.get('to')
        if not to_name or not isinstance(to_name, str):
            raise MappingError('missing to in rename')
        if from_name in saml_attributes:
            saml_attributes[to_name] = saml_attributes[from_name]

    def action_set_attribute(self, user, idp, saml_attributes, mapping):
        attribute = mapping.get('attribute')
        if not attribute or not isinstance(attribute, str):
            raise MappingError('missing attribute key')

        saml_attribute = mapping.get('saml_attribute')
        if not saml_attribute or not isinstance(saml_attribute, str):
            raise MappingError('missing saml_attribute key')

        if saml_attribute not in saml_attributes:
            raise MappingError('unknown saml_attribute')
        value = saml_attributes[saml_attribute]
        return self.set_user_attribute(user, attribute, value)

    def set_user_attribute(self, user, attribute, value):
        if isinstance(value, list):
            if len(value) == 0:
                raise MappingError('no value')
            if len(value) > 1:
                raise MappingError('too many values', details={'value': value})
            value = value[0]
        if attribute in ('first_name', 'last_name', 'email', 'username'):
            if getattr(user, attribute) != value:
                logger.info('auth_saml: attribute %r set to %r', attribute, value, extra={'user': user})
                setattr(user, attribute, value)
                return True
        else:
            if getattr(user.attributes, attribute) != value:
                logger.info('auth_saml: attribute %r set to %r', attribute, value, extra={'user': user})
                setattr(user.attributes, attribute, value)
                return True
        return False

    def get_ou(self, role_desc):
        ou_desc = role_desc.get('ou')
        if ou_desc is None:
            return None
        if not isinstance(ou_desc, dict):
            raise MappingError('invalid ou description')
        slug = ou_desc.get('slug')
        name = ou_desc.get('name')
        if slug:
            if not isinstance(slug, str):
                raise MappingError('invalid ou.slug in ou description')
            try:
                return OU.objects.get(slug=slug)
            except OU.DoesNotExist:
                raise MappingError('unknown ou', details={'slug': slug})
        elif name:
            if not isinstance(name, str):
                raise MappingError('invalid ou.slug in ou description')
            try:
                return OU.objects.get(name=name)
            except OU.DoesNotExist:
                raise MappingError('unknown ou', details={'name': name})
        else:
            raise MappingError('invalid ou description')

    def get_role(self, mapping):
        role_desc = mapping.get('role')
        if not role_desc or not isinstance(role_desc, dict):
            raise MappingError('missing or invalid role description')
        slug = role_desc.get('slug')
        name = role_desc.get('name')
        ou = self.get_ou(role_desc)

        kwargs = {}
        if ou:
            kwargs['ou'] = ou

        if slug:
            if not isinstance(slug, str):
                raise MappingError('invalid role slug', details={'slug': slug})
            kwargs['slug'] = slug
        elif name:
            if not isinstance(name, str):
                raise MappingError('invalid role name', details={'name': name})
            kwargs['name'] = name
        else:
            raise MappingError('invalid role description')

        try:
            return Role.objects.get(**kwargs)
        except Role.DoesNotExist:
            raise MappingError('unknown role', details=kwargs)
        except MultipleObjectsReturned:
            raise MappingError('ambiuous role description', details=kwargs)

    def evaluate_condition(self, user, saml_attributes, mapping):
        condition = mapping.get('condition')
        if condition is None:
            return True
        if not isinstance(condition, str):
            raise MappingError('invalid condition')
        try:
            # use a proxy to simplify condition expressions as subscript is forbidden
            # you can write "email == 'a@b.com'" but also "'a@b.com' in email__list"
            value = evaluate_condition(condition, SamlConditionContextProxy(saml_attributes))
            logger.debug('auth_saml: condition %r is %s', condition, value, extra={'user': user})
            return value
        except Exception as e:
            raise MappingError('condition evaluation failed', details={'error': str(e)})

    def action_add_role(self, user, idp, saml_attributes, mapping):
        role = self.get_role(mapping)
        if self.evaluate_condition(user, saml_attributes, mapping):
            if role not in user.roles.all():
                logger.info('auth_saml: adding role "%s"', role, extra={'user': user})
                user.roles.add(role)
        else:
            if role in user.roles.all():
                logger.info('auth_saml: removing role "%s"', role, extra={'user': user})
                user.roles.remove(role)

    def action_toggle_role(self, *args, **kwargs):
        return self.action_add_role(*args, **kwargs)

    def auth_login(self, request, user):
        utils_misc.login(request, user, 'saml')

    def get_users_queryset(self, idp, saml_attributes):
        return get_user_queryset()
