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

import random
from collections import defaultdict, OrderedDict
from colorama import Fore
from statistics import mean, StatisticsError

from django.conf import settings
from django.core.management import call_command
from django.core.urlresolvers import reverse
from django.db import models
from django.utils.functional import cached_property
from django.utils.translation import ugettext_lazy as _

from extended_choices import Choices
from pinax.notifications.models import send

from mtp.discrete_apps.hashable.hashers import with_default
from mtp.discrete_apps.hashable.models import Hashable
from mtp.discrete_apps.versionable.exceptions import NotDraftable
from mtp.discrete_apps.versionable.models import (
    Dependency, Versionable,
    PolymorphicModelForDependency,
)

from mtp.apps.accounts.constants import (
    GROUPTYPE_BUSINESS_UNIT, GROUPTYPE_INTERNAL,
    GROUPTYPE_JOB_PROFILE, INTERNAL_GROUP_WHOSWHO,
)
from mtp.apps.accounts.models import (
    Group,
    GroupRole,
    GroupSet,
    GroupType,
    MTPUser,
)
from mtp.apps.assessments.models import (
    AssessableActivity,
    CompetencyTree,
    CertificationProgram,
    Curriculum,
)
from mtp.apps.common.constants import RESOLUTIONS
from mtp.apps.common.limpyd_base import RedisBackedDjangoModel
from mtp.apps.common.models import NonAbstractChildOrderable, Tokenable
from mtp.apps.common.utils import (
    bypass_orderable,
    get_current_timestamp,
    sort_list_of_dicts,
)
from mtp.apps.sync.hooks import pre_sync_hook
from mtp.apps.sync.models import Syncable

from . import constants
from .managers import (
    DependencyTrainingActivityManager,
    TrainingSessionManager,
    VersionableTrainingManager,
)


