"""This module holds the managers for the models of the ``surveys`` app."""

from django.core.exceptions import ValidationError
from django.db import models
from django.db.utils import IntegrityError
from django.utils.translation import ugettext_lazy as _


class NonDeletedManagerMixin:
    """Mixin for managers to retrieve only non-deleted instance of the tied  model.

    It's for models based on the ``Syncable`` one. In this case it is not really deleted by the
    application, which use ``remove`` instead of ``delete`` to delete an instance, setting its
    ``is_deleted`` field to ``True``.

    Example
    -------
    >>> # get all the pools
    >>> Pool.objects.all()
    ... # [...]
    >>> # get only the non-deleted ones
    >>> Pool.non_deleted.all()
    ... # [...]

    Notes
    -----
    See ``NonDeletedPoolsManager```for how to use this mixin.

    """

    def get_queryset(self):
        """Queryset excluding the deleted instances.

        Returns
        -------
        Queryset of the tied model.
            A lazy queryset without the deleted pools.

        """

        return super().get_queryset().exclude(is_deleted=True)


class PoolsManager(models.Manager):
    """Manager used by the ``Pool`` model"""

    def filter_for_trainer(self, trainer, include_globals=True, with_questions_count=False,
                           only_with_questions=False):
        """Get all pools for the given trainer.

        This includes the global ones if wanted, and the ones created by the trainer themselves,
        in this order.

        Parameters
        ----------
        trainer : ``mtp.apps.accounts.MTPUser`` instance
        include_globals : boolean, default to ``True``
            If ``True``, includes all global pools. Else returns only the ones the trainer is the
            author of.
        with_questions_count : boolean, default to ``False``
            If ``True``, each pool will have a new entry, ``nb_questions`` with the number of
            questions for this pool.
        only_with_questions : boolean, default to ``False``
            If ``True``, includes only pools that have questions. If ``False``, all pools are
            included.
            Settings this to ``True`` implies ``with_questions_count`` to be ``True``, even if it
            is passed with ``False``

        Returns
        -------
        Queryset of ``mtp.apps.surveys.models.Pool``
            A lazy queryset with the wanted pools.

        """

        if include_globals:
            queryset = self.filter(
                models.Q(is_global=True) | models.Q(is_global=False, author=trainer)
            ).order_by('-is_global')
        else:
            queryset = self.filter(author=trainer)

        if with_questions_count or only_with_questions:
            queryset = QuestionsManager.annotate_queryset_with_nb_questions(
                queryset, only_with_questions=only_with_questions)

        return queryset

    def filter_for_admin(self, admin, include_others=True, with_questions_count=False,
                         only_with_questions=False):
        """Get all pools for the given admin.

        This includes all the global ones if wanted, or only its own.
        in this order.

        Parameters
        ----------
        admin : ``mtp.apps.accounts.MTPUser`` instance
        include_others : boolean, default to ``True``
            If ``True``, includes all global pools. Else returns only the ones the admin is the
            author of.
        with_questions_count : boolean, default to ``False``
            If ``True``, each pool will have a new entry, ``nb_questions`` with the number of
            questions for this pool.
        only_with_questions : boolean, default to ``False``
            If ``True``, includes only pools that have questions. If ``False``, all pools are
            included.
            Settings this to ``True`` implies ``with_questions_count`` to be ``True``, even if it
            is passed with ``False``

        Returns
        -------
        Queryset of ``mtp.apps.surveys.models.Pool``
            A lazy queryset with the wanted pools.

        """

        if include_others:
            queryset = self.filter(is_global=True)
        else:
            queryset = self.filter(author=admin)

        if with_questions_count or only_with_questions:
            queryset = QuestionsManager.annotate_queryset_with_nb_questions(
                queryset, only_with_questions=only_with_questions)

        return queryset

    def count_valid_for_user(self, user):
        """Returns the number of available pools for a user (trainer or admin).

        This count global pools and the ones owned by the user. But only the pools that have
        questions.

        Parameters
        ----------
        user : ``mtp.apps.accounts.MTPUser`` instance

        Returns
        -------
        integer
            The number of available pools for the given user

        """

        return self.filter_for_trainer(
            trainer=user,
            include_globals=True,
            only_with_questions=True
        ).count()

    def filter_for_survey(self, survey, only_with_questions=True, only_displayed=False):
        """Get all pools for the given survey.

        The globals pools are returned first, then the others.

        Parameters
        ----------
        admin : ``mtp.apps.accounts.MTPUser`` instance
        only_with_questions : boolean, default to ``True``
            If ``True``, includes only pools that have questions. If ``False``, all pools are
            included.
        only_displayed : boolean, default to ``False``
            If ``True``, only the pools marked as ``displayed`` by the owner of the survey are
            included. If ``False``, the ones not marked as ``displayed`` are included too.

        Returns
        -------
        Queryset of ``mtp.apps.surveys.models.Pool``
            A lazy queryset with the wanted pools.

        """

        filters = {
            'surveypool__survey': survey,
        }

        if only_displayed:
            filters['surveypool__is_displayed'] = True

        queryset = self.filter(**filters).order_by('-is_global')

        queryset = QuestionsManager.annotate_queryset_with_nb_questions(
            queryset,
            only_with_questions=only_with_questions,
        )

        return queryset

    def count_questions_for_pool(self, pool, force=False):
        """Returns the number of questions for the given pool.

        The value will also be saved in the ``nb_questions`` attribute of the pool.

        Parameters
        ----------
        pool : ``mtp.apps.surveys.models.Pool`` instance
            The pool for which we want the number of questions.
        force : boolean, default to ``False``
            If the number was already computed and stored in the ``nb_questions`` attribute of the
            pool, passing ``force=True`` will recompute this value. If ``False``, it will simply
            return the already computed value.

        Returns
        -------
        integer
            The number of questions tied to the given pool.

        """
        from .models import Question
        # Use a previously saved value, for example by ``annotate``.
        if force or not hasattr(pool, 'nb_questions'):
            pool.nb_questions = Question.non_deleted.filter(pool=pool).count()
        return pool.nb_questions


