# 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 base64
import datetime
import os

from django.core.exceptions import MultipleObjectsReturned, ValidationError
from django.core.mail import send_mail
from django.db import models, transaction
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _

try:
    from django.contrib.contenttypes.fields import GenericRelation
except ImportError:
    from django.contrib.contenttypes.generic import GenericRelation

from django.contrib.auth.models import AbstractBaseUser
from django.contrib.postgres.fields import JSONField

from authentic2 import app_settings
from authentic2.decorators import RequestCache, errorcollector
from authentic2.models import Attribute, AttributeValue, Service
from authentic2.utils import misc as utils_misc
from authentic2.validators import email_validator
from django_rbac.models import PermissionMixin
from django_rbac.utils import get_role_parenting_model

from .managers import UserManager, UserQuerySet


@RequestCache
def get_attributes_map():
    mapping = {}
    for at in Attribute.objects.all():
        mapping[at.id] = at
        mapping[at.name] = at
    return mapping


def iter_attributes():
    for key, value in get_attributes_map().items():
        if isinstance(key, str):
            yield value


class Attributes:
    def __init__(self, owner, verified=None):
        self.__dict__['owner'] = owner
        self.__dict__['verified'] = verified
        if not hasattr(self.owner, '_a2_attributes_cache'):
            values = {}
            setattr(self.owner, '_a2_attributes_cache', values)
            for atv in self.owner.attribute_values.filter(attribute__disabled=False):
                attribute = get_attributes_map()[atv.attribute_id]
                atv.attribute = attribute
                if attribute.multiple:
                    values.setdefault(attribute.name, []).append(atv)
                else:
                    values[attribute.name] = atv
        self.__dict__['values'] = owner._a2_attributes_cache

    def __setattr__(self, name, value):
        attribute = get_attributes_map().get(name)
        if not attribute:
            raise AttributeError(name)

        with transaction.atomic():
            if attribute.multiple:
                attribute.set_value(self.owner, value, verified=bool(self.verified))
            else:
                atv = self.values.get(name)
                self.values[name] = attribute.set_value(
                    self.owner, value, verified=bool(self.verified), attribute_value=atv
                )

            update_fields = ['modified']
            if name in ['first_name', 'last_name']:
                if getattr(self.owner, name) != value:
                    setattr(self.owner, name, value)
                    update_fields.append(name)
            self.owner.save(update_fields=update_fields)

    def __getattr__(self, name):
        if name not in get_attributes_map():
            raise AttributeError(name)
        atv = self.values.get(name)
        if self.verified and (not atv or not atv.verified):
            return None
        if atv:
            if not isinstance(atv, (list, tuple)):
                return atv.to_python()
            else:
                # multiple
                return [x.to_python() for x in atv]
        return None


class AttributesDescriptor:
    def __init__(self, verified=None):
        self.verified = verified

    def __get__(self, obj, objtype):
        return Attributes(obj, verified=self.verified)


class IsVerified:
    def __init__(self, user):
        self.user = user

    def __getattr__(self, name):
        v = getattr(self.user.attributes, name, None)
        return v is not None and v == getattr(self.user.verified_attributes, name, None)


class IsVerifiedDescriptor:
    def __get__(self, obj, objtype):
        return IsVerified(obj)


