"""This module holds the models of the ``accounts`` app."""

from collections import OrderedDict

import hashlib
import waffle

from django.conf import settings
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin
from django.contrib.auth.signals import user_logged_in
from django.core import validators
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.mail import send_mail
from django.core.urlresolvers import reverse
from django.db import models
from django.dispatch import receiver
from django.utils import timezone
from django.utils.functional import cached_property
from django.utils.translation import ugettext_lazy as _

from extended_choices import Choices

from mtp.apps.assessments.models import CompetencyTree
from mtp.apps.common.utils import get_current_timestamp, upload_to_generator
from mtp.apps.eventlog.helpers import event_log
from mtp.apps.eventlog.models import ACTION_CHOICES as EVENT_TYPES
from mtp.apps.likes.constants import LIKE_DEFAULT_WEIGHT
from mtp.apps.likes.utils import check_likeability
from mtp.apps.sync.models import Syncable
from mtp.apps.trainings.constants import WAITING_ROOM_REDIS_KEY
from mtp.apps.translation import model_fields as translation_fields

from .constants import (
    GROUPTYPE_INTERNAL,
    GROUPTYPE_JOB_PROFILE,
    INTERNAL_GROUP_TRAINEE,
    INTERNAL_GROUP_TRAINER
)
from .managers import (
    GroupManager,
    GroupRoleManager,
    GroupTypeManager,
    GroupTypeQuerySet,
    GroupQuerySet,
    MTPUserManager
)


class Avatar(Syncable):
    img = models.ImageField(upload_to=upload_to_generator, verbose_name=_("Photo"))
    hash = models.CharField(max_length=36, blank=True)
    date = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ('date', )

    def save(self, *args, **kwargs):

        md5 = hashlib.md5()
        for chunk in self.img.chunks():
            md5.update(chunk)
        self.hash = md5.hexdigest()

        super(Avatar, self).save(*args, **kwargs)

    def get_sync_files(self):
        return [{
            'name': self.img.name,
            'hash': self.hash,
        }]


class GroupSet(Syncable):
    name = models.CharField(max_length=200)
    mgroups = models.ManyToManyField('Group')

    def __str__(self):
        return self.name


class GroupType(Syncable):
    """This class allows to group `accounts.Group` objects.

    It will especially be used to create categorization axises for trainer
    groups. For example, a trainer may belong to a `CFR`-typed group, and to
    a `population`-typed group, who have similar functions but different
    semantics. Hence this model.

    """
    name = models.CharField(_('name'), max_length=255, unique=True)
    description = models.TextField(blank=True)
    is_stats_filter = models.BooleanField(default=False,
        help_text=_("Should the groups of this type used as a stats filter?"))
    is_training_filter = models.BooleanField(default=False,
        help_text=_("Should the groups of this type used as a training filter?"))

    objects = GroupTypeManager.from_queryset(GroupTypeQuerySet)()

    def __str__(self):
        return self.name


class Group(Syncable):
    name = models.CharField(max_length=100, verbose_name=_("name"))
    type = models.ForeignKey('GroupType', null=True, blank=True, verbose_name=_("type"))
    parent = models.ForeignKey('self', null=True, blank=True)
    color = models.CharField(max_length=30, blank=True)

    objects = GroupManager.from_queryset(GroupQuerySet)()

    class Meta:
        unique_together = ('name', 'type')

    def __str__(self):
        return "%s - %s" % (self.type, self.name)

    def save(self, *args, **kwargs):

        super().save(*args, **kwargs)

        # If the assessments system is active, we must create
        # a competency tree for each job profile.

        if (self.type and self.type.name == GROUPTYPE_JOB_PROFILE and
                waffle.switch_is_active('enable_assessments')):

            CompetencyTree.objects.get_or_create(
                job_profile=self,
                defaults={
                    'name': self.name
                },
            )

    @property
    def is_job_profile(self):
        return self.type.name == GROUPTYPE_JOB_PROFILE

    @property
    def manager_member(self):
        """Try to get the user manager of the current group.

        Returns
        -------
        ``MTPUser`` instance, or None

        Raises
        ------
        ``django.core.exceptions.MultipleObjectsReturned``
            If muliple ``MTPUser`` instances were returned.

        """
        try:
            return self.member_roles.get(role=GroupRole.ROLES.MANAGER).user
        except GroupRole.DoesNotExist:
            return None

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

    def get_absolute_url(self):
        """ Get the absolute url of the group.

        It actually returns the edit url as there is no "read" view for the user

        Returns
        -------
        string
            The wanted url.

        """

        return self.get_edit_url()

    def get_edit_url(self):
        """ Get the url to edit the group

        Returns
        -------
        string
            The wanted url.

        """

        return reverse('accounts:group-update', kwargs={'group_uuid': self.pk})

    def get_delete_url(self):
        """ Get the url to soft-delete the group

        Returns
        -------
        string
            The wanted url.

        """

        return reverse('accounts:group-delete', kwargs={'group_uuid': self.pk})

    @classmethod
    def get_create_url(cls):
        """ Get the url create a new group

        As it is not tied to an instance, this method is a ``classmethod``.

        Returns
        -------
        string
            The wanted url.

        """
        return reverse('accounts:group-create')

    @classmethod
    def get_list_url(cls):
        """ Get the url to list the groups

        As it is not tied to an instance, this method is a ``classmethod``.

        Returns
        -------
        string
            The wanted url.

        """
        return reverse('accounts:group-list')