class Training(RedisBackedDjangoModel, Hashable, Versionable, Syncable):
    """Django model representing a training.

    Attributes
    ----------
    id : django field, instance of ``django_extensions.db.fields.UUIDField``
    owner : django ``ForeignKey``
        The ``MTPUser`` the training belongs to.
    name : django ``CharField``
        The name of the training, mandatory.
    description : django ``TextField``
        Optional description for this training.
    theme : foreign key to ``mtp.apps.themes.models.Theme``
        The theme of the training
    thumbnail_image : django ``ImageField``
        The thumbnail image of the training.
    thumbnail_image_hash : django ``CharField``
        The hash of the image in the ``thumbnail_image`` field.
    header_image : django ``ImageField``
        The header image of the training.
    header_image_hash : django ``CharField``
        The hash of the image in the ``header_image`` field.
    default_group_set : django ``foreignKey`` to ``mtp.apps.accounts.models.GroupSet``
        A list of groups the user will be added to when joining a session of this training.
    dimensions : django ``ManyToManyField`` field.
        The "wheighted" dimensions of the training.
    business_unit : django ``ForeignKey`` to ``mtp.apps.accounts.models.Group``
        The group of user who have access to the training.
    job_profile : foreign key to ``mtp.apps.accounts.models.Group``
        The job_profile associated with this training.
    external_id : django ``Charfield`` field.
        ID of the the training in an external system (eg SABA).

    DuplicateInProgress : Exception
        Exception raised during ``duplicate_to_member`` if the same duplicate is in progress.

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

    """
    owner = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        related_name='trainings',
        verbose_name=_("owner")
    )

    name = models.CharField(max_length=30, verbose_name=_("name"))
    description = models.TextField(blank=True)

    theme = models.ForeignKey('themes.Theme', verbose_name=_("theme"), null=True)

    thumbnail_image = models.ImageField(
        blank=True, null=True, upload_to="training",
        help_text=_('Preferred size: %(preview_width)dx%(preview_height)dpx') % RESOLUTIONS,
        verbose_name=_('Thumbnail Image')
    )
    thumbnail_image_hash = models.CharField(_('Thumbnail Image Hash'), max_length=64, blank=True)

    header_image = models.ImageField(
        blank=True, null=True, upload_to="training",
        help_text=_('Preferred size: %(retina_width)dx%(retina_height)dpx (Retina) '
                    'or %(normal_width)dx%(normal_height)dpx (Normal)') % RESOLUTIONS,
        verbose_name=_('Header Image'))
    header_image_hash = models.CharField(_('Header Image Hash'), max_length=64, blank=True)

    default_group_set = models.ForeignKey(
        'accounts.GroupSet',
        verbose_name=_("default group set"), null=True, blank=True
    )

    dimensions = models.ManyToManyField(
        'assessments.Dimension', blank=True,
        through='assessments.DimensionTrainingWeight'
    )

    business_unit = models.ForeignKey(
        'accounts.Group', null=True, blank=True,
        limit_choices_to={'type__name': GROUPTYPE_BUSINESS_UNIT},
        related_name='trainings_by_business_unit',
        verbose_name=_("Business unit")
    )

    job_profile = models.ForeignKey(
        'accounts.Group', null=True, blank=True,
        help_text=_('If not empty, this is the job profile targeted by this training.'),
        limit_choices_to={'type__name': GROUPTYPE_JOB_PROFILE},
        related_name='trainings_by_job_profile',
    )

    external_id = models.CharField(
        max_length=255,
        blank=True,
        verbose_name=_('External id')
    )

    objects = VersionableTrainingManager()

    hashable_fields = [
        ('header_image', 'header_image_hash',
         with_default(settings.EMPTY_FILE_HASH)),
        ('thumbnail_image', 'thumbnail_image_hash',
         with_default(settings.EMPTY_FILE_HASH))
    ]

    class Meta:
        ordering = ('name',)

    def __str__(self):
        return self.name

    class DuplicateInProgress(Exception):
        """Exception raised during ``duplicate_to_member`` if the same duplicate is in progress."""
        pass

    @cached_property
    def redis_model(self):
        """Returns the ``DjangoBackedRedisModel`` linked to the current one."""

        from .redis_models import Training as RedisTraining

        return RedisTraining

    @cached_property
    def is_assessing(self):
        """Return if this training is assessing or not.

        Notes
        -----
        A training is assessing if it contains at least one assessing activity.

        Returns
        -------
        bool

        """

        return self.activities.assessing().exists()

    def save(self, *args, **kwargs):
        """Create and populate the ``default_grouo_set`` field if empty"""

        if not self.default_group_set:
            default_group_set = GroupSet.objects.create(name="%s - Trainee group set" % self.name)

            group_type_internal, __ = GroupType.objects.get_or_create(
                name=GROUPTYPE_INTERNAL)
            group_whoswho, __ = Group.objects.get_or_create(
                name=INTERNAL_GROUP_WHOSWHO,
                type=group_type_internal,
            )
            self.default_group_set = default_group_set
            self.default_group_set.mgroups.add(group_whoswho)

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

    @property
    def version(self):
        """Return the "version" of the training.

        If the training is editable, we should refresh the version each time,
        to tell the client the contents may have changed.
        If the training is not editable, the content will ne not change anymore,
        so we simply return the id of the training.

        """
        return random.random() if self.is_editable else self.pk

    @cached_property
    def available_activity_types(self):
        """Get the available activity type for this training

        The activity are ordered by their ``verbose_name``.

        Returns
        -------
        list of dict
            A list of dict with three entries:
            - create_url: The url to create the activity type
            - verbose_name: The verbose name of the activity type
            - import_url: The url to create a the activity type from a file

        """
        activity_types = []

        for cls in TrainingActivity.__subclasses__():

            activity_type = {
                'create_url': cls.get_create_url(training=self),
                'verbose_name': cls.verbose_activity_name
            }

            if hasattr(cls, 'get_import_url'):
                activity_type.update({
                    'import_url': cls.get_import_url(training=self)
                })

            activity_types.append(activity_type)

        return sort_list_of_dicts(activity_types, 'verbose_name')

    @property
    def options(self):
        """Get or create the ``TrainingClientOptions`` for this training

        Returns
        -------
        ``TrainingClientOptions`` instance
            The client options for this training.

        """

        options, __ = TrainingClientOptions.objects.get_or_create(training=self)
        return options

    def get_thumbnail_image(self):
        """Get the thumbnail image of the training.

        Returns
        -------
        ImageField
            The ``thumbnail_image`` value from the training if set,
            or the one from the theme if not.

        """

        return self.thumbnail_image or self.theme.thumbnail_image

    def get_header_image(self):
        """Get the header image of the training.

        Returns
        -------
        ImageField
            The ``header_image`` value from the training if set,
            or the one from the theme if not.

        """

        return self.header_image or self.theme.header_image

    def get_header_image_hash(self):
        """Get the header image hash of the training.

        Returns
        -------
        str
            The ``header_image_hash`` value from the training if set,
            or the one from the theme if not.

        """

        return self.header_image_hash or self.theme.header_image_hash

    @cached_property
    def media_files(self):
        """Get a list of the media files for this training

        Returns
        -------
        list(str)
            A list of media path for this training

        """

        file_list = []

        # Add the media from the activities
        for activity in self.activities.non_deleted():
            if hasattr(activity, 'media_files'):
                file_list.extend(activity.media_files)

        # Add the media from the brainstormer activities
        for brainstormer in self.brainstormeractivity_set.all():
            file_list.extend(brainstormer.media_files)

        # Add the media from the associated theme
        file_list.extend(self.theme.media_files)

        return file_list

    def get_survey_url(self):
        """Get the url to edit the survey of this training.

        Returns
        -------
        string

        """

        return reverse('trainings:training-survey', kwargs={'training_uuid': self.pk})

    @classmethod
    def get_list_url(cls):
        """Get the url to display a list of training

        Returns
        -------
        string

        """

        return reverse('trainings:training-list')

    def get_edit_url(self):
        """Get the url to edit the current training instance.

        Returns
        -------
        string

        """

        return reverse('trainings:training-detail', kwargs={'training_uuid': self.pk})

    def get_activity_list_url(self):
        """Get the url to the list the activity of the current training instance.

        Returns
        -------
        string

        """

        return reverse('trainings:activity-list', kwargs={'training_uuid': self.pk})

    def get_activity_add_url(self):
        """Get the url to create a new activity on the current training instance.

        Returns
        -------
        string

        """

        return reverse('trainings:activity-create', kwargs={'training_uuid': self.pk})

    def get_create_draft_url(self):
        """Get the url to create a new draft based on the current training instance.

        Returns
        -------
        string

        """

        return reverse('trainings:training-draft', kwargs={'training_uuid': self.pk})

    def get_publish_version_state_url(self):
        """Get the url to set the ``version_state`` to "publish" on the
        current training instance.

        Returns
        -------
        string

        """

        return reverse('trainings:training-publish', kwargs={'training_uuid': self.pk})

    def get_duplicate_url(self):
        """Get the url to duplicate the current instance to the trainers.

        Returns
        -------
        string

        """

        return reverse('trainings:training-duplicate', kwargs={'training_uuid': self.pk})

    def get_delete_url(self):
        """Get the url to soft-delete the current training instance.

        Returns
        -------
        string

        """

        return reverse('trainings:training-delete', kwargs={'training_uuid': self.pk})

    def get_session_create_url(self):
        """Get the url to create a training session from the current training.

        Returns
        -------
        string

        """

        return reverse('trainings:session-create', kwargs={'training_uuid': self.pk})

    def get_session_list_url(self):
        """ Get the url to list the sessions which belongs to this training.

        Returns
        -------
        string

        """

        return reverse('trainings:session-list', kwargs={'training_uuid': self.pk})

    @classmethod
    def get_create_url(cls):
        """Get the url to create a training.

        Returns
        -------
        string

        Notes
        -----
        As it's not tied to an instance, this method is a ``classmethod``.

        """

        return reverse('trainings:training-create')

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

        Returns
        -------
        string

        Notes
        -----
        As it's not tied to an instance, this method is a ``classmethod``.

        """

        return reverse('trainings:training-list')

    def get_qrcodes_url(self):
        """Get the url to download the training QRCodes able to launch activity.

        Returns
        -------
        string

        """

        return reverse(
            'trainings:download-launch-qrcode-list',
            kwargs={
                'training_uuid': self.pk
            }
        )

    def is_new_draft_allowed(self, **fields):
        """Check if creating a new draft is allowed.

        Parameters
        ----------
        fields : dict
            Some fields to override on the new draft.

        Returns
        -------
        tuple(bool, exception)
            A tuple with two entries:
            1. bool: ``True`` if creating a new draft is allowed, ``False`` otherwise.
            2. ``None`` if the first entry is ``True``, and, if ``False``, an instance of an
               exception to know why the creation of a new draft is not allowed.

        Notes
        -----
        The returned exceptions are:
        - ``NotDraftable`` if the training is not in a state that allow the draft creation
        - ``Training.DuplicateInProgress`` if a draft creation is already in progress.

        """

        if not self.is_draftable:
            return False, NotDraftable(self)

        # We need the owner in the fields, at least to compute the lock key.
        lock_params = dict(fields)
        if 'owner' not in lock_params:
            lock_params['owner'] = self.owner

        # First check if the draft-creation is already running
        if self.redis_instance.is_create_draft_locked(**lock_params):
            return False, self.DuplicateInProgress(_('A draft of this training is currently '
                                                     'being created.'))

        return True, None

    def create_draft(self, async=False, skip_in_progress=False, **fields):
        """Create a new draft the current training.

        Only one draft creation can be run at a given time with the same ``fields`` value, or a
        ``DuplicateInProgress`` exception will be raised.
        If ``fields`` doesn't define a new owner, the current owner is added to it to allow
        locking.

        Parameters
        ----------
        async : bool, default to ``False``
            If ``True``, the draft creation will be done asynchronously, otherwise it will be
            executed directly.
        skip_in_progress : bool, default to ``False``
            If the same duplicate is in progress, a ``DuplicateInProgress`` exception
            will be raised if ``skip_in_progress`` is ``False``, but the process will be skipped if
            ``True``.
        fields : dict
            Some fields to override on the new draft.

        Results
        -------
        Training / AsyncResult
            If ``async`` is ``True``, will return an ``AsyncResult`` from celery.
            If it's ``False``, will return the new duplicated training.

        Raises
        ------
        NotDraftable
            If the training is not in a state that allow the creation of a new draft.
        DuplicateInProgress
            If a draft creation is currently running with the same ``fields`` (including the owner
            if not set in ``fields``), and ``skip_in_progress`` if ``False``.

        Notes
        -----
        When called with ``async=True``, the task ``duplicate_training_to_member`` is delayed.
        This task will then, when executed, call back this current method with ``async=False``.

        """

        allowed, reason = self.is_new_draft_allowed()
        if not allowed:
            # One is in progress, but we are asked to ignore this, so we stop here.
            if skip_in_progress and isinstance(reason, self.DuplicateInProgress):
                return

            # If the training is not draftable, or are not asked to skip if the draft creation
            # is already in progress, we raise the error returned by ``is_new_draft_allowed``.
            raise reason

        if async:
            # Async mode, we delay the task

            from .tasks import create_training_draft

            # Will return an ``AsyncResult`` from celery
            return create_training_draft.delay(
                training_pk=self.pk,
                fields=fields  # Should contain only PKs, no complex objects
            )

        # We need the owner in the fields, at least to compute the lock key.
        lock_params = dict(fields)
        if 'owner' not in lock_params:
            lock_params['owner'] = self.owner

        # We tell the world that we are currently running this draft-creation
        lock_set = self.redis_instance.lock_create_draft(**lock_params)

        # But the world told us that someone is already doing the job, so we skip
        if not lock_set:
            if skip_in_progress:
                return
            raise self.DuplicateInProgress(_('A draft of this training is currently '
                                             'being created.'))

        try:
            with bypass_orderable():  # The ordering is done by copying the values!

                new_training = super().create_draft(**fields)

        finally:
            # We can now tell the world that we are done, so someone could do it again.
            self.redis_instance.release_create_draft_lock(**lock_params)

        return new_training

    def duplicate_to_members(self, replace_published=False, async=False):
        """Duplicate the current training to members that should receive it.

        "members that should receive it" is defined by a call to the ``get_members_to_duplicate``
        method.

        Parameters
        ----------
        replace_published : bool, default to ``False``
            If set to ``True``, users that don't own a group will all have the ``PUBLISHED``
            duplicate, even if they already have one. The existing one will then be archived.
        async : bool, default to False
            If ``True``, each duplicate will be done asynchronously, otherwise it will be executed
            directly.
            This is decided in the ``duplicate_to_member`` method.

        Returns
        -------
        list
            If not async, will return the new duplicated trainings.
            If async, will return a list of ``AsyncResult`` object from celery (one for each
            duplicate to do)

        """

        to_duplicate = self.get_members_to_duplicate(
            replace_published=replace_published,
            use_pks=async
        )

        return [
            self.duplicate_to_member(
                new_state=entry.pop('version_state'),  # Remove key from the dict and return value
                params=entry,  # Don't have "version_state" anymore
                async=async,
                skip_in_progress=True,
            )
            for entry in to_duplicate
        ]

    def duplicate_to_member(self, new_state, params, async=False, skip_in_progress=False):
        """Duplicate the current training to a member defined in ``params``.

        Only one duplicate can be run at a given time with the same ``params`` value, or a
        ``DuplicateInProgress`` exception will be raised.

        Parameters
        ----------
        new_state : string
            Value from ``VERSION_STATES`` to set to the ``version_state`` on the duplicated
            training.
        params : dict
            The fields to set on the training to duplicate. Must at least contain a ``owner`` key.
            If ``async`` is ``True``, entries that represent a model field (FK, One2One...) should
            be the primary keys, not the objects themselves (ie ``owner_id`` with a PK instead of
            ``owner`` with a ``MTPUser`` object)
        async : bool, default to ``False``
            If ``True``, the duplicate will be done asynchronously, otherwise it will be executed
            directly.
        skip_in_progress : bool, default to ``False``
            If the same duplicate is in progress, a ``DuplicateInProgress`` exception
            will be raised if ``skip_in_progress`` is ``False``, but the process will be skipped if
            ``True``.

        Results
        -------
        Training / AsyncResult
            If ``async`` is ``True``, will return an ``AsyncResult`` from celery.
            If it's ``False``, will return the new duplicated training.

        Raises
        ------
        DuplicateInProgress
            If a duplicate is currently running with the same ``params``, and ``skip_in_progress``
            if ``False``.

        Notes
        -----
        When called with ``async=True``, the task ``duplicate_training_to_member`` is delayed.
        This task will then, when executed, call back this current method with ``async=False``.

        """

        # First check if the duplicate is already running
        if self.redis_instance.is_duplicate_to_member_locked(**params):
            if skip_in_progress:
                return
            raise self.DuplicateInProgress(_('The duplicate to this person is currently '
                                             'being created.'))

        if async:
            # Async mode, we delay the task

            from .tasks import duplicate_training_to_member

            # Will return an ``AsyncResult`` from celery
            return duplicate_training_to_member.delay(
                training_pk=self.pk,
                new_state=str(new_state),
                params=params  # Should contain only PKs, no complex objects
            )

        # We tell the world that we are currently running this duplicate
        lock_set = self.redis_instance.lock_duplicate_to_member(**params)

        # But the world told us that someone is already doing the job, so we skip
        if not lock_set:
            if skip_in_progress:
                return
            raise self.DuplicateInProgress(_('The duplicate to this person is currently '
                                             'being created.'))

        try:

            # We can duplicate the training
            with bypass_orderable():  # The ordering is done by copying the values!
                new_training = self.create_new_version(
                    new_state=new_state,
                    params=params
                )

            # Check if we already had published trainings for this user, if he doesn't manage the
            # business_unit, and archive them.
            if new_state == Training.VERSION_STATES.PUBLISHED and \
                    new_training.owner != new_training.business_unit.manager_member:

                existing = (self.original_version or self).all_duplicates.filter_is_published(
                    ).owned_by(new_training.owner
                    ).for_business_unit(new_training.business_unit
                    ).exclude(pk=new_training.pk)

                for training in existing:
                    training.archive()

        finally:
            # We can now tell the world that we are done, so someone could do it again.
            self.redis_instance.release_duplicate_to_member_lock(**params)

        return new_training

    def get_members_to_duplicate(self, replace_published=False, use_pks=False):
        """Returns the member who need to receive a duplicate of the current training.

        The ``Training`` instance will be copied to all the members of the ``business_unit``,
        who don't own an instance with the same ``original_version``.
        Users that don't own a group will have a ``PUBLISHED`` duplicate, while the others will have
        a ``DRAFT`` duplicate.

        Parameters
        ----------
        replace_published : bool, default to ``False``
            If set to ``True``, users that don't own a group will all have the ``PUBLISHED``
            duplicate, even if they already have one. The existing one will then be archived.
        use_pks : bool, default to ``False``
            If set to ``False``, entries from the result dict that represent a model field (FK,
            One2One...) must be objects, but if ``True``, they should be the primary keys, not the
            objects themselves (ie ``owner_id`` with a PK instead of ``owner`` with a ``MTPUser``
            object)
            It must be set to ``True`` when the result is used for asynchronous tasks.

        Returns
        -------
        list
            A list of dict, each entry being one duplicate to do, with these keys:
            - owner : ``MTPUSer``
                The owner of the duplicated training
                If ``use_pks`` is ``True``, the key will instead be ``owner_id`` and the value
                will be the primary key of this user.
            - business_unit : ``Group``
                To fill the ``business_unit`` field of the duplicated training
                If ``use_pks`` is ``True``, the key will instead be ``business_unit_id`` and the
                value will be the primary key of this group.
            - version_state : str
                The ``version_state`` to set on the duplicated training

        """

        # Get all the existing duplicates for this training
        existing_duplicates = (self.original_version or self).all_duplicates.all()

        # Get all the potential owners of a version
        owners = MTPUser.objects.filter(
            is_active=True,
            mgroups=self.business_unit,
            group_roles__role=GroupRole.ROLES.MEMBER,
        )

        # Get the groups for which we have a duplicate for this user
        duplicate_groups_by_user = defaultdict(list)
        for duplicate in existing_duplicates:
            duplicate_groups_by_user[duplicate.owner.pk].append(duplicate.business_unit)

        # Get all the groups owned by the users
        groups_by_user = defaultdict(list)
        for group in GroupRole.objects.filter(user__in=owners, role=GroupRole.ROLES.MANAGER):
            groups_by_user[group.user_id].append(group)

        # Prepare the dict to return
        to_duplicate = []

        # Prepare the result value for each user
        for owner in owners:

            # Get the groups for which we need a duplicate
            # Its the groups in `groups_by_user[user_id]`
            # that are not in `duplicate_groups_by_user[user_id]`
            wanted_groups_for_user = [
                g for g in groups_by_user[owner.pk]
                if g.group not in duplicate_groups_by_user[owner.pk]
            ]

            for group in wanted_groups_for_user:
                to_duplicate.append({
                    ('owner_id' if use_pks else 'owner'):
                        owner.pk if use_pks else owner,
                    ('business_unit_id' if use_pks else 'business_unit'):
                        group.group_id if use_pks else group.group,
                    'version_state': self.VERSION_STATES.DRAFT
                })

            # If the user is not managing any group, he will get one copy,
            # associated with the current ``business_unit``.
            # Only if he don't own one already, or if ``replace_published`` is ``True``

            if groups_by_user[owner.pk]:
                continue

            if self.business_unit in duplicate_groups_by_user[owner.pk] and not replace_published:
                continue

            to_duplicate.append({
                ('owner_id' if use_pks else 'owner'):
                    owner.pk if use_pks else owner,
                ('business_unit_id' if use_pks else 'business_unit'):
                    self.business_unit.pk if use_pks else self.business_unit,
                'version_state': self.VERSION_STATES.PUBLISHED,
            })

        return to_duplicate


class TrainingClientOptions(Dependency, Syncable):
    """Django model representing a set of options for a ``Training``

    Attributes
    ----------
    training : django ``OneToOneField`` to a ``Training`` instance
        The training the options belongs to.
    activate_show_results : django ``BooleanField`` field
        Activate the "show results" menu item for the training
    activate_activity_launcher : django ``BooleanField`` field
        Activate the creation "on the fly" of new activities on the clients

    Notes
    -----
    This model is a ``Dependency`` of the ``Training``.

    """

    training = models.OneToOneField(Training, related_name='client_options')

    activate_show_results = models.BooleanField(
        default=False,
        verbose_name=_('Activate "Show results" interface'),
        help_text=_('"Show results" interface allows the trainer to review '
                    'trainee results based on the competences and qualitatives feedbacks')
    )
    activate_activity_quickcreation = models.BooleanField(
        default=True,
        verbose_name=_('Activate "In-session activity creation"'),
        help_text=_('"In-session activity creation" allows the trainer to create new '
                    'activities during the training session. It includes new photostory, '
                    'new buzzer, new metaplan & new brainstormer')
    )
    activate_qrcode_launcher = models.BooleanField(
        default=False,
        verbose_name=_('Activate "QR code launcher"'),
        help_text=_('"QR code launcher" allows trainees to launch activities when they scan'
                    'QR codes with their device. So activating "QR code launcher" creates a QR'
                    'code for each activity of the training and activate the functionality'
                    '"Code Scanner" on the trainee iPad.')
    )

    versionable_link = (Training, 'training')

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

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

        """
        return reverse('trainings:options-edit', kwargs={
            'options_uuid': self.pk,
            'training_uuid': self.training.pk
        })


