"""This module provides the ``TrainingSession`` model for the ``trainings`` app"""

import logging
from contextlib import contextmanager
from statistics import mean, StatisticsError

from colorama import Fore

from django.conf import settings
from django.core.management import call_command
from django.core.exceptions import ValidationError
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 _

import waffle

from extended_choices import OrderedChoices

from mtp.apps.assessments.models import (
    CompetencyTree,
    CertificationProgram,
    Curriculum,
)
from mtp.apps.brainstormers.constants import BRAINSTORMER_TYPE_CHOICES
from mtp.apps.broker_models_hooks.models import BrokerHookM2MFromMixin
from mtp.apps.common.limpyd_base.django_models import RedisBackedDjangoModel
from mtp.apps.common.models import Tokenable
from mtp.apps.common.notifications.tasks import send_async
from mtp.apps.sync.hooks import pre_sync_hook
from mtp.apps.sync.models import Syncable

from ..duplicators import LiveDependencyDuplicator
from ..managers import TrainingSessionManager


logger = logging.getLogger('mtp.apps.trainings')


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

    Attributes
    ----------
    STATE_CHOICES : ``Choices`` instance
        It contains all available types of ``state``:
        - ``NOT_YET_OPEN`` : The session is not yet open.
        - ``OPEN`` : The session is open, and live interaction is possible.
        - ``CLOSED`` : The session is closed, and cannot be accessed by trainees or trainer.
    PERMISSION_CHOICES : ``Choices`` instance
        It contains all available types of ``permissions``:
        - ``PUBLIC`` : The session is visible by all trainers and trainees.
        - ``APPROVAL_NEEDED`` : The session is visible by all trainers and trainees.
            The trainees (non-attendees) needs a trainer approval to join the session.
        - ``PRIVATE`` : The session is only visible by the registered users.
    VALID_STATE_CHANGES : dict
        It define, for each existing state of the session, which other states are
        allowed. The key ``None`` is used as source when creating a session
    CHECK_STATE_CHANGES: bool
        Default to ``True``, this will enforce the check of the changes of state.
        Should not be updated manually, but only via the ``bypass_session_state_check``
        context manager, mainly in tests, or in sync.

    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.

    loaded_state: str
        The value of the ``state`` field when the object is loaded
        from database. It is used to check if a change of state is
        allowed.
    async_close: bool
        Not set by default, must be set to ``True`` on a session for which we want to run the post
        session actions asynchronously when we don't have the hand on the ``save`` method to pass
        the ``async_close`` attribute to ``True (like with DRF serializers)

    For the parameters, see ``django.db.Model`` (related to `*args and **kwargs of the ``__init__``)

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

    """

    STATE_CHOICES = OrderedChoices(
        ('NOT_YET_OPEN', 'no', _("Ready for launch")),
        ('OPEN', 'op', _("Ongoing")),
        ('CLOSED', 'cl', _("Closed")),
    )

    PERMISSION_CHOICES = OrderedChoices(
        ('PUBLIC', 'pu', _("Public")),
        ('APPROVAL_NEEDED', 'na', _("Approval needed")),
        ('PRIVATE', 'pr', _("Private")),
    )

    VALID_STATE_CHANGES = {
        None: {STATE_CHOICES.NOT_YET_OPEN, STATE_CHOICES.OPEN},
        STATE_CHOICES.NOT_YET_OPEN: {STATE_CHOICES.OPEN, STATE_CHOICES.CLOSED},
        STATE_CHOICES.OPEN: {STATE_CHOICES.CLOSED},
        STATE_CHOICES.CLOSED: set(),
    }
    CHECK_STATE_CHANGES = True

    training = models.ForeignKey(
        'Training',
        verbose_name=_("training"),
        related_name='training_sessions',
        on_delete=models.CASCADE
    )
    trainer = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        related_name="trainer",
        verbose_name=_("trainer"),
        on_delete=models.CASCADE
    )
    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.NOT_YET_OPEN
    )
    permissions = models.CharField(
        _('permissions'),
        db_index=True,
        choices=PERMISSION_CHOICES,
        max_length=2,
        default=PERMISSION_CHOICES.PRIVATE
    )

    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 __init__(self, *args, **kwargs):
        """Save the ``state`` at load time

        For the parameters, see ``django.db.models.Model``

        """

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

        self.loaded_state = self.state

    def refresh_from_db(self, *args, **kwargs):
        """Save the ``state`` at load time.

        For the parameters, see ``django.db.models.Model``

        """

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

        self.loaded_state = self.state

    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

    @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.

        Parameters
        -----------
        kwargs: dict
            In other additional kwargs expected by ``save``, we add:
            async_close: bool
                When closing a session, we may want to execute post-session actions
                asynchronously, which is done by passing ``True`` to this argument,
                or by setting the instance attribute of the same name to ``True``

        For the other parameters, see ``django.db.models.Model``

        Raises
        ------
        ValidationError
            If we try to change the state in one not accepted (defined in ``VALID_STATE_CHANGES``)
            If ``end_date`` is anterior to ``start date``

        """

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

        # Extract our own stuff from kwargs
        async_close = kwargs.pop('async_close', getattr(self, 'async_close', False))

        # check changes of state
        previous_state = None if is_new else self.loaded_state
        if self.CHECK_STATE_CHANGES and self.state != previous_state:
            if self.state not in self.VALID_STATE_CHANGES[previous_state]:
                raise ValidationError(
                    "It's not possible to change a session state "
                    "from `%(old_state)s` to `%(new_state)s`" % {
                        'old_state': previous_state,
                        'new_state': self.state,
                    },
                    code='invalid-state-change'
                )

        if self.end_date < self.start_date:
            raise ValidationError(
                {'text': ("End date cannot be earlier than start date")}
            )

        if self.average_tablet_usage_time is not None:
            try:
                assert self.average_tablet_usage_time >= 0
            except AssertionError:
                # may happen if the internal clock of the box was reset during the session
                # lifetime :-/
                logger.exception(
                    'Session `%s` had a wrong value for `average_tablet_usage_time`: %s ',
                    self.pk,
                    self.average_tablet_usage_time,
                )
                self.average_tablet_usage_time = 0

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

        # we update ``loaded_state`` to the new value of the state
        self.loaded_state = self.state

        # 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

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

        # When the state changes, we may have some action to execute
        if self.state != previous_state:
            if self.state == self.STATE_CHOICES.OPEN:
                self._after_open()
            # The "after_close" method should not be called on "SLAVE" instances
            elif self.state == self.STATE_CHOICES.CLOSED and settings.INSTANCE_IS_MASTER:
                self._after_close(async_close)

    def open(self):
        """Shortcut method to open this session.

        Notes
        -----
        This simply updates the state and calls ``save``: it's the save that will do the check
        if the session can be opened and then run some actions if needed.

        """

        self.state = self.STATE_CHOICES.OPEN
        self.save()

    def _after_open(self):
        """Called when a session is opened to create a token"""

        self.get_or_assign_token()

    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
        -----
        This simply updates the state and calls ``save``, passing ``async_close`` with the
        value of ``async``: it's the save that will do the check if the session can be closed
        and then run post-session actions if needed.

        """

        self.state = self.STATE_CHOICES.CLOSED
        self.save(async_close=async)

    def _after_close(self, async=False):
        """Called when a session is closed to run post-session actions, on master

        - Compute the assessment scores if the session is an assessing one.
        - Send a mail to the trainees to let them know the availability of the
            post-session content.

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

        """

        # Compute the 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)

        # Send a mail to let the participants know that the post-session content is available
        self.send_end_mail()

    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, brainstormer_type):
        """Get the url to create a new brainstormer for this session

        Parameters
        ----------
        brainstormer_type : string
            one of ``BRAINSTORMER_TYPE_CHOICES`` values


        Returns
        -------
        string

        """

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

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

        Returns
        -------
        string

        """
        return self.get_brainstormer_create_url(BRAINSTORMER_TYPE_CHOICES.PROPOSITIONWALL)

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

        Returns
        -------
        string

        """
        return self.get_brainstormer_create_url(BRAINSTORMER_TYPE_CHOICES.QUESTIONWALL)

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

        Returns
        -------
        string

        """

        return self.get_brainstormer_create_url(BRAINSTORMER_TYPE_CHOICES.WORDCLOUD)

    def get_brainstormer_type_list_urls(self):
        """Get urls to list of brainstormer activities type.

        Like brainstorming, questionwalls and wordcloud

        Returns
        -------
        list

        """
        return [
            {
                'display': choice.display,
                'url': reverse(
                    'brainstormers:list_by_session',
                    kwargs={
                        'session_uuid': self.pk,
                        'brainstormer_type_slug': choice.value
                    }
                )
            }
            for choice in BRAINSTORMER_TYPE_CHOICES.entries
        ]

    def get_photostory_list_url(self):
        """Get the url to the list of the photostories related to this session.

        Returns
        -------
        string

        """

        return reverse('photostories:photostory-list-by-session', kwargs={
            'session_uuid': self.id
        })

    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_export_results_url(self):
        """Get the url to download the results of this session

        Returns
        -------
        string

        """

        return reverse('trainings:session-results-export', 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 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()

    @cached_property
    def is_live(self):
        """Return True if the session is ``OPEN``

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

        .. note:: Deprecated in MTP 3.4
                  ``is_live`` property will be removed in MTP 3.5.

        """

        # If the session is not ``OPEN``, don't need to check if the session is live.
        return self.state == self.STATE_CHOICES.OPEN

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

        # Notification for the trainees, if applicable
        if waffle.switch_is_active('enable_sessions_end_mail_for_trainees'):
            send_async.delay(self.users.all(), "trainee_post_trainingsession", {
                'training': self.training,
                'session': self,
                'button_url': '/app/#/archives/%s/activities' % self.pk,
            })

        # Notification for the trainer, if applicable
        if waffle.switch_is_active('enable_sessions_end_mail_for_trainers'):

            # The training_id filter on the session list be set to the original version
            # - IF the actual training is a duplicate
            # - AND if the actual trainer is the owner of the original training
            try:
                assert self.training.original_version.owner == self.trainer
                training_id = self.training.original_version_id
            except (AttributeError, AssertionError):
                training_id = self.training.id

            session_list_filters = 'training=%s&state=%s' % (
                training_id,
                self.state,
            )

            send_async.delay([self.trainer], "trainer_post_trainingsession", {
                'training': self.training,
                'session': self,
                'button_url': '%s?%s' % (
                    TrainingSession.get_list_url(),
                    session_list_filters,
                ),
            })

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

            join_timestamp = join_times.zscore(trainee.pk)
            quit_timestamp = last_pings.zscore(trainee.pk)

            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.

        Parameters
        ----------
        save: bool
            When ``True`` (the default), the session will be saved after the usage computation

        """
        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 get_trainee_score(self, trainee):
        """Get the scores of the given trainee fo this session

        Parameters
        ----------
        trainee : MTPUser
            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 ``get_trainee_session_score`` method.

        """

        return sum(
            activity.get_trainee_session_score(session=self, trainee=trainee)
            for activity in self.training.activities.all()
            if hasattr(activity, 'get_trainee_session_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.get_trainee_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.

        """

        # Try to set the lock on the assessment computation
        lock_set = self.redis_instance.acquire_assessment_computation_lock()

        # The computation is already running for this session !
        if not lock_set:
            return

        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)

        # Finally, release the lock
        self.redis_instance.release_assessment_computation_lock()

    def bypass_state_check(self):
        """Shortcut for ``bypass_session_state_check`` for the current session"""

        return bypass_session_state_check(session=self)


class LiveDependencyMixin:
    """Mixin for the ``Dependencies`` of a training which maybe linked to a session

    This mixin slightly change the ``duplicable`` behaviour on the model who inherit from it.

    See Also
    --------
    ``mtp.apps.trainings.duplicators.LiveDependencyDuplicator``

    """

    instance_duplicator_class = LiveDependencyDuplicator

    @property
    def versionable_instance(self):
        """Return None if this instance is linked to a ``TrainingSession``

        By doing this, we avoid the lock in a "live" context.

        """

        if self.session:
            return None
        else:
            return super().versionable_instance


@contextmanager
def bypass_session_state_check(session=None):
    """Context manager allowing to update the sessions states without any check

    Must not be used on a regular basis, but for example in sync

    Parameters
    ----------
    session: TrainingSession
        If given, the bypass of the checks will only occur for this session.
        Else it will occurs for all sessions

    """

    obj = session or TrainingSession

    old_value = obj.CHECK_STATE_CHANGES
    obj.CHECK_STATE_CHANGES = False

    try:
        yield
    finally:
        if obj is TrainingSession:
            obj.CHECK_STATE_CHANGES = old_value
        else:
            # if it's a session, we don't set the value back on the instance, but we
            # delete it to let it use again the value defined at the class level
            del obj.CHECK_STATE_CHANGES


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