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

import fnmatch
import logging
import os
from statistics import mean, StatisticsError

from colorama import Fore

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 _

import waffle

from extended_choices import OrderedChoices

from mtp.apps.assessments.models import (
    CompetencyTree,
    CertificationProgram,
    Curriculum,
)
from mtp.apps.broker_models_hooks.models import BrokerHookM2MFromMixin
from mtp.apps.common.limpyd_base 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 ..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.
    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 = 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")),
    )

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

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

        """

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

        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 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 open(self):
        """Open this session.

        We assign a token for this session.

        Raises
        ------
        TypeError
            If we try to open a closed session.

        """

        if self.state == self.STATE_CHOICES.CLOSED:
            raise TypeError("It's forbidden to open a session already closed.")

        if self.state != self.STATE_CHOICES.OPEN:
            self.get_or_assign_token()
            self.state = self.STATE_CHOICES.OPEN
            self.save()

    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
        -----
        When closing a session, the following actions are taken:
        - 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.

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

        """

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

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

        return self

    def get_sync_files(self):
        """Get the files who need to be sync with this session

        Returns
        -------
        list(dict)
            A list of dict containing the name of file to sync

        """

        if not self.can_download_photostory_images():
            return []
        else:
            photostory_images_paths = []
            for user in self.users.all():
                photostory_images_paths.extend(
                    [
                        {'name': image_path.replace(settings.MEDIA_ROOT, '').lstrip('/')}
                        for image_path in self.get_photostory_user_image_paths(user, absolute=True)
                    ]
                )
            return photostory_images_paths

    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_photostory_export_url(self):
        """Get the url to export the "photostory" for this session.

        Returns
        -------
        string

        """

        return reverse('trainings:session-photostory-export', 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 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' % self.pk,
            })

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

            session_list_filters = 'training=%s&state=%s' % (
                self.training.original_version_id or self.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():
            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.

        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 photostory_images_path(self, absolute=True):
        """Get the path of the photostory images folder for this session

        Parameters
        ----------
        absolute: bool
            If True, the path will be absolute,
            If False, the path will be relative to the ``MEDIA_ROOT``.

        Returns
        -------
        str
            The path of the photostory images folder for this session

        """

        relative_path = os.path.join(
            'upload/photostory',
            self.training.id,
            self.id
        )

        if absolute:
            return os.path.join(settings.MEDIA_ROOT, relative_path)
        else:
            return os.path.join(settings.MEDIA_URL, relative_path)

    def can_download_photostory_images(self):
        """Test if the current session have any photostory images available for download

        Returns
        -------
        bool
            True if the current session have any photostory images, False otherwise

        """

        photostory_images_path = self.photostory_images_path(absolute=True)

        return (
            os.path.isdir(photostory_images_path) and
            os.listdir(photostory_images_path)
        )

    def get_photostory_user_image_paths(self, user, absolute=True):
        """Get the ``photostory`` image paths for a given user in this session

        Parameters
        ----------
        user: MTPUser
            The user for whom we want the photostory images
        absolute: bool
            If True, the paths will be absolute,
            If False, the paths will be relative to the ``MEDIA_ROOT``.

        Returns
        -------
        list(str)
            A list of photostory image absolute paths

        """

        file_pattern = '_result_session_%(session_uuid)s_ps_user_%(user_uuid)s*' % {
            'session_uuid': self.id,
            'user_uuid': user.id
        }

        try:
            return [
                os.path.join(self.photostory_images_path(absolute=absolute), filename)
                for filename in fnmatch.filter(
                    os.listdir(self.photostory_images_path()),
                    file_pattern
                )
            ]
        except (FileNotFoundError, NotADirectoryError):
            return []


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