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

from statistics import mean, StatisticsError

from django.conf import settings
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 mtp.apps.assessments.models import AssessableActivityPart
from mtp.apps.common.limpyd_base import BaseModel as LimpydBaseModel, RedisBackedDjangoModel
from mtp.apps.common.models import Orderable
from mtp.apps.sync.models import Syncable
from mtp.apps.trainings.models import Training, TrainingActivity
from mtp.discrete_apps.versionable.models import Dependency, PolymorphicModelForDependency

from .managers import (
    DependencyWorkshopStepManager,
    WorkshopStepQuestionAnswerManager,
    WorkshopStepQuestionAnswerQuerySet,
    WorkshopStepQuestionUserAnswerManager,
)


class Workshop(RedisBackedDjangoModel, TrainingActivity):
    """Django model representing a workshop.

    Attributes
    ----------
    is_broadcastable : django ``BooleanField`` field
        Can the workshop be broadcasted to the trainees devices ?
    is_steps_order_random : django ``BooleanField`` field
        Is the workshop's steps should be displayed in a random order ?
    are_questions_mandatory : django ``BooleanField`` field
        Must the questions of the workshop be all answered?
    is_questions_answers_order_random : django ``BooleanField`` field
        Should the answers of the workshop be displayed in a random order ?
    needs_approval : django ``BooleanField`` field
        Do the trainees need an approval from a trainer to open the workshop ?
    enable_score : django ``BooleanField`` field
        Do we want to enable the scoring on this workshop ?

    Notes
    -----
    This model inherits from ``TrainingActivity`` and therefore is a ``Dependency`` of
    the ``Training`` model.

    """
    is_broadcastable = models.BooleanField(default=False, verbose_name=_("broadcastable"))
    is_steps_order_random = models.BooleanField(default=False,
                                                verbose_name=_("randomize the steps order"))
    are_questions_mandatory = models.BooleanField(default=False, verbose_name=_(
        "questions are mandatory"), help_text=_("Does the questions must be mandatory ?"))
    is_questions_answers_order_random = models.BooleanField(  # pylint: disable=invalid-name
        default=False, verbose_name=_("randomize the questions answers order"))
    enable_score = models.BooleanField(default=False, verbose_name=_("with score"))

    verbose_activity_name = _('Quiz')

    def __str__(self):
        return self.name

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

        from .redis_models import Workshop as RedisWorkshop

        return RedisWorkshop

    @cached_property
    def activity_type(self):
        """Return a verbose name for this activity type."""

        return _('Workshop')

    @cached_property
    def available_step_types(self):
        """Get the available step types

        Returns
        -------
        list(dict)

        """

        return [
            {
                'create_url': cls.get_create_url(workshop=self),
                'verbose_name': cls.verbose_step_name
            }
            for cls in WorkshopStep.__subclasses__()
        ]

    @cached_property
    def assessing_parts(self):
        return WorkshopStepQuestion.objects.filter(workshop=self).all()

    @cached_property
    def media_files(self):
        """Get a list of media file paths for this document

        Returns
        -------
        list(str)
            A list of media relative paths for this document

        """

        return [
            question_image.image.name
            for question_image in WorkshopStepImage.objects.filter(workshop=self).non_deleted()
        ]

    def get_trainee_session_score(self, session, trainee):
        """Get the score of this workshop for the given session and trainee

        The score of the workshop is the sum of all the points the trainee got
        from the workshop's assessing parts.

        Parameters
        ----------
        session: TrainingSession
            The session for which we want the score
        trainee: MTPUser
            The trainee for which we want the score

        Returns
        -------
        int
            The trainee's score for this workshop

        """

        return sum([
            self.redis_instance.get_trainee_score(session.pk, trainee.pk, part.id) or 0
            for part in self.assessing_parts
        ])

    def get_training_session_average_score(self, session):
        """Compute the average score of this workshop for the given session

        Parameters
        ----------
        session: ``TrainingSession`` instance.
            The session we want to get the average score for.

        Returns
        -------
        float
            The average trainee score for this workshop

        """

        try:
            return mean([
                self.get_trainee_session_score(session, trainee)
                for trainee in session.users.non_deleted()
            ])
        except StatisticsError:
            return 0

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

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

        """
        return self.get_detail_url()

    def get_detail_url(self):
        """ Get the url to view the current workshop.

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

        """
        return reverse('workshops:workshop-detail', kwargs={'workshop_uuid': self.pk})

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

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

        """
        return reverse('workshops:workshop-update', kwargs={'workshop_uuid': self.pk})

    def get_steps_sort_url(self):
        """ Get the url to sort the steps of current workshop.

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

        """
        return reverse('workshops:steps-sort', kwargs={'workshop_uuid': self.pk})

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

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

        """
        return reverse('trainings:workshop-create', kwargs={'training_uuid': training.pk})

    @classmethod
    def get_import_url(cls, training):
        """ Get the url of to import a new workshop.

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

        """
        return reverse(
            'workshops:import-xls',
            kwargs={'training_uuid': training.pk}
        )

    def get_export_url(self):
        """Get the url of to export a workshop

        Returns
        -------
        string or None
            The wanted url or None if the Workshop is not exportable.

        """

        if self.steps.instance_of(WorkshopStepImage).exists():
            return None
        else:
            return reverse(
                'workshops:export-xls',
                kwargs={'workshop_uuid': self.pk}
            )


class WorkshopStep(Dependency, PolymorphicModelForDependency, Syncable, Orderable):
    """Django model representing a workshop step.

    Attributes
    ----------
    title : django ``CharField`` field.
        The title of the step.
    description : django ``CharField`` field
        The description of the step.
    workshop : foreign key to ``mtp.apps.workshops.models.Workshop``
        The workshop this step belongs to.

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

    """
    title = models.CharField(max_length=255, verbose_name=_("title"))
    description = models.CharField(
        max_length=500,
        blank=True,
        verbose_name=_('Description')
    )

    workshop = models.ForeignKey('Workshop', related_name='steps')

    versionable_link = (Training, 'workshop__training')
    non_lockable_fields = Dependency.non_lockable_fields + Syncable.non_lockable_fields

    objects = DependencyWorkshopStepManager()

    class Meta:
        unique_together = ('sort_order', 'workshop')
        ordering = ['sort_order']

    def __str__(self):
        return self.title

    def get_filtered_manager(self):
        """Override the ``get_filtered_manager`` from the ``Orderable`` parent

        We want the "sort_order" of all the step for the current workshop,
        not only for the current child class.

        """

        return self.workshop.steps

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

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

        """
        return reverse('workshops:step-delete', kwargs={
            'workshop_uuid': self.workshop.pk, 'step_uuid': self.pk})