class NonDeletedPoolsManager(NonDeletedManagerMixin, PoolsManager):
    """Manager to retrieve only non-deleted instance of the ``Pool`` model."""
    pass


class QuestionsManager(models.Manager):
    """Manager used by the ``Question`` model"""

    def filter_for_pool(self, pool):
        """Get all questions for the given pool.

         Parameters
         ----------
         pool : ``mtp.apps.surveys.Pool`` instance

        Returns
        -------
        Queryset of ``mtp.apps.surveys.models.Question``
            A lazy queryset with the wanted questions.

        """

        return self.filter(pool=pool)

    @classmethod
    def annotate_queryset_with_nb_questions(cls, queryset, prefix='questions__',
                                            only_with_questions=False):

        queryset = queryset.annotate(
            nb_questions=models.Sum(models.Case(
                models.When(**{'%sis_deleted' % prefix: False, 'then': 1}),
                default=0,
                output_field=models.IntegerField()
            )),
        )

        if only_with_questions:
            queryset = queryset.filter(nb_questions__gt=0)
        else:
            # The use of ``annotate`` and ``Case`` force the query to use a ``INNER JOIN`` on the
            # relation with the ``Question`` model. But we want the count of questions for all
            # pools, even ones with no questions at all, so we transform the join to a
            # ``LEFT OUTER JOIN``.
            from .models import Question
            queryset.query.promote_joins([Question._meta.db_table])

        return queryset


class NonDeletedQuestionsManager(NonDeletedManagerMixin, QuestionsManager):
    """Manager to retrieve only non-deleted instance of the ``Questions`` model."""