class GroupRole(Syncable):
    """Django model representing a role for a given ``MTPUser`` inside a given ``Group``.

    Attributes
    ----------
    ROLES : ``Choices`` instance
        It contains all available types of ``role``:
        - ``MEMBER`` : The user is a basic member of the group.
        - ``MANAGER`` : The user is a manager of the group.
    id : django field, instance of ``django_extensions.db.fields.UUIDField``
    user : foreign key to ``mtp.apps.accounts.models.MTPUser``
    group : foreign key to ``mtp.apps.accounts.models.Group``
    role : django ``CharField`` field
        The role of the user inside the group.

    Notes
    -----
    This model is ``Syncable``.

    """

    ROLES = Choices(
        ('MEMBER', 'me', _("Member")),
        ('MANAGER', 'ma', _("Manager")),
        dict_class=OrderedDict,
    )

    user = models.ForeignKey('MTPUser', related_name='group_roles')
    group = models.ForeignKey('Group', related_name='member_roles')
    role = models.CharField(
        _('role'),
        choices=ROLES,
        max_length=2,
        default=ROLES.MEMBER
    )

    objects = GroupRoleManager()

    class Meta:
        unique_together = ('user', 'group')

    def save(self, *args, **kwargs):
        """Enforce the unicity of given group's manager.

        We are doing so by checking if the linked ``Group`` instance
        already has a member with the ``MANAGER`` role.

        """

        if self.role == self.ROLES.MANAGER and self.group.manager_member and (
            not self.group.manager_member == self.user):
            # A manager already exist in this group
            raise ValidationError('Only one manager is allowed by group.')

        super().save(*args, **kwargs)


