"""This module provides ``WorkshopStepQuestion`` + the related models for the ``workshops`` app"""

from statistics import mean, StatisticsError

import pandas as pd

from django.conf import settings
from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist
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
from mtp.apps.common.models import Orderable
from mtp.apps.data_export.constants import EXCEL_STYLE_CHOICES
from mtp.apps.sync.models import Syncable
from mtp.apps.trainings.models import Training
from mtp.discrete_apps.versionable.models import Dependency

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

from . import WorkshopStep


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_multiple_choices = models.BooleanField(
        default=False,
        verbose_name=_("is multiple answer question")
    )
    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):
        """Determine if the question is a single choice

        Returns
        -------
        bool
            True if the question is a single choice,
            False otherwise

        """

        return not self.is_multiple_choices

    @property
    def serializer_class(self):
        """Get the serializer class to use for this model

        This is a property to avoid recursive import problem.

        """

        from ..apiv2.serializers import WorkshopStepQuestionSerializer

        return WorkshopStepQuestionSerializer

    @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 get_results_excel_data(self, session):
        """Get the session users answers for this question

        Parameters
        ----------
        session: TrainingSession
            The training session we want to get the results from

        Returns
        -------
        list((panda.Series, styles,))
            A list of tuple with two entries:
            - A pandas ``Series`` containing the details of the each users answers
            - One of the ``EXCEL_STYLE_CHOICES`` choice. The style to apply to this column.

        """

        # The question is an "open" one, we store the user's text answers in a "Verbatim" column
        if self.is_open:
            trainee_answers = []
            for user in session.users.non_deleted():
                try:
                    answer = WorkshopStepQuestionUserAnswer.objects.get_open_answer_for_question(
                        question=self,
                        training_session=session,
                        trainee=user
                    ).text
                except (ObjectDoesNotExist, MultipleObjectsReturned):
                    answer = ''

                trainee_answers.append(answer)

            return [
                (
                    pd.Series(trainee_answers, name=str(_('Verbatim')), ),
                    EXCEL_STYLE_CHOICES.DEFAULT
                )
            ]

        # The question is not an "open" one, we store the user's selected answers.
        # We will get one ``Series`` (one column) for each available answer of this question.
        else:
            results_data = list()
            for answer in self.answers.non_deleted():

                if answer.is_correct:
                    answer_style = EXCEL_STYLE_CHOICES.WORKSHOP_STEP_ANSWER_VALID
                else:
                    answer_style = EXCEL_STYLE_CHOICES.DEFAULT

                results_data.append(
                    (
                        pd.Series(
                            [
                                'x' if answer.is_selected(user, session) else ''
                                for user in session.users.non_deleted()
                            ],
                            name=answer.label
                        ),
                        answer_style
                    )
                )

            return results_data

    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.

        Parameters
        ----------
        workshop: Workshop
            The workshop for which we want to create a new question step.

        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',
        on_delete=models.CASCADE
    )

    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
            The trainee for whom we want to know if the answer was selected
        training_session: TrainingSession
            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, on_delete=models.CASCADE)
    question = models.ForeignKey(
        'WorkshopStepQuestion',
        related_name='user_answers',
        on_delete=models.CASCADE
    )
    answer = models.ForeignKey(
        'WorkshopStepQuestionAnswer',
        null=True,
        on_delete=models.CASCADE
    )
    training_session = models.ForeignKey(
        'trainings.TrainingSession',
        on_delete=models.CASCADE
    )

    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.

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

        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)