class TrainingActivity(AssessableActivity, Dependency, Syncable, NonAbstractChildOrderable,
                       PolymorphicModelForDependency):
    """Base (non-abstract) Django model representing a training activity.

    Attributes
    ----------
    training : django ``ForeignKey`` field
        The training the activity belong to.
    name : django ``CharField``
        The name of the activity, mandatory.
    description : django ``TextField``
        Optional description for this activity.

    Notes
    -----
    This model is a ``Dependency`` of the ``Training``.

    """
    training = models.ForeignKey(
        Training,
        verbose_name=_("training"),
        related_name='activities'
    )

    name = models.CharField(max_length=255, verbose_name=_("name"))
    description = models.TextField(blank=True)

    needs_approval = models.BooleanField(
        default=False,
        verbose_name=_("needs trainer approval"),
        help_text=_("Does the activity needs a trainer approval to be started?")
    )

    versionable_link = (Training, 'training')

    objects = DependencyTrainingActivityManager()

    class Meta:
        unique_together = ('sort_order', 'training')

    def __str__(self):
        return self.name

    def duplicate(self, **kwargs):
        """Override the ``duplicate`` from the ``Dependency``.

        We don't want the training to be duplicated when we duplicate an activity.

        See also
        --------
        mtp/discrete_apps/duplicable/README.rst

        """

        with bypass_orderable():
            return super().duplicate(non_duplicable_relations={'training'}, **kwargs)

    def get_duplicate_url(self):
        """Get the url to duplicate the activity

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

        """
        return reverse('trainings:activity-duplicate', kwargs={
            'activity_uuid': self.pk, 'training_uuid': self.training.pk})

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

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

        """
        return reverse('trainings:activity-delete', kwargs={
            'activity_uuid': self.pk, 'training_uuid': self.training.pk})

    def get_qrcode_data(self):
        """Get data will be stored in the QRCode to launch an activity.

        Returns
        -------
        dict
            Data to insert in the QRCode.

        """
        activity_type = getattr(constants.MODULE_CHOICES, self.__class__.__name__.upper())

        # When the activity is an original version, we return its id in `original_version` property.
        # This hack allow to keep compatibilities with old iPad versions.

        data = {
            'id': self.id,
            'original_version': self.original_version_id or self.id,
            'type': activity_type,
            'action': constants.ACTIVITY_LAUNCH_ACTION
        }

        return data

    def get_qrcode_url(self):
        """Get the url to download the QRCode able to launch activity.

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

        """
        return reverse(
            'trainings:download-launch-qrcode-detail',
            kwargs={
                'activity_uuid': self.pk,
                'training_uuid': self.training.pk
            }
        )


class TrainingSession(Tokenable, RedisBackedDjangoModel, Syncable):
    """Django model representing a training session.

    Attributes
    ----------
    STATE_CHOICES : ``Choices`` instance
        It contains all available types of ``state``:
        - ``NOT_OPEN`` : The session is not open.
        - ``BEFORE`` : The session is open, but no live interaction.
            Only the pre-training content is visible.
        - ``DURING`` : The session is open, and live interaction is possible.
            Only the during-training content is visible.
        - ``AFTER`` : The session is open, but no live interaction.
            Only the post-training content is visible.
        - ``CLOSED`` : The session is closed, and cannot be accessed by trainees or trainer.
    PERMISSION_CHOICES : ``Choices`` instance
        It contains all available types of ``permissions``:
        - ``OPEN`` : The session is visible by all trainers and trainees.
            All the trainees can join.
        - ``APPROVAL_NEEDED`` : The session is visible by all trainers and trainees.
            The trainees (non-attendees) needs a trainer approval to join the session.
        - ``RESTRICTED`` : The session is only visible by the attendees.
            Only the attendees can join.
    training : foreign key to ``mtp.apps.trainings.models.Training``
        The training the session belong to.
    trainer : foreign key to ``mtp.apps.accounts.models.MTPUser``
        The trainer of the session.
    users : django ``ManyToManyField`` to ``mtp.apps.accounts.models.MTPUser``
        The attending trainees of the session.
    start_date : django ``DateTimeField`` field
        The starting date/time of the session.
    end_date : django ``DateTimeField`` field
        The ending date/time of the session.
    state : django ``CharField`` field
        The state of the session, one of STATE_CHOICES.
    permissions : django ``CharField`` field
        The permission of the session, one of PERMISSION_CHOICES.
    average_tablet_usage_time : django ``PositiveIntegerField`` field
        The average of all the trainees time spend during the session.
        See also: ``compute_average_tablet_usage_time`` method.

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

    """

    STATE_CHOICES = Choices(
        ('NOT_OPEN', 'no', _("Not open")),
        ('BEFORE', 'be', _("Before")),
        ('DURING', 'du', _("During")),
        ('AFTER', 'af', _("After")),
        ('CLOSED', 'cl', _("Closed")),
        dict_class=OrderedDict,
    )

    PERMISSION_CHOICES = Choices(
        ('OPEN', 'op', _("Open")),
        ('APPROVAL_NEEDED', 'na', _("Approval needed")),
        ('RESTRICTED', 're', _("Restricted")),
        dict_class=OrderedDict,
    )

    training = models.ForeignKey(
        'Training',
        verbose_name=_("training"),
        related_name='training_sessions'
    )
    trainer = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        related_name="trainer",
        verbose_name=_("trainer")
    )
    users = models.ManyToManyField(
        settings.AUTH_USER_MODEL,
        related_name="training_sessions",
        verbose_name=_("trainees"),
        blank=True
    )
    start_date = models.DateTimeField(db_index=True, verbose_name=_("start date"))
    end_date = models.DateTimeField(verbose_name=_("end date"))

    state = models.CharField(
        _('state'),
        db_index=True,
        choices=STATE_CHOICES,
        max_length=2,
        default=STATE_CHOICES.DURING
    )
    permissions = models.CharField(
        _('permissions'),
        db_index=True,
        choices=PERMISSION_CHOICES,
        max_length=2,
        default=PERMISSION_CHOICES.OPEN
    )

    token = models.CharField(
        _('token'),
        max_length=5,
        unique=True,
        null=True,
        blank=True,
    )

    average_tablet_usage_time = models.PositiveIntegerField(blank=True, null=True)  # in minutes

    objects = TrainingSessionManager()

    class Meta:
        ordering = ('start_date',)

    def __str__(self):
        return _("%(training_name)s: session "
                 "from %(start_date)s created by %(trainer_firstname)s") % {
            'training_name': self.training.name,
            'start_date': self.start_date,
            'trainer_firstname': self.trainer.first_name
        }

    @cached_property
    def redis_model(self):
        """Returns the ``DjangoBackedRedisModel`` linked to the current one."""

        from .redis_models import TrainingSession as RedisTrainingSession

        return RedisTrainingSession

    @property
    def competency_tree(self):
        """Try to get the competency tree linked to the training this session belongs to

        Returns
        -------
        ``mtp.apps.assessments.models.competencies.CompetencyTree`` or None

        """

        try:
            return CompetencyTree.objects.get(job_profile=self.training.job_profile)
        except CompetencyTree.DoesNotExist:
            return None

    @cached_property
    def is_assessing(self):
        """Return if this session is assessing or not.

        Notes
        -----
        A session is assessing if its training is assessing.

        Returns
        -------
        bool

        """

        return self.training.is_assessing

    @cached_property
    def is_closed(self):
        """Return if this session is closed or not.

        Returns
        -------
        bool

        """

        return self.state == self.STATE_CHOICES.CLOSED

    def save(self, *args, **kwargs):
        """Save the instance and create the brainstormers and metaplans if applicable

        If the session is new, we need to create the metaplan and brainstormers
        accordingly to the templates found for the training attached to this session.

        """

        # We keep track if this a new training session
        is_new = False if self.pk else True

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

        # We create a new metaplan and brainstormer only if the session is new.
        if is_new:

            # Frak circular imports.
            from mtp.apps.brainstormers.models import Brainstormer, BrainstormerTemplate
            from mtp.apps.metaplans.models import Metaplan, MetaplanTemplate

            training = self.training

            metaplan_tpls = MetaplanTemplate.objects.non_deleted().for_training(training)
            for metaplan_tpl in metaplan_tpls:
                metaplan = Metaplan(session=self)
                metaplan.save(template=metaplan_tpl)

            brainstormer_tpls = BrainstormerTemplate.objects.filter(
                    is_deleted=False, training=training
            )
            for brainstormer_tpl in brainstormer_tpls:
                brainstormer = Brainstormer(session=self)
                brainstormer.save(template=brainstormer_tpl)

    def close(self, async=False):
        """Shortcut method to close this session.

        Parameters
        ----------
        async : bool
            Default to ``False``.
            If ``True``, the scores computation will be done asynchronously, otherwise it will be
            executed directly.

        Notes
        -----
        We compute assessment scores of an assessing session when we close it.
        And we delete the associated token.

        Returns
        -------
        self : this ``TrainingSession`` instance

        """

        if self.state != self.STATE_CHOICES.CLOSED:
            self.state = self.STATE_CHOICES.CLOSED
            self.save()

            # We compute assessment scores for this session
            if self.is_assessing:
                if async:
                    from . import tasks
                    tasks.compute_assessment_scores.delay(self.pk, force_recompute=True)
                else:
                    self.compute_assessment_scores(force_recompute=True)

        return self

    def get_absolute_url(self):
        """ Get the url of the current training session.

        It actually returns the edit url, as there is no "read" view of a training session.

        Returns
        -------
        string

        """
        return self.get_edit_url()

    def get_edit_url(self):
        """ Get the url to edit the current training session.

        Returns
        -------
        string

        """
        return reverse('trainings:session-detail', kwargs={'session_uuid': self.pk})

    def get_delete_url(self):
        """ Get the url to delete the current training session.

        Returns
        -------
        string

        """
        return reverse('trainings:session-delete', kwargs={'session_uuid': self.pk})

    def get_whoswho_url(self):
        """ Get the url to the training session "whoswho".

        Returns
        -------
        string

        """
        return reverse('trainings:session-whoswho', kwargs={'session_uuid': self.pk})

    def get_brainstormer_create_url(self):
        """Get the url to create a new brainstormer for this session

        Returns
        -------
        string

        """

        return reverse('brainstormers:create', kwargs={'session_uuid': self.pk})

    def get_brainstormer_list_url(self):
        """Get the url to the list of the brainstormers related to this session

        Returns
        -------
        string

        """

        return reverse('brainstormers:list_by_session', kwargs={'session_uuid': self.pk})

    def get_brainstormer_import_url(self):
        """Get the url to import a brainstormer for this session

        Returns
        -------
        string

        """

        return reverse('brainstormers:brainstormer-import-xls', kwargs={'session_uuid': self.pk})

    def get_close_url(self):
        """ Get the url to close the training session.

        Returns
        -------
        string

        """
        return reverse('trainings:session-close', kwargs={'session_uuid': self.pk})

    def get_podium_url(self):
        """Get the url to display a "podium" for this session

        Returns
        -------
        string

        """
        return reverse('trainings:session-podium', kwargs={'session_uuid': self.pk})

    def get_token_url(self):
        """Get the url to display the token for this session

        Returns
        -------
        str

        """

        return reverse('trainings:session-token', kwargs={'session_uuid': self.pk})

    def get_workshop_export_statistics_url(self):
        """Get the url to download the workshops statistics for this session

        Returns
        -------
        string

        """

        return reverse('workshops:export-statistics', kwargs={'session_uuid': self.pk})

    def get_evaluation_export_statistics_url(self):
        """Get the url to download the evaluations statistics for this session

        Returns
        -------
        string

        """

        return reverse('evaluations:export-statistics', kwargs={'session_uuid': self.pk})

    @classmethod
    def get_list_url(cls):
        """Get the url to list the training session.

        Returns
        -------
        string

        Notes
        -----
        As it's not tied to an instance, this method is a ``classmethod``.

        """
        return reverse('trainings:session-list')

    @classmethod
    def get_create_url(cls):
        """Get the url to create a new training session.

        Returns
        -------
        string

        Notes
        -----
        As it's not tied to an instance, this method is a ``classmethod``.

        """
        return reverse('trainings:session-add')

    def set_current(self):
        """Mark this session as being the current one.

        This is probably going to be deprecated when we merge the multisession
        branch, but I need it for my factory.

        """
        try:
            current_session = CurrentSession.objects.all()[0]
        except IndexError:
            current_session = CurrentSession()
        current_session.session = self
        current_session.save()

    def is_attendee(self, user):
        """Shortcut method to know if a given user is in the session users list.

        Parameters
        ----------
        user : instance of ``mtp.apps.accounts.models.MTPUser``
            The user to know if he's in the session list

        Returns
        -------
        bool
            ``True`` if the user is an attendee, ``False`` otherwise

        """

        return self.users.filter(pk=user.pk).exists()

    def is_live(self):
        """Check if there is an active trainer in this session

        We are doing that by checking the existing `last_pings`
        on the ``RedisTrainingSession``

        Returns
        -------
        bool
            ``True`` if the session is "live", ``False`` otherwise

        See also
        --------
        `mtp.apps.accounts.MTPUser.keep_alive` method

        """

        last_pings = self.redis_instance.last_pings
        trainer_session_key = self.trainer.redis_key

        # The trainer key is not in the redis session instance
        if trainer_session_key not in last_pings.zmembers():
            return False
        else:
            join_timestamp = last_pings.zscore(trainer_session_key)

            # The last_ping of the trainer is less than 30 minutes ago
            duration = (get_current_timestamp() - join_timestamp) / 100000
            return duration < settings.SESSION_TRAINER_TIMEOUT

    def send_end_mail(self):
        """Send the end session email notifications."""

        # Notification for the trainees
        send(self.users.all(), "trainee_post_trainingsession", {
            'training': self.training,
        })

        # Notification for the trainer
        send([self.trainer], "trainer_post_trainingsession", {
            'training': self.training,
        })

    def compute_average_tablet_usage_time(self, save=True):
        """Compute the average tablet usage duration for this session.

        Parameters
        ----------
        save : bool, default to ``True``
            If ``True`` the computed ``average_tablet_usage_time`` will be saved

        Returns
        -------
        float
            The computed average tablet usage time

        Notes
        -----
        We are doing so by using the ``redis_instance`` attached to this session.
        It contains all the ping issued by the clients, and the associated date/time.

        This method needs the redis session instance to be alive, and populated.
        Therefore, we need to call it on the "SLAVE" instances.

        See also
        --------
        ``mtp.apps.trainings.management.commands.update_sessions_stats``

        """
        durations = []

        session_data = self.redis_instance
        join_times = session_data.join_times
        last_pings = session_data.last_pings

        for trainee in self.users.all():
            trainee_redis_key = trainee.redis_key

            join_timestamp = join_times.zscore(trainee_redis_key)
            quit_timestamp = last_pings.zscore(trainee_redis_key)

            if join_timestamp is None or quit_timestamp is None:
                continue

            duration = round((quit_timestamp - join_timestamp) / 100000)  # seconds
            durations.append(duration)

        try:
            average_tablet_usage_time = mean(durations)
        except StatisticsError:
            average_tablet_usage_time = 0

        if save:
            self.average_tablet_usage_time = average_tablet_usage_time
            self.save()

        return average_tablet_usage_time

    def get_average_tablet_usage_time(self, save=True):
        """
        Return the average tablet usage time, either by simply fetching the
        previously computed value, or by computing it on demand if it hasn't
        been done before.

        """
        if self.average_tablet_usage_time is not None:
            return self.average_tablet_usage_time
        return self.compute_average_tablet_usage_time(save=save)

    def compute_challenge_score(self, trainee):
        """Compute the scores of the given trainee fo this session

        Parameters
        ----------
        trainee : ``mtp.apps.accounts.models.MTPUser`` instance
            The trainee for whom we want the challenge score

        Returns
        -------
        int
            The trainee's challenge score for this session

        Notes
        -----
        The scores is computed on all the ``TrainingActivity`` children instances
        who implements the ``compute_challenge_score`` method.

        """

        return sum(
            activity.compute_challenge_score(session=self, trainee=trainee)
            for activity in self.training.activities.all()
            if hasattr(activity, 'compute_challenge_score')
        )

    def debrief_challenge_scores(self):
        """Compute all the session's trainees score

        Returns
        -------
        list of dict
            A dict for each trainees of this session

            Example:

            >>> [
            ...     {
            ...         'trainee': 'MTPUser instance',
            ...         'score': 1
            ...     },
            ...     {
            ...         'trainee': 'MTPUser instance',
            ...         'score': 0
            ...     }
            >>> ]

        """

        return [
            {
                'trainee': trainee,
                'score': self.compute_challenge_score(trainee)
            }
            for trainee in self.users.all()
        ]

    def compute_assessment_scores(self, only_trainees=None, force_recompute=False):
        """Compute assessments scores of this session.

        Notes
        -----
        This method allows to compute assessment scores, certifications and recommendations.

        Parameters
        ----------
        only_trainees: list
            The trainee list we want to compute the scores from.
            If is None, we take all users in session.
        force_recompute: bool
            Set to True, to recompute ``CompetencyTreeUserScore``.
            Default value is False.

        """

        trainees = self.users.all() if only_trainees is None else only_trainees

        program = CertificationProgram.objects.get_for_session(self)
        curriculum = Curriculum.objects.get_for_session(self)

        for trainee in trainees:

            # Compute users scores
            self.competency_tree.compute_scores(trainee, self, force_recompute=force_recompute)

            # Compute users certifications
            if program:
                program.compute_trainee_status(trainee, self)

            # Compute users recommendations
            if curriculum:
                curriculum.compute_trainee_recommendations(trainee, self)


# TODO: Is this model still used ?
class CurrentSession(models.Model):
    """Django model representing a ``CurrentSession`"""

    session = models.ForeignKey('TrainingSession')


ACTION_CHOICES = Choices(
    ('OPEN', 'open', 'open'),
    ('POPIN', 'popin', 'popin'),
    ('POPIN_OPEN', 'popin open', 'popin open'),
    ('LOCKED', 'locked', 'locked'),
    ('AUTHORIZATION', 'authorization', 'authorization'),
)

MODULE_CHOICES = Choices(
    ('WHO_IS_WHO', 'who_is_who', 'who_is_who'),
    ('DOCUMENT', 'document', 'document'),
    ('PLAN_META', 'plan_meta', 'plan_meta'),
    ('BUZZER', 'buzzer', 'buzzer'),
    ('SPIDER_GRAPH', 'spider_graph', 'spider_graph'),
)


# TODO: Is this model still used ?
class Action(Syncable):
    """Django model representing an ``Action``"""

    training = models.ForeignKey('Training')
    user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True,
                             verbose_name=_("trainer"))

    action = models.CharField(max_length=20, choices=ACTION_CHOICES)
    module = models.CharField(max_length=40, choices=MODULE_CHOICES)
    module_id = models.CharField(max_length=100)
    mgroups = models.ManyToManyField('accounts.Group')
    external_id = models.CharField(max_length=100)


@pre_sync_hook(settings.INSTANCE_IS_SLAVE)
def update_sessions_stats():
    """Called before a sync to update session statistics, on slave instances."""

    print(Fore.GREEN + '  Compute training session statistics' + Fore.RESET)
    call_command('update_sessions_stats')
    print(Fore.GREEN + '  => DONE!' + Fore.RESET)