class WorkshopStepImage(WorkshopStep):
    """Django model representing a workshop image step.

    Attributes
    ----------
    image : django ``ImageField``
        The image of the step.

    """
    image = models.ImageField(upload_to="workshops", verbose_name=_('Image'))

    step_name = 'image'
    verbose_step_name = _('Image')

    def get_absolute_url(self):
        """ Get the url of the current image step.

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

        """
        return self.get_edit_url()

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

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

        """
        return reverse('workshops:step-image-update', kwargs={
            'workshop_uuid': self.workshop.pk, 'step_uuid': self.pk})

    @classmethod
    def get_create_url(cls, workshop):
        """ Get the url to create a new image step.

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

        """
        return reverse('workshops:step-image-create', kwargs={'workshop_uuid': workshop.pk})


class WorkshopStepQuestion(AssessableActivityPart, WorkshopStep):
    """Django model representing a workshop question step.

    Attributes
    ----------
    is_multiple_choices : django ``BooleanField`` field
        Are the multiple answers possible for this question ?
    is_answers_order_random : django ``BooleanField`` field
        Should the answers for this question be displayed in a random order ?

    """

    is_multiple_choices = models.BooleanField(
        default=False,
        verbose_name=_("is multiple answer question")
    )
    is_answers_order_random = models.BooleanField(
        default=False,
        verbose_name=_("randomize the answers order")
    )
    is_all_or_nothing = models.BooleanField(
        default=False,
        verbose_name=_("trainee must find all the right answers to get the points")
    )

    step_name = 'question'
    verbose_step_name = _('Question')

    class Meta:
        ordering = ['sort_order']

    @property
    def is_single_choice(self):
        return not self.is_multiple_choices

    @cached_property
    def is_open(self):
        """Determine if this question is an open one

        The question is considered as "open" if there is no predefined answers set

        Returns
        -------
        bool
            True if this question is considered as "open", False otherwise

        """

        return not self.answers.non_deleted().exists()

    def is_valid_for_trainee(self, trainee, session):
        """Return the question "validity" accordingly to the provided user and session.

        A question is known as "valid", when the user got at least good answer on it (with the
        ``is_correct`` flag).
        If the question got the ``all_or_nothing`` flag, then the user must got all, and only the
        "valid" answers for the question to be valid.

        Parameters
        ----------
        trainee: MTPUser
            The trainee for whom we want to know if this question is considered as a "valid" one.
        session: TrainingSession
            The session in which the trainee answered this question.

        Returns
        -------
        bool
            True if this question is valid else False.

        """

        trainee_answers = self.user_answers.non_deleted().filter(
            user=trainee,
            training_session=session,
        )

        if self.is_all_or_nothing:
            correct_answers = self.answers.non_deleted().filter(
                is_correct=True
            ).values_list('pk', flat=True)

            return set(trainee_answers.values_list('answer__pk', flat=True)) == set(correct_answers)
        else:
            return trainee_answers.filter(answer__is_correct=True).exists()

    def compute_trainee_session_score(self, trainee, session):
        """Compute the session score of the trainee for this question.

        Parameters
        ----------
        trainee: MTPUser
            The user which has answered to the question.
        session: TrainingSession
            The training session in which the user participates.

        Returns
        -------
        int
            The question score of the trainee

        """

        if self.is_all_or_nothing:
            if self.is_valid_for_trainee(trainee, session):
                # If the 'all_or_nothing' question is valid so only good answers have been answered.
                # The score is the points sum of good answers.
                return WorkshopStepQuestionAnswer.objects.non_deleted().filter(
                    question=self,
                    is_correct=True
                ).aggregate_points()
            else:
                # It's all or nothing, the question is invalid for this trainee
                # so NOTHING (GAME OVER!)
                return 0
        else:
            # It's a normal question, the score is the points sum of user answer.
            return WorkshopStepQuestionUserAnswer.objects.non_deleted().filter(
                user=trainee,
                question=self,
                training_session=session
            ).aggregate_points()

    def save_user_answers(self, user, session, answers):
        """Save new user answers for this question.

        Parameters
        ----------
        user: MTPUser
            The user which has answered to the question.
        session: TrainingSession
            The training session in which the user participates.
        answers: iterator(WorkshopStepQuestionAnswer or str)
            The answers chosen by the user. We can receive answer or text (open question).

        """

        # We lock the user answers save according to the current question, user and session.
        # The first request will wait to finish to run the second.
        # So only the last request will be recognised.
        lock_key = 'lock-%s-%s-%s' % (self.pk, user.pk, session.pk)
        with LimpydBaseModel.database.connection.lock(lock_key, timeout=1, sleep=0.01):

            # We reset old answers before saving the new answers
            self.reset_user_answers(user, session)

            for answer_or_text in answers:

                # Ugly hack to check if we have an existing answer or a free answer (text)
                if isinstance(answer_or_text, WorkshopStepQuestionAnswer):
                    answer = answer_or_text
                    text = ''
                else:
                    answer = None
                    text = answer_or_text

                # We create the new user answer
                user_answer, created = WorkshopStepQuestionUserAnswer.objects.get_or_create(
                    training_session=session,
                    user=user,
                    question=self,
                    answer=answer,
                    defaults={
                        'text': text,
                    }
                )

                if not created:
                    # If the user answer already exist, we soft-undelete it if needed
                    user_answer.unremove(commit=False)

                    # And we update the text if it's an open question
                    if answer is None:
                        user_answer.text = text

                    # Now we can save the soft-undeletion and/or the text
                    user_answer.save()

            # Compute and save the score
            self.save_trainee_session_score(user, session)

    def reset_user_answers(self, user, session):
        """Reset all user answers for this question according the provided user and session.

        Parameters
        ----------
        user: MTPUser
            The user whose answers are going to be deleted.
        session: TrainingSession
            The training session in which the user participates.

        """

        user_answers_to_remove = WorkshopStepQuestionUserAnswer.objects.filter(
            training_session=session,
            user=user,
            question=self,
        )

        for user_answer in user_answers_to_remove:
            user_answer.remove()

    def save_trainee_session_score(self, user, session):
        """Record the score for the provided user and session

        Parameters
        ----------
        user: MTPUser
            The user whose the score will be computed.
        session: TrainingSession
            The training session in which the user participates.

        """

        score = self.compute_trainee_session_score(user, session)
        self.workshop.redis_instance.save_trainee_score(
            session.id,
            user.id,
            self.id,
            score
        )

        # We record assessment score if needed
        if self.should_be_assessed:
            super().record_assessment_score(
                user=user,
                session=session,
                points=score
            )

    def get_trainee_session_score(self, user, session):
        """Get the score for a given trainee and session

        If the score is ``None``, we recompute it.

        Parameters
        ----------
        user: MTPUser
            The user whose the score will be retrieved.
        session: TrainingSession
            The training session in which the user participates.

        Returns
        -------
        int
            The score

        See Also
        --------
        ``mtp.apps.workshops.redis_models.Workshop.get_trainee_score``

        """

        score = self.workshop.redis_instance.get_trainee_score(
            session.id,
            user.id,
            self.id
        )

        if score is None:
            score = self.compute_trainee_session_score(user, session)
            self.workshop.redis_instance.save_trainee_score(
                session.id,
                user.id,
                self.id,
                score
            )

        return score

    def get_session_average_score(self, session):  # pylint: disable=missing-param-doc,missing-type-doc
        """Get the average session score for this question.

        Parameters
        ----------
        session: TrainingSession
            The session we want to get the average score for.

        Returns
        -------
        float
            The average question score for the provided session

        """

        try:
            return mean([
                self.get_trainee_session_score(trainee, session)
                for trainee in session.users.non_deleted()
            ])
        except StatisticsError:
            return 0

    @cached_property
    def max_points_yield(self):
        """The maximum number of points a user can obtain with this answer

        Returns
        -------
        int
            The sum/max of the this question's answers points

        """

        valuable_answers = self.answers.non_deleted().filter(points__gt=0)
        if not valuable_answers.exists():
            return 0

        answer_points = valuable_answers.values_list('points', flat=True)
        if self.is_single_choice:
            return max(answer_points)
        else:
            return sum(answer_points)

    def get_actual_activity(self):
        return self.workshop

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

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

        """
        return self.get_edit_url()

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

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

        """
        return reverse('workshops:step-question-update', kwargs={
            'workshop_uuid': self.workshop.pk, 'step_uuid': self.pk})

    def get_create_answer_url(self):
        """ Get the url to create a new answer for this question.

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

        """
        return reverse('workshops:question-answer-create', kwargs={
            'question_uuid': self.pk})

    def get_answers_sort_url(self):
        """ Get the url to sort the answers of the current question.

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

        """
        return reverse('workshops:question-answers-sort', kwargs={'question_uuid': self.pk})

    @classmethod
    def get_create_url(cls, workshop):
        """ Get the url to create a new question.

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

        """
        return reverse('workshops:step-question-create', kwargs={'workshop_uuid': workshop.pk})


class WorkshopStepQuestionAnswer(Dependency, Syncable, Orderable):
    """Django model representing a workshop question answer.

    Attributes
    ----------
    label : django ``CharField`` field
        The label for this answer.
    color : django ``CharField`` field
        The color for this answer (displayed on client's devices)
    is_correct : django ``BooleanField`` field
        Is the answer correct ?
    points : django ``IntegerField`` field
        The number of points this answer can give.
    question : foreign key to ``mtp.apps.workshops.models.WorkshopStepQuestion``
        The question instance this answer belongs to.

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

    """
    label = models.CharField(max_length=500, verbose_name=_("answer"))
    color = models.CharField(max_length=50, null=True, blank=True, verbose_name=_("color"))
    is_correct = models.BooleanField(default=False, verbose_name=_("correct"))
    points = models.IntegerField(
        default=0,
        verbose_name=_('points')
    )

    question = models.ForeignKey('WorkshopStepQuestion', related_name='answers')

    objects = WorkshopStepQuestionAnswerManager.from_queryset(
        WorkshopStepQuestionAnswerQuerySet)()

    versionable_link = (Training, 'question__workshop__training')
    non_lockable_fields = Dependency.non_lockable_fields + Syncable.non_lockable_fields

    class Meta:
        unique_together = ('sort_order', 'question')
        ordering = ['sort_order']

    def is_selected(self, trainee, training_session):
        """Get the selection status of this answer for a given trainee / session

        Parameters
        ----------
        trainee: ``MTPUser`` instance
            The trainee for whom we want to know if the answer was selected
        training_session: ``TrainingSession`` instance
            The training session in which we want to know if the answer was selected

        Returns
        -------
        bool
            True if the answer was selected by the given trainee in the given session,
            False otherwise

        """

        return WorkshopStepQuestionUserAnswer.objects.filter(
            user=trainee,
            question=self.question,
            answer=self,
            training_session=training_session
        ).non_deleted().exists()

    def session_rate(self, training_session):
        """Get the answer rate for the provided session

        The "rate" is the percentage of user who picked this answer in the provided session

        Parameters
        ----------
        training_session: TrainingSession
            The session for which we want the rate.

        Returns
        -------
        float
            The percentage of the user who picked this answer

        """

        # All the user answers for the question of this answer
        question_answers = WorkshopStepQuestionUserAnswer.objects.non_deleted().filter(
            training_session=training_session,
            question=self.question
        )

        # The number of user who picked this specific answer
        question_answer_number = question_answers.filter(
            answer=self
        ).count()

        # The number of trainees who answered to the question linked of this answer
        answered_trainees_number = question_answers.values('user').distinct().count()

        try:
            return question_answer_number / answered_trainees_number * 100
        except ZeroDivisionError:
            return 0

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

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

        """
        return self.get_edit_url()

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

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

        """
        return reverse('workshops:question-answer-update', kwargs={
            'question_uuid': self.question.pk, 'answer_uuid': self.pk})

    def get_delete_url(self):
        """ Get the url to delete the answer.

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

        """
        return reverse('workshops:question-answer-delete', kwargs={
            'question_uuid': self.question.pk, 'answer_uuid': self.pk})


class WorkshopStepQuestionUserAnswer(Syncable):
    """Django model representing a workshop question answer.

    Attributes
    ----------
    id : django field, instance of ``django_extensions.db.fields.UUIDField``
    user : foreign key to ``mtp.apps.accounts.models.MTPUser``
        The user who made this answer.
    answer : foreign key to ``mtp.apps.workshops.models.WorkshopStepQuestionAnswer``
        The workshop answer this user answer belongs to.
    text : django ``TextField`` field
        The user text answer for the question.
    training_session : foreign key to ``mtp.apps.trainings.models.TrainingSession``
        The training session in which this user's answer was created.

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

    """
    user = models.ForeignKey(settings.AUTH_USER_MODEL)
    question = models.ForeignKey('WorkshopStepQuestion', related_name='user_answers')
    answer = models.ForeignKey('WorkshopStepQuestionAnswer', null=True)
    training_session = models.ForeignKey('trainings.TrainingSession')

    text = models.TextField()

    objects = WorkshopStepQuestionUserAnswerManager()

    class Meta:
        unique_together = (('user', 'answer', 'training_session', 'question'),)

    def save(self, *args, force_assessment_record=False, **kwargs):
        """Check that only one non-deleted answer is save when the question is unique.

        Parameters
        ----------
        force_assessment_record: bool
            If True, we force the record of the related question assessment score.

        Raises
        ------
        TypeError
            When we try to save many answers for a single choice question.

        """

        # No need to do the check for a deleted answer
        if not self.is_deleted and self.question.is_single_choice:

            existing_user_answers = self.__class__.objects.filter(
                question=self.question,
                user=self.user,
                training_session=self.training_session,
                is_deleted=False,
            )
            if self.pk:
                existing_user_answers = existing_user_answers.exclude(pk=self.pk)

            if existing_user_answers.exists():
                raise TypeError("Only one user answer is authorized for single choice question.")

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

        if force_assessment_record:
            self.question.record_assessment_score(self.user, self.training_session)