class SurveysManager(models.Manager):
    """Manager used by the ``Survey`` model"""

    def get_or_create_for_training(self, training, trainer):
        """Get, or create, a survey for a training, for a specific trainer.

        If created, the surveys is filled with pools:
        - all the global pools
        - all the non-global pools created by the given trainer.

        If the survey already existed, all the pools that wasn't yet in the survey will be added.

        All the added pools are marked as ``displayed``. The trainer will have to change it
        manually fo the pools he owns, or the global ones that are not mandatory.

        Parameters
        ----------
        training : ``mtp.apps.trainings.models.Training`` instance
        trainer : ``mtp.apps.accounts.MTPUser`` instance
            It must be in the ``trainer`` group.

        Returns
        -------
        tuple
            0 : Instance of ``mtp.apps.surveys.models.Survey``
                    The newly created survey.
            1 : boolean
                ``True`` if the survey was just created, ``False`` otherwise.

        Raises
        ------
        django.core.exceptions.ValidationError
            If the given trainer is not in the ``trainer`` group.

        """

        # Import in the method to avoid circular imports.
        from .models import Pool

        # Check that the user is really a trainer.
        if not trainer.is_trainer:
            raise ValidationError(_('The user is not a trainer.'))

        # If we don't have any pool, we don't create/return a survey.
        if not Pool.objects.count_valid_for_user(trainer):
            return None, False

        # We start by trying to get, and create if needed, the wanted survey.
        survey, created = self.get_or_create(training=training, trainer=trainer)

        if created:
            # The survey was just created, so we can add all all pools (globals first,
            # then locals to the trainer).
            pools = Pool.non_deleted.filter_for_trainer(trainer,
                                                        include_globals=True,
                                                        with_questions_count=False)

            # We cannot use bulk_create because it does not call the ``save`` method, and there is
            # one in the ``Syncable`` model, so we have to add each pool one by one.
            for pool in pools:
                survey.add_pool(pool)

        else:
            # The survey already existed, we'll just add the missing pools.

            # Update pools that are now mandatory that were not displayed before
            for survey_pool in survey.surveypool_set.filter(is_deleted=False,
                                                            pool__is_deleted=False,
                                                            pool__is_mandatory=True,
                                                            is_displayed=False):
                survey_pool.is_displayed = True
                survey_pool.save()

            # Get the list of pools already included.
            existing_pools = survey.pools.values_list('id', flat=True)

            # Get all the pools we want to have, except the ones we already have.
            new_pools = Pool.non_deleted.filter_for_trainer(
                trainer, include_globals=True, with_questions_count=False
            ).exclude(id__in=existing_pools)

            # Then we can add the missing pools.
            for pool in new_pools:
                # We mark the pool as recently added to let the trainer see it by setting
                # ``is_new`` to ``True``
                survey.add_pool(pool, is_new=True)

        # Set the link to external objects to avoid queries.
        survey.training = training
        survey.trainer = trainer

        # We return the newly created survey,and if it was just created or not.
        return survey, created


class SurveyPoolsManager(models.Manager):

    def add(self, survey, pool, **kwargs):
        # Add the pool in the survey, and ignore ``IntegrityError``:
        # It's possible that this same function was called by another request.
        # We don't want this one to fail so if the pool was already added to the
        # survey, just ignore the error and continue.
        try:
            return self.get_or_create(survey=survey, pool=pool, defaults=kwargs)
        except IntegrityError:
            pass

    def count_questions_for_survey_pool(self, survey_pool, force=False):
        from .models import Pool
        # Use a previously saved value, for example by ``annotate``.
        if force or not hasattr(survey_pool, 'nb_questions'):
            survey_pool.nb_questions = Pool.objects.count_questions_for_pool(survey_pool.pool,
                                                                             force)
        return survey_pool.nb_questions

    def filter_for_survey(self, survey, only_with_questions=True, only_displayed=False):

        filters = {
            'survey': survey,
            'pool__is_deleted': False,
        }

        if only_displayed:
            filters['is_displayed'] = True

        queryset = self.filter(**filters).select_related('pool')

        queryset = QuestionsManager.annotate_queryset_with_nb_questions(
            queryset,
            prefix='pool__questions__',
            only_with_questions=only_with_questions,
        )

        return queryset


class NonDeletedSurveyPoolsManager(NonDeletedManagerMixin, SurveyPoolsManager):
    """Manager to retrieve only non-deleted instance of the ``SurveyPool`` model."""
    pass


class ChoicesManager(models.Manager):

    def filter_for_question(self, question):
        return self.filter(
            question=question
        )


class NonDeletedChoicesManager(NonDeletedManagerMixin, ChoicesManager):
    """Manager to retrieve only non-deleted instance of the ``Choices`` model."""
    pass