class User(AbstractBaseUser, PermissionMixin):
    """
    An abstract base class implementing a fully featured User model with
    admin-compliant permissions.

    Username, password and email are required. Other fields are optional.
    """

    uuid = models.CharField(
        _('uuid'), max_length=32, default=utils_misc.get_hex_uuid, editable=False, unique=True
    )
    username = models.CharField(_('username'), max_length=256, null=True, blank=True)
    first_name = models.CharField(_('first name'), max_length=128, blank=True)
    last_name = models.CharField(_('last name'), max_length=128, blank=True)
    email = models.EmailField(_('email address'), blank=True, max_length=254, validators=[email_validator])
    email_verified = models.BooleanField(default=False, verbose_name=_('email verified'))
    is_staff = models.BooleanField(
        _('staff status'),
        default=False,
        help_text=_('Designates whether the user can log into this admin ' 'site.'),
    )
    is_active = models.BooleanField(
        _('active'),
        default=True,
        help_text=_(
            'Designates whether this user should be treated as '
            'active. Unselect this instead of deleting accounts.'
        ),
    )
    ou = models.ForeignKey(
        verbose_name=_('organizational unit'),
        to='a2_rbac.OrganizationalUnit',
        blank=True,
        null=True,
        swappable=False,
        on_delete=models.CASCADE,
    )

    # events dates
    date_joined = models.DateTimeField(_('date joined'), default=timezone.now)
    modified = models.DateTimeField(verbose_name=_('Last modification time'), db_index=True, auto_now=True)
    last_account_deletion_alert = models.DateTimeField(
        verbose_name=_('Last account deletion alert'), null=True, blank=True
    )
    deactivation = models.DateTimeField(verbose_name=_('Deactivation datetime'), null=True, blank=True)
    deactivation_reason = models.TextField(verbose_name=_('Deactivation reason'), null=True, blank=True)

    objects = UserManager.from_queryset(UserQuerySet)()
    attributes = AttributesDescriptor()
    verified_attributes = AttributesDescriptor(verified=True)
    is_verified = IsVerifiedDescriptor()

    attribute_values = GenericRelation('authentic2.AttributeValue')

    USERNAME_FIELD = 'username'
    REQUIRED_FIELDS = ['email']
    USER_PROFILE = ('first_name', 'last_name', 'email')

    class Meta:
        verbose_name = _('user')
        verbose_name_plural = _('users')
        ordering = ('last_name', 'first_name', 'email', 'username')

    def get_full_name(self):
        """
        Returns the first_name plus the last_name, with a space in between.
        """
        full_name = '%s %s' % (self.first_name, self.last_name)
        return full_name.strip() or self.username or self.email

    def get_short_name(self):
        "Returns the short name for the user."
        return self.first_name or self.username or self.email or self.uuid[:6]

    def email_user(self, subject, message, from_email=None):
        """
        Sends an email to this User.
        """
        send_mail(subject, message, from_email, [self.email])

    def get_username(self):
        "Return the identifying username for this User"
        return self.username or self.email or self.get_full_name() or self.uuid

    def roles_and_parents(self):
        qs1 = self.roles.all()
        qs2 = qs1.model.objects.filter(child_relation__child__in=qs1)
        qs = (qs1 | qs2).order_by('name').distinct()
        RoleParenting = get_role_parenting_model()
        rp_qs = RoleParenting.objects.filter(child__in=qs1)
        qs = qs.prefetch_related(models.Prefetch('child_relation', queryset=rp_qs), 'child_relation__parent')
        qs = qs.prefetch_related(
            models.Prefetch('members', queryset=self.__class__.objects.filter(pk=self.pk), to_attr='member')
        )
        return qs

    def __str__(self):
        human_name = self.username or self.email or self.get_full_name()
        short_id = self.uuid[:6]
        return '%s (%s)' % (human_name, short_id)

    def __repr__(self):
        return '<User: %r>' % str(self)

    def clean(self):
        if not (self.username or self.email or (self.first_name and self.last_name)):
            raise ValidationError(
                _(
                    'An account needs at least one identifier: '
                    'username, email or a full name (first and last name).'
                )
            )

    def validate_unique(self, exclude=None):
        errors = {}

        with errorcollector(errors):
            super().validate_unique(exclude=exclude)

        exclude = exclude or []

        model = self.__class__
        qs = model.objects
        if self.pk:
            qs = qs.exclude(pk=self.pk)

        if (
            'username' not in exclude
            and self.username
            and (app_settings.A2_USERNAME_IS_UNIQUE or (self.ou and self.ou.username_is_unique))
        ):
            username_qs = qs
            if not app_settings.A2_USERNAME_IS_UNIQUE:
                username_qs = qs.filter(ou=self.ou)
            try:
                try:
                    username_qs.get(username=self.username)
                except MultipleObjectsReturned:
                    pass
            except model.DoesNotExist:
                pass
            else:
                errors.setdefault('username', []).append(
                    _('This username is already in use. Please supply a different username.')
                )

        if (
            'email' not in exclude
            and self.email
            and (app_settings.A2_EMAIL_IS_UNIQUE or (self.ou and self.ou.email_is_unique))
        ):
            email_qs = qs
            if not app_settings.A2_EMAIL_IS_UNIQUE:
                email_qs = qs.filter(ou=self.ou)
            try:
                try:
                    email_qs.get(email__iexact=self.email)
                except MultipleObjectsReturned:
                    pass
            except model.DoesNotExist:
                pass
            else:
                errors.setdefault('email', []).append(
                    _('This email address is already in use. Please supply a different email ' 'address.')
                )
        if errors:
            raise ValidationError(errors)

    def natural_key(self):
        return (self.uuid,)

    def has_verified_attributes(self):
        return AttributeValue.objects.with_owner(self).filter(verified=True).exists()

    def to_json(self):
        d = {}
        attributes_map = get_attributes_map()
        for av in AttributeValue.objects.with_owner(self):
            attribute = attributes_map[av.attribute_id]
            drf_field = attribute.get_drf_field()
            d[str(attribute.name)] = drf_field.to_representation(av.to_python())
        d.update(
            {
                'uuid': self.uuid,
                'username': self.username,
                'email': self.email,
                'ou': self.ou.name if self.ou else None,
                'ou__uuid': self.ou.uuid if self.ou else None,
                'ou__slug': self.ou.slug if self.ou else None,
                'ou__name': self.ou.name if self.ou else None,
                'first_name': self.first_name,
                'last_name': self.last_name,
                'is_superuser': self.is_superuser,
                'roles': [role.to_json() for role in self.roles_and_parents()],
                'services': [
                    service.to_json(roles=self.roles_and_parents()) for service in Service.objects.all()
                ],
            }
        )
        return d

    def save(self, *args, **kwargs):
        update_fields = kwargs.get('update_fields')
        rc = super().save(*args, **kwargs)
        if not update_fields or not set(update_fields).isdisjoint({'first_name', 'last_name'}):
            try:
                self.attributes.first_name
            except AttributeError:
                pass
            else:
                if self.attributes and self.attributes.first_name != self.first_name:
                    self.attributes.first_name = self.first_name
            try:
                self.attributes.last_name
            except AttributeError:
                pass
            else:
                if self.attributes.last_name != self.last_name:
                    self.attributes.last_name = self.last_name
        return rc

    def can_change_password(self):
        return True

    def refresh_from_db(self, *args, **kwargs):
        if hasattr(self, '_a2_attributes_cache'):
            del self._a2_attributes_cache
        return super().refresh_from_db(*args, **kwargs)

    def mark_as_active(self):
        self.is_active = True
        self.deactivation = None
        self.deactivation_reason = None
        self.save(update_fields=['is_active', 'deactivation', 'deactivation_reason'])

    def mark_as_inactive(self, timestamp=None, reason=None):
        self.is_active = False
        self.deactivation = timestamp or timezone.now()
        self.deactivation_reason = reason
        self.save(update_fields=['is_active', 'deactivation', 'deactivation_reason'])

    def set_random_password(self):
        self.set_password(base64.b64encode(os.urandom(32)).decode('ascii'))

    @transaction.atomic
    def delete(self, **kwargs):
        deleted_user = DeletedUser(old_user_id=self.id)
        if 'email' in app_settings.A2_USER_DELETED_KEEP_DATA:
            deleted_user.old_email = self.email.rsplit('#', 1)[0]
        if 'uuid' in app_settings.A2_USER_DELETED_KEEP_DATA:
            deleted_user.old_uuid = self.uuid

        # save LDAP account references
        external_ids = self.userexternalid_set.order_by('id')
        if external_ids.exists():
            deleted_user.old_data = {'external_ids': []}
            for external_id in external_ids:
                deleted_user.old_data['external_ids'].append(
                    {
                        'source': external_id.source,
                        'external_id': external_id.external_id,
                    }
                )
            external_ids.delete()
        deleted_user.save()
        return super().delete(**kwargs)