class MTPUser(AbstractBaseUser, PermissionsMixin, Syncable):

    USERNAME_LENGTH = 50

    username = models.CharField(_('username'), max_length=USERNAME_LENGTH, unique=True,
        help_text=_('Required. 50 characters or fewer. Letters, digits and '
                    '@/./+/-/_ only.'),
        validators=[
            validators.RegexValidator(r'^[\w.@+-]+$',
                                      _('Enter a valid username. '
                                        'This value may contain only letters, numbers '
                                        'and @/./+/-/_ characters.'), 'invalid'),
        ],
        error_messages={
            'unique': _("A user with that username already exists."),
        })
    first_name = models.CharField(_('first name'), max_length=50, blank=True)
    last_name = models.CharField(_('last name'), max_length=50, blank=True)
    email = models.EmailField(_('email address'), blank=True)
    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.'))
    date_joined = models.DateTimeField(_('date joined'), default=timezone.now)


    mgroups = models.ManyToManyField(
        'Group',
        related_name='members',
        verbose_name=_("MTP Groups"),
        through='GroupRole'
    )

    photo = models.ForeignKey('Avatar', related_name='profile_by_photo', null=True, blank=True)
    photos = models.ManyToManyField('Avatar', related_name='profile_by_photos', blank=True)

    natural_language = translation_fields.LanguageField(
        verbose_name=_('natural language'),
        default=settings.DEFAULT_NATURAL_LANGUAGE,
        help_text=_('Your natural language.'))
    business = models.CharField(max_length=255, blank=True)
    dr = models.CharField(max_length=255, blank=True)

    last_changed_password = models.DateTimeField(default=timezone.now)

    external_id = models.CharField(max_length=255, blank=True)

    old_username = models.CharField(max_length=USERNAME_LENGTH, blank=True)

    objects = MTPUserManager()

    USERNAME_FIELD = 'username'
    REQUIRED_FIELDS = ['email']

    class Meta:
        verbose_name = _('user')
        verbose_name_plural = _('users')

    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()

    def get_short_name(self):
        "Returns the short name for the user."
        return self.first_name

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

    @cached_property
    def managed_groups(self):
        """Get the groups managed by this user

        Returns
        -------
        ``mtp.apps.accounts.managers.GroupQuerySet`` instance
            A django ``QuerySet`` with the groups managed by this user

        """

        return self.mgroups.managed_by(user=self)

    def _get_job_profile(self):
        """Get the job profile of this ``MTPUser``.

        Returns
        -------
        ``Group`` instance or ``None``
            The user's job profile.

        Notes
        -----
        This method is better used indirectly by calling the ``job_profile``
        property below.

        """
        try:
            return self.mgroups.get(type__name=GROUPTYPE_JOB_PROFILE)
        except (Group.DoesNotExist, Group.MultipleObjectsReturned):
            return None

    def _set_job_profile(self, group):
        """Assign a job profile to this ``MTPUser``.

        Parameters
        ----------
        group : instance of ``mtp.apps.accounts.models.Group``
            The job profile to assign to the user.

        Raises
        ------
        ValueError
            If the group that was passed is not actually a job profile.

        Notes
        -----
        This method enforces the unicity of the user's job profile.
        It is better used indirectly by calling the ``job_profile``
        property below.

        """
        if not group.is_job_profile:
            raise ValueError("The specified Group is not a Job Profile.")
        current_job_profiles = self.mgroups.filter(type__name=GROUPTYPE_JOB_PROFILE)
        try:
            GroupRole.objects.get(group__in=current_job_profiles, user=self).delete()
        except (GroupRole.DoesNotExist, GroupRole.MultipleObjectsReturned):
            # The user don't have a job_profile yet
            pass

        GroupRole.objects.create(group=group, user=self)


    def get_absolute_url(self):
        """ Get the absolute url of the user.

        It actually returns the edit url as there is no "ready" view for the user

        Returns
        -------
        string
            The wanted url.

        """

        return self.get_edit_url()

    def get_edit_url(self):
        """ Get the url to edit the user

        Returns
        -------
        string
            The wanted url.

        """

        return reverse('accounts:user-update', kwargs={'user_uuid': self.pk})

    def get_delete_url(self):
        """ Get the url to soft-delete the user

        Returns
        -------
        string
            The wanted url.

        """

        return reverse('accounts:user-delete', kwargs={'user_uuid': self.pk})

    def get_welcome_url(self):
        """ Get the url to send the welcome mail to the user

        Returns
        -------
        string
            The wanted url.

        """

        return reverse('accounts:user-welcome', kwargs={'user_uuid': self.pk})

    @classmethod
    def get_list_url(cls):
        """ Get the url to list the user

        Returns
        -------
        string
            The wanted url.

        """
        return reverse('accounts:user-list')

    @classmethod
    def get_create_url(cls, trainer=False):
        """ Get the url to create a new user

        Returns
        -------
        string
            The wanted url.

        """
        if trainer:
            return "%s?trainer=true" % reverse('accounts:user-create')
        else:
            return reverse('accounts:user-create')

    # This property lets us get and set this user's job profile as if it was
    # an actual field.
    job_profile = property(_get_job_profile, _set_job_profile)

    @property
    def redis_key(self):
        return 'user_%s' % self.id.replace('-', '')

    def save(self, *args, **kwargs):

        is_new = False if self.pk else True

        super(MTPUser, self).save(*args, **kwargs)

        # If the favorite per default Waffle flag is active, we add the
        # selected trainings to the user's favorites
        if waffle.switch_is_active('enable_favorites_trainings_per_default'):
            if is_new:

                # lazy import
                from mtp.apps.trainings.models import Training

                trainings = Training.objects.non_deleted(
                ).filter(
                    models.Q(trainer_groups=None) | models.Q(trainer_groups__in=self.mgroups.all())
                )

                self.favorite_trainings.add(*trainings)

    def get_session_notes(self, session):
        return self.note_set.filter(training_session=session)

    def check_password_expiry(self):
        """
        Check if the user's password has expired, return ``True`` if it has,
        ``False`` otherwise.
        """
        if not settings.PASSWORD_EXTRA_SECURITY:
            return False
        now = timezone.now()
        expiry_delay = settings.PASSWORD_TTL
        expiry_date = self.last_changed_password + timezone.timedelta(days=expiry_delay)
        return now > expiry_date

    def keep_alive(self, session_id=None):
        """Mark this user as still alive.

        They may be in a training session or in the waiting room if no
        ``session_id`` is provided.

        This is achieved by updating the relevant Redis fields with the
        ``user_id'`, scored by the current timestamp, which will then make it
        possible to compute usage statistics.

        """

        # Here to avoid circular imports
        from mtp.apps.trainings.models import TrainingSession as DjangoTrainingSession
        from mtp.apps.trainings.redis_models import TrainingSession as RedisTrainingSession

        user_id = self.redis_key
        timestamp = get_current_timestamp()

        session = None
        try:

            # The user is in a training session. We get the matching Redis
            # object.
            session = DjangoTrainingSession.objects.get(pk=session_id)
            session_state = session.redis_instance

        except DjangoTrainingSession.DoesNotExist:

            # The user is in the waiting room, which we consider as a kind of
            # special, singleton-like session. We get the matching Redis
            # object.
            session_key = WAITING_ROOM_REDIS_KEY
            session_state, __ = RedisTrainingSession.get_or_connect(pk=session_key)

        # If the user hasn't been recorded yet in the session, add them.
        current_user_ids = session_state.join_times.zmembers()
        if user_id not in current_user_ids:
            session_state.join_times.zadd(timestamp, user_id)

            # If this is an _actual_ training session, rather than the waiting
            # room, this is the right moment to log this for statistics.
            if session is not None:
                event_log(subject=self, target=session,
                    action=EVENT_TYPES.SESSION_JOIN)

        # Record the ping anyway as being the current last one.
        session_state.last_pings.zadd(timestamp, user_id)

        return user_id, timestamp

    def get_pools_url(self):
        """ Get the url to list the pools of the current user.

        Returns
        -------
        string
            The wanted url.

        """
        return reverse('surveys:pools-list')

    def is_trainer(self):
        """Tell if the current user is a trainer.

        Returns
        -------
        boolean
            ``True`` if the user is a trainer, or ``False``.

        Notes
        -----
        This is done by checking if the user is in the ``trainer`` group.

        """

        return (
            not self.is_deleted and
            self.is_active and
            self.is_authenticated() and
            self.mgroups.filter(name=INTERNAL_GROUP_TRAINER).exists()
        )

    def is_trainee(self):
        """Tell if the current user is a trainee.

        Returns
        -------
        boolean
            ``True`` if the user is a trainee, or ``False``.

        Notes
        -----
        This is done by checking if the user is in the ``trainee`` group.

        """

        return (
            not self.is_deleted and
            self.is_active and
            self.is_authenticated() and
            self.mgroups.filter(name=INTERNAL_GROUP_TRAINEE).exists()
        )

    def like(self, obj, weight=LIKE_DEFAULT_WEIGHT):
        """ Allow a user to like or dislike an object according the weight.

        Parameters
        ----------
        obj : object
            The object which will be liked by this user.
        weight : Optional[int]
            The weight of this like.

        Raises
        ------
        ValueError via `check_likeability`
            If `obj` is not a likeable object.
        """
        check_likeability(obj)
        obj.likes.create(author=self, weight=weight)

    def unlike(self, obj):
        """ Allow a user to unlike an object.

        Parameters
        ----------
        obj : object
            The object which will be unliked by this user.

        Raises
        ------
        ValueError via `check_likeability`
            If `obj` is not a likeable object.

        Notes
        -----
        There cannot be more than one Like per author/object couple.
        """
        check_likeability(obj)
        like = obj.likes.for_author(self)
        if like.exists():
            like.get().remove()

    @classmethod
    def test_if_superuser(cls, user):
        """Class method to test if a user a an authenticated and active superuser.

        This is intended to be used with the ``user_passes_test`` from django.

        Example
        -------
        >>> from django.contrib.auth.decorators import user_passes_test
        >>> from mtp.apps.accounts.models import MTPUser
        >>>
        >>> @user_passes_test(MTPUser.test_is_superuser)
        >>> def function_based_view(request):
        >>>     pass  # do something
        ...
        >>> from django.utils.decorators import method_decorator
        >>> from django.views import generic
        >>>
        >>> class ClassBasedView(generic.View):
        >>>
        >>>     @method_decorator(user_passes_test(MTPUser.test_is_superuser))
        >>>     def dispatch(self, request, *args, **kwargs):
        >>>         super().dispatch(request, *args, **kwargs)
        ...

        """

        # check
        return (
            user.is_superuser and
            user.is_active and
            user.is_authenticated() and
            (not isinstance(user, cls) or not user.is_deleted)
        )

    @classmethod
    def test_if_superuser_or_trainer(cls, user):
        """Class method to test if a user a an authenticated and active superuser or trainer.

        This is intended to be used with the ``user_passes_test`` from django.

        Example
        -------
        >>> from django.contrib.auth.decorators import user_passes_test
        >>> from mtp.apps.accounts.models import MTPUser
        >>>
        >>> @user_passes_test(MTPUser.test_is_superuser_or_trainer)
        >>> def function_based_view(request):
        >>>     pass  # do something
        ...
        >>> from django.utils.decorators import method_decorator
        >>> from django.views import generic
        >>>
        >>> class ClassBasedView(generic.View):
        >>>
        >>>     @method_decorator(user_passes_test(MTPUser.test_is_superuser_or_trainer))
        >>>     def dispatch(self, request, *args, **kwargs):
        >>>         super().dispatch(request, *args, **kwargs)
        ...

        """

        # check
        return cls.test_if_trainer(user) or (
            user.is_superuser and
            user.is_active and
            user.is_authenticated() and
            (not isinstance(user, cls) or not user.is_deleted)
        )

    @classmethod
    def test_if_trainer(cls, user):
        """Class method to test if a user is an authenticated and active trainer.

        This is intended to be used with the ``user_passes_test`` from django.

        Example
        -------
        >>> from django.contrib.auth.decorators import user_passes_test
        >>> from mtp.apps.accounts.models import MTPUser
        >>>
        >>> @user_passes_test(MTPUser.test_is_trainer)
        >>> def function_based_view(request):
        >>>     pass  # do something
        ...
        >>> from django.utils.decorators import method_decorator
        >>> from django.views import generic
        >>>
        >>> class ClassBasedView(generic.View):
        >>>
        >>>     @method_decorator(user_passes_test(MTPUser.test_is_trainer))
        >>>     def dispatch(self, request, *args, **kwargs):
        >>>         super().dispatch(request, *args, **kwargs)
        ...

        """

        return isinstance(user, cls) and user.is_trainer()

    @classmethod
    def test_if_trainee(cls, user):
        """Class method to test if a user is an authenticated and active trainee.

        This is intended to be used with the ``user_passes_test`` from django.

        Example
        -------
        >>> from django.contrib.auth.decorators import user_passes_test
        >>> from mtp.apps.accounts.models import MTPUser
        >>>
        >>> @user_passes_test(MTPUser.test_is_trainee)
        >>> def function_based_view(request):
        >>>     pass  # do something
        ...
        >>> from django.utils.decorators import method_decorator
        >>> from django.views import generic
        >>>
        >>> class ClassBasedView(generic.View):
        >>>
        >>>     @method_decorator(user_passes_test(MTPUser.test_is_trainee))
        >>>     def dispatch(self, request, *args, **kwargs):
        >>>         super().dispatch(request, *args, **kwargs)
        ...

        """

        return isinstance(user, cls) and user.is_trainee()

@receiver(user_logged_in)
def user_logged_in_handler(user, **kwargs):
    """This handler `redispatch` signals coming from
    the django auth app to the handler in ``mtp.apps.eventlog`` app.
    """

    if 'request' in kwargs:
        # If we got a request object coming along with the event
        if 'cms' in kwargs['request'].GET.get('next', ''):
            # We got the keyword 'cms' in the query, the event come from the cms
            event_log(sender=user, subject=user, action=EVENT_TYPES.CMS_LOGIN)
        elif '/api/' in kwargs['request'].META.get('PATH_INFO', ''):
            # We got the 'api' keyword in the query, the event come from the api
            event_log(sender=user, subject=user, action=EVENT_TYPES.APP_LOGIN)