class AnswerManager(models.Manager):

    def save_answer(self, session, survey, trainee, question_pk, choice_pks=None, text=None):
        """Save an answer with data from the API.

        Parameters
        ----------
        session : instance of ``mtp.apps.trainings.models.TrainingSession``
            The session in which the user answer a survey question.
        survey : instance of ``mtp.apps.surveys.models.Survey``, optional
            The survey for which the user answer a question.
            If not given, will be fetched based on the session.
        trainee : instance of ``mtp.apps.account.models.MTPUser``
            The trainee that answered the question. Must be a trainee.
        question_pk : uuid
            PK of the answered question
        choice_pk : list of uuid, optional
            PK of the choice made by the user if it's not an open question.
        text : str, optional
            Text typed by the user if it's an open question.

        Returns
        -------
        instance of ``mtp.apps.surveys.models.Answer``
            The newly created, or updated, answer for the given question.

        Raises
        ------
        django.core.exceptions.ValidationError
            If anything is wrong, a ValidationError is raised, with a text and a code.
            Codes are:
            - invalid-survey
            - invalid-user
            - invalid-question
            - invalid-choice
            - expected-text (actually not used, see below)
            - expected-choices
            - unexpected-choices
            - unexpected-text
            - expected-choice
            - too-many-choices

            -
        """

        from .models import Question

        # Ensure the survey it's for the current session.
        if session.training != survey.training or session.trainer != survey.trainer:
            raise ValidationError(
                _('Unexpected survey for this session.'),
                code='invalid-survey'
            )

        # Check that the user is a trainee.
        if not trainee or not trainee.is_trainee:
            raise ValidationError(
                _('User is not a trainee.'),
                code='invalid-user'
            )

        # Get the question and ensure it's for the current survey.
        try:
            question = Question.non_deleted.filter(
                pool__surveypool__survey=survey
            ).get(pk=question_pk)

        except Question.DoesNotExist:
            raise ValidationError(
                _("Question not found for this session."),
                code='invalid-question'
            )

        # Ensure that an open question has text and no choices.
        if question.question_type == Question.QUESTION_TYPES.OPEN:

            # We currently accept emtpy texts so this part is commented.

            # if not text:
            #     raise ValidationError(
            #         _("Text expected for an open question"),
            #         code='expected-text'
            #     )

            if choice_pks:
                raise ValidationError(
                    _("Choices not expected for an open question."),
                    code='unexpected-choices'
                )

            # Empty choices for open question.
            choices = []

        # Ensure choices for non-open questions are correct.
        else:
            if text:
                raise ValidationError(
                    _("Text not expected for question with choices."),
                    code='unexpected-text'
                )

            if question.question_type == Question.QUESTION_TYPES.SINGLE_CHOICE:

                if not len(choice_pks):
                    raise ValidationError(
                        _("Choice expected for a single choice question."),
                        code='expected-choice'
                    )
                if len(choice_pks) > 1:
                    raise ValidationError(
                        _("Only one choice expected for a single choice question."),
                        code='too-many-choices'
                    )
            else:
                if not len(choice_pks):
                    raise ValidationError(
                        _("Choice(s) expected for a multiple choices question."),
                        code='expected-choices'
                    )

            choices = question.choices.filter(pk__in=choice_pks)

            if len(choices) != len(choice_pks):
                raise ValidationError(
                    _("Choice not found for this question."),
                    code='invalid-choice'
                )

            # Empty text for questions with choice(s).
            text = ''

        # Now we can create the answer, or get it if it already exists.
        answer, __ = self.get_or_create(
            training_session=session,
            survey=survey,
            trainee=trainee,
            question=question,
        )

        # Force update choices and text.
        answer.choices = choices
        answer.text = text
        answer.save()

        return answer

    def filter_for_question(self, question):
        """Retrieve all answers for the given question.

        Parameters
        ----------
        question : instance of ``mtp.apps.surveys.models.Question``.

        Returns
        -------
        Queryset
            Queryset of ``mtp.apps.surveys.models.Answer``.

        """

        return self.filter(question=question)

    def filter_for_stats(self, answer_pks):
        """Get the answers for which we have the PKs and return them ready to compute stats.

        Parameters
        ----------
        answer_pks : list
            List of primary keys of ``mtp.apps.surveys.models.Answer`` instance.

        Returns
        -------
            Queryset of ``mtp.apps.surveys.models.Answer``.

        Notes
        -----
        The matching answers are returned with their choices prefetched as we need the values.

        """

        return self.filter(pk__in=answer_pks).prefetch_related('choices')