class DeletedUser(models.Model):
    deleted = models.DateTimeField(verbose_name=_('Deletion date'), auto_now_add=True)
    old_uuid = models.TextField(verbose_name=_('Old UUID'), null=True, blank=True)
    old_user_id = models.PositiveIntegerField(verbose_name=_('Old user id'), null=True, blank=True)
    old_email = models.EmailField(verbose_name=_('Old email adress'), null=True, blank=True)
    old_data = JSONField(verbose_name=_('Old data'), null=True, blank=True)

    @classmethod
    def cleanup(cls, threshold=None, timestamp=None):
        threshold = threshold or (
            timezone.now() - datetime.timedelta(days=app_settings.A2_USER_DELETED_KEEP_DATA_DAYS)
        )
        cls.objects.filter(deleted__lt=threshold).delete()

    def __repr__(self):
        return 'DeletedUser(old_id=%s, old_uuid=%s…, old_email=%s)' % (
            self.old_user_id or '-',
            (self.old_uuid or '')[:6],
            self.old_email or '-',
        )

    def __str__(self):
        data = ['#%d' % self.old_user_id]
        if self.old_email:
            data.append(self.old_email)
        return _('deleted user (%s)') % ', '.join(data)

    class Meta:
        verbose_name = _('deleted user')
        verbose_name_plural = _('deleted users')
        ordering = ('deleted', 'id')
