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

A survey is a list of questions related to a training.

They are grouped in "pools", each pool may be created by a admin (for all trainers) or a trainer (
for their sessions only), and may be mandatory or optional.

Each question may be an open one, or a list of choices with only one selectable one, or many,
depending on the configuration done by the author.

"""

from collections import OrderedDict
import logging

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 import timezone
from django.utils.translation import ugettext_lazy as _

from extended_choices import Choices

from mtp.apps.common.limpyd_base import RedisBackedDjangoModel
from mtp.apps.common.models import Orderable
from mtp.apps.sync.hooks import post_sync_hook
from mtp.apps.sync.models import Syncable
from mtp.apps.trainings.models import Training, TrainingSession

from .managers import (
    AnswerManager,
    NonDeletedChoicesManager,
    NonDeletedPoolsManager,
    NonDeletedQuestionsManager,
    NonDeletedSurveyPoolsManager,
    ChoicesManager,
    PoolsManager,
    QuestionsManager,
    SurveysManager,
    SurveyPoolsManager,
)

from .redis_models import QuestionData

logger = logging.getLogger('mtp.surveys')


class Pool(Orderable, Syncable):
    """Django model representing a pool of questions.

    A pool of questions can be created by an admin or a trainer, for a training.

    If done by an admin, it will be used in all the surveys for this training, except if it's not
    mandatory: in this case the trainer can hide it.

    If done by a trainer, it will be used only in its own training.

    In the public application, the manager to use is ``non_deleted`` instead of ``objects``, to
    exclude deleted pools from the default results.

    Attributes
    ----------
    id : django field, instance of ``django_extensions.db.fields.UUIDField``
    name : django ``CharField``
        The name of the pool, mandatory.
    description : django ``TextField``
        Optional description for this pool.
    author : foreign key to ``mtp.apps.accounts.MTPUser``
    is_global : django ``BooleanField``
        Will be set automatically to ``True`` if the author is superuser.
    is_mandatory : django ``BooleanField``
        If set to False, the trainer will be able to hide it in the survey.

    objects : instance of ``PoolsManager``
        It's the default manager of this model.
    non_deleted :: instance of ``NonDeletedPoolsManager``
        It's a manager that exclude all pools marked as deleted (``is_deleted`` field)

    Notes
    -----
    The ``is_global`` field is set automatically to ``True`` on saving, if the author is admin.

    This model is ``Syncable``.

    """
    name = models.CharField(max_length=500, verbose_name=_('name'))
    description = models.TextField(blank=True, verbose_name=_('description'))

    author = models.ForeignKey(settings.AUTH_USER_MODEL, verbose_name=_('author'))
    is_global = models.BooleanField(default=False, verbose_name=_('is global?'))
    is_mandatory = models.BooleanField(default=True, verbose_name=_('is mandatory?'))

    objects = PoolsManager()
    non_deleted = NonDeletedPoolsManager()

    class Meta:
        # If a queryset includes global and non-global pools, always return the global ones first.
        ordering = ['-is_global', 'sort_order']

    def save(self, *args, **kwargs):
        """Set the ``is_global`` field then save the instance"""

        self.is_global = self.author and self.author.is_superuser
        super().save(*args, **kwargs)

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

        It actually returns the edit url, as there is no "read" view of a pool.

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

        """
        return self.get_edit_url()

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

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

        """
        return reverse('surveys:pool-update', kwargs={'pool_uuid': self.pk})

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

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

        """
        return reverse('surveys:pool-delete', kwargs={'pool_uuid': self.pk})

    @classmethod
    def get_list_url(cls):
        """ Get the url to list the pools.

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


        As it's not tied to an instance, this method is a ``classmethod``.

        """
        return reverse('surveys:pools-list')

    @classmethod
    def get_sort_url(cls):
        """ Get the url to sort the pools.

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


        As it's not tied to an instance, this method is a ``classmethod``.

        """
        return reverse('surveys:pools-sort')

    @classmethod
    def get_create_url(cls):
        """ Get the url to create a pool.

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


        As it's not tied to an instance, this method is a ``classmethod``.

        """
        return reverse('surveys:pool-create')

    def get_questions_list_url(self):
        """ Get the url to list questions of a pool.

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

        """
        return reverse('surveys:questions-list', kwargs={'pool_uuid': self.pk})

    def get_questions_sort_url(self):
        """ Get the url to sort the questions of a pools.

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

        """
        return reverse('surveys:questions-sort', kwargs={'pool_uuid': self.pk})

    def get_questions_create_url(self):
        """ Get the url to add a questions to a pool.

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

        """
        return reverse('surveys:question-create', kwargs={'pool_uuid': self.pk})

    def count_questions(self):
        return Pool.objects.count_questions_for_pool(self)

    def add_to_survey(self, survey, **kwargs):
        return SurveyPool.objects.add(survey, self, **kwargs)

    def get_questions(self):
        return Question.non_deleted.filter_for_pool(self)

    def serialize(self):
        """Serialize a pool of questions in a dict to be used as a api."""
        return {
            'id': self.id,
            'name': self.name,
            'description': self.description,
            'questions': [question.serialize() for question in self.get_questions()],
        }


class Question(RedisBackedDjangoModel, Orderable, Syncable):
    """Django model representing a question in a pool of questions..

    In the public application, the manager to use is ``non_deleted`` instead of ``objects``, to
    exclude deleted questions from the default results.

    Attribute
    ---------

    MIN_CHOICES : integer
        Minimum number of choices required when a question expects choices.

    MAX_CHOICES : integer
        Maximum number of choices required when a question expects choices.

    QUESTION_TYPES : ``Choices`` instance
        It contains all available types of questions:
        - ``OPEN`` : the question expects a written answer by the trainee
        - ``SINGLE_CHOICE`` : the question has many possible answers, the trainee must choose one
        - ``MULTIPLE_CHOICES`` : the question has many possible answers, the trainee can chosse many

    VALUE_TYPES : ``Choices``instance
        It contains all available types of values for the answers:
        - ``NONE`` : no value for the answer
        - ``PERCENT`` : a percentage value (for example for questions about satisfaction, used to
                        compute the average)
        - ``NUMBER`` : a simple number (integer) value (for example to count things)
        - ``TEXT`` : a sort of keyword for answer

    ALLOWED_VALUE_TYPES : dict
        It contains, for each question type, a tuple containing allowed types of values:
        - ``OPEN``: open questions don't expect predefined values, so only ``NONE`` is allowed
        - ``SINGLE_CHOICE``: all types are allowed. If ``NONE``, then the choice label will be used
        - ``MULTIPLE_CHOICES`` : same as for ``SINGLE_CHOICE``

    CHOICES_EXPECTED : dict
        It containts, for each question type, a boolean indicating if this type of question
        expect choices.

    ALLOWED_GLOBAL_SATISFACTION : dict
        It contains, for each question type, a tuple containing allowed types of values:
        - ``OPEN``: cannot be used for global satisfaction, empty tuple
        - ``SINGLE_CHOICE``: contains only ``PERCENT``
        - ``MULTIPLE_CHOICES`` : cannot be used for global satisfaction, empty tuple

    id : django field, instance of ``django_extensions.db.fields.UUIDField``
    pool : foreign key to ``Pool``
    title : django ``CharField``
        A short title for the question, mandatory.
    details : django ``TextField``
        An optional long version of the question, to help the trainee to understand it.
    question_type : django ``CharField``.
        Hold a value from ``QUESTION_TYPES``.
    value_type : django ``CharField``
        Hold a value from ``VALUE_TYPES``.
    is_for_global_satisfaction : django ``BooleanField``
        Default to ``False``. If ``True``, will be used in surveys stats to compute global
        satisfaction.

    objects : instance of ``QuestionsManager``
        It's the default manager of this model.
    non_deleted : instance of ``NonDeletedQuestionsManager``
        It's a manager that exclude all questions marked as deleted (``is_deleted`` field)

    redis_model : QuestionData
        The limpyd models used to save statistics about the question.

    Notes
    -----
    Given that not all question types accept all value types, displaying fields and managing data
    must be done carefully.
    For example, do not allow adding choices for an open question, do not allow changing the type
    of value for an open question, etc...

    This model is ``Syncable``.

    """

    MIN_CHOICES = 2
    MAX_CHOICES = 5

    QUESTION_TYPES = Choices(
        ('OPEN', 'o', _('Open question')),
        ('SINGLE_CHOICE', 's', _('Multiple answers, unique choice')),
        ('MULTIPLE_CHOICES', 'm', _('Multiple answers, multiple choice')),
        dict_class=OrderedDict,
    )

    VALUE_TYPES = Choices(
        ('NONE', '', _('No value')),
        ('PERCENT', 'p', _('Percentage')),
        ('NUMBER', 'n', _('Number')),
        ('TEXT', 't', _('Text')),
        dict_class=OrderedDict,
    )

    ALLOWED_VALUE_TYPES = {
        QUESTION_TYPES.OPEN: (
            VALUE_TYPES.NONE,
        ),
        QUESTION_TYPES.SINGLE_CHOICE: (
            VALUE_TYPES.NONE,
            VALUE_TYPES.PERCENT,
            VALUE_TYPES.NUMBER,
            VALUE_TYPES.TEXT,
        ),
        QUESTION_TYPES.MULTIPLE_CHOICES: (
            VALUE_TYPES.NONE,
            VALUE_TYPES.PERCENT,
            VALUE_TYPES.NUMBER,
            VALUE_TYPES.TEXT,
        ),
    }

    CHOICES_EXPECTED = {
        QUESTION_TYPES.OPEN: False,
        QUESTION_TYPES.SINGLE_CHOICE: True,
        QUESTION_TYPES.MULTIPLE_CHOICES: True,
    }

    ALLOWED_GLOBAL_SATISFACTION = {
        QUESTION_TYPES.OPEN: (),
        QUESTION_TYPES.SINGLE_CHOICE: (
            VALUE_TYPES.PERCENT,
        ),
        QUESTION_TYPES.MULTIPLE_CHOICES: (),
    }

    pool = models.ForeignKey(Pool, related_name='questions', verbose_name=_('pool'))

    title = models.CharField(max_length=500,
                             help_text=_('Short title of the question'),
                             verbose_name=_('title'))
    details = models.TextField(blank=True,
                               help_text=_('Detailed question'),
                               verbose_name=_('details'))
    question_type = models.CharField(
        max_length=1,
        choices=QUESTION_TYPES,
        default=QUESTION_TYPES.SINGLE_CHOICE,
        help_text=_('Question type'),
        verbose_name=_('question type')
    )
    value_type = models.CharField(
        max_length=1,
        choices=VALUE_TYPES,
        default=VALUE_TYPES.PERCENT,
        help_text=_('Type of value for the choices'),
        blank=True, null=False,
        verbose_name=_('value type')
    )
    is_for_global_satisfaction = models.BooleanField(
        default=False,
        verbose_name=_('Use to compute global satisfaction?'),
        help_text=_('If checked, this question will be used to compute global satisfaction. '
                    'It can only be used with single choice questions with percentages.')
        )

    objects = QuestionsManager()
    non_deleted = NonDeletedQuestionsManager()

    redis_model = QuestionData

    class Meta:
        ordering = ['sort_order']

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

        It actually returns the edit url, as there is no "read" view of a 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('surveys:question-update', kwargs={'pool_uuid': self.pool_id,
                                                          'question_uuid': self.pk})

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

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

        """
        return reverse('surveys:question-delete', kwargs={'pool_uuid': self.pool_id,
                                                          'question_uuid': self.pk})

    def get_choices(self):
        return Choice.non_deleted.filter_for_question(self)

    def serialize(self):
        """Serialize a question in a dict to be used as a api."""

        return {
            'id': self.id,
            'title': self.title,
            'details': self.details,
            'type': self.QUESTION_TYPES.for_value(self.question_type).constant,
            'choices': [choice.serialize() for choice in self.get_choices()],
        }

    def save_answer_data(self, answer):
        """Will save answer data in redis for fast stat access.

        We currently only save data related to single choice question with number or percent
        values, to compute answer average on a daily basis.

        Other kind of answers may be computed directly or precomputed in redis later. See #236

        See also
        --------
        ``mtp.apps.surveys.redis_models.QuestionData.save_single_choice_number_answer``

        """

        if (self.question_type == self.QUESTION_TYPES.SINGLE_CHOICE
                and self.value_type in (self.VALUE_TYPES.PERCENT, self.VALUE_TYPES.NUMBER)):
            choice = answer.choices.first()
            if choice and choice.value_number is not None:
                self.redis_instance.save_single_choice_number_answer(
                    training_id=answer.training_session.training_id,
                    trainer_id=answer.training_session.trainer_id,
                    session_id=answer.training_session_id,
                    user_id=answer.trainee_id,
                    value=choice.value_number,
                    answer_time=answer.time,
                )

    def update_stats(self, force_all=False):
        """Update stats with all answers or only ones not already used for pre-computation.

        Parameters
        ----------
        force_all : boolean
            If ``False``, will only use new answers to update the statistics. If ``True``, all
            answers will be used.

        Returns
        -------
        tuple
            A tuple with two numbers: the number of answers used successfuly to update the
            statistics and the number that failed.

        """

        answers = Answer.objects.filter_for_question(self).filter(
            question__question_type=self.QUESTION_TYPES.SINGLE_CHOICE,
            question__value_type__in=(self.VALUE_TYPES.PERCENT, self.VALUE_TYPES.NUMBER)
        ).values('id', 'training_session_id', 'trainee_id')

        return self.redis_instance.update_single_choice_number_answers(answers, force_all)


class Choice(Orderable, Syncable):
    """Django model representing a choice for a question.

    Attributes
    ----------
    id : django field, instance of ``django_extensions.db.fields.UUIDField``
    question : foreign key to ``question``
    label : django ``CharField``
        The text of the choice, mandatory.
    details : django ``TextField``
        Optional details to help the trainee to understand the choice
    value_number: django ``IntegerField``
        Value to be set if the ``value_type`` field of the question is ``PERCENT`` or ``NUMBER``.
        It must be ``None`` for other types.
    value_text: django ``IntegerField``
        Value to be set if the ``value_type`` field of the question is ``TEXT``.
        It must be ``None`` for other types.

    Notes
    -----
    Be careful not to allow filling value fields if the value type of the question does not match.

    This model is ``Syncable``.

    """

    question = models.ForeignKey(Question, related_name='choices')

    label = models.CharField(max_length=500, verbose_name=_('label'))
    details = models.TextField(blank=True, verbose_name=_('details'))

    value_number = models.IntegerField(blank=True, null=True)
    value_text = models.CharField(max_length=500, blank=True, null=True)

    objects = ChoicesManager()
    non_deleted = NonDeletedChoicesManager()

    def serialize(self):
        """Serialize a choice in a dict to be used as a api."""
        return {
            'id': self.id,
            'label': self.label,
            'details': self.details,
        }


class Survey(Syncable):
    """Django model representing a survey - a series of pools of questions - for a training.

    A survey must be created using the ``create_for_training`` method of the manager

    Attributes
    ----------
    id : django field, instance of ``django_extensions.db.fields.UUIDField``
    training : foreign key to ``Training``
        The training for which this survey exists,
    trainer : foreign key to ``mtp.apps.accounts.MTPUser``
        The trainer creating the survey for a training.
    pools : M2M to ``Pool``, through ``SurveyPool``
        List of pools for this survey, each pool can be hidden (via fields in ``SurveyPool``).

    objects : instance of ``SurveysManager``
        It's the default manager of this model.

    Notes
    -----
    The ``Survey`` entry is created by the trainer who choose to create the survey for a training,
    and then can hide some pools.
    If this is not done, a survey will be created on demand, with all global pools displayed.

    If a global pool is added after a survey is created, they will be included in the survey and
    marked as new in the web interface to let the trainer see them, and be able to hide them (if
    the ``mandatory`` field of the pool is ``False``).

    There can be only one survey for a (training, trainer) couple. This is enforced via
    ``Meta.unique_together``.

    TODO: think about a way to notify trainers abut new global surveys.

    This model is ``Syncable``.

    Example
    -------
    >>> from mtp.apps.trainings.models import Training
    >>> from mtp.apps.accounts.models import MTPUser, INTERNAL_GROUP_TRAINER
    >>> from mtp.apps.surveys.models import Survey
    >>> training = Training.objects.first()  # in the real world use a real training
    >>> trainer = MTPUser.objects.filter(mgroups__name=INTERNAL_GROUP_TRAINER).first()
    >>> survey = Survey.objects.create_for_training(training, trainer)

    """
    training = models.ForeignKey(Training)
    trainer = models.ForeignKey(settings.AUTH_USER_MODEL)
    pools = models.ManyToManyField(Pool, through='SurveyPool')

    objects = SurveysManager()

    class Meta(Syncable.Meta):
        unique_together = (
            ('training', 'trainer'),
        )

    def get_survey_pools(self, only_with_questions=True, only_displayed=False):
        return SurveyPool.non_deleted.filter_for_survey(self, only_with_questions, only_displayed)

    def get_pools(self, only_with_questions=True, only_displayed=False):
        return Pool.non_deleted.filter_for_survey(self, only_with_questions, only_displayed)

    def add_pool(self, pool, **kwargs):
        return SurveyPool.objects.add(self, pool, **kwargs)

    def serialize(self):
        """Serialize a survey in a dict to be used as a api."""
        return {
            'id': self.id,
            'training_id': self.training_id,
            'trainer_id': self.trainer_id,
            'pools': [pool.serialize() for pool in self.get_pools(only_with_questions=True,
                                                                  only_displayed=True)],
        }


class SurveyPool(Syncable):
    """Django model holding specific information about a pool on a survey.

    Attributes
    ----------
    id : django field, instance of ``django_extensions.db.fields.UUIDField``
    survey : foreign key to ``Survey``
    pool : foreign key to ``Pool``
    is_displayed : django ``BooleanField``
        The survey's author (the trainer) can set this to ``False`` to hide a pool from the survey.
    is_new : django ``BooleanField``
        Automatically updated : set to ``True`` when a global pool is created and automatically
        added to existing surveys, and set to ``False`` when the trainer save the survey after
        having seen the new pools.

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

    """
    survey = models.ForeignKey(Survey)
    pool = models.ForeignKey(Pool)
    is_displayed = models.BooleanField(default=True)
    is_new = models.BooleanField(default=False)

    class Meta(Syncable.Meta):
        unique_together = (
            ('survey', 'pool'),
        )
        ordering = ('-pool__is_global', 'pool__sort_order',)

    objects = SurveyPoolsManager()
    non_deleted = NonDeletedSurveyPoolsManager()

    def count_questions(self):
        return SurveyPool.objects.count_questions_for_survey_pool(self)


class Answer(Syncable):
    """Django model representing an answer to a survey question by a trainee in a training session.

    Attributes
    ----------
    id : django field, instance of ``django_extensions.db.fields.UUIDField``
    training_session : foreign key to ``TrainingSession``
    survey : foreign key to ``Survey``
    question: foreign key to ``Question``
    trainee: foreign key to ``mtps.apps.accounts.MTPUser``
    text: django ``TextField``
        Filled when the question is an ``OPEN`` one.
    choices : M2M to ``Choice``
        If the question is one with many choices, this holds the choices (one if it's a
        ``SINGLE_CHOICE``, many if a ``MULTIPLE_CHOICES``) made by the trainee.
    time : django `DateTimeField`
        Moment where the answer was saved. Automatically set.

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

    """
    training_session = models.ForeignKey(TrainingSession)
    survey = models.ForeignKey(Survey)
    trainee = models.ForeignKey(settings.AUTH_USER_MODEL)
    question = models.ForeignKey(Question)
    text = models.TextField(blank=True)
    choices = models.ManyToManyField(Choice)
    time = models.DateTimeField(default=timezone.now)

    objects = AnswerManager()

    def save(self, *args, **kwargs):
        """Save the answer and update statistics."""

        super().save(*args, **kwargs)
        if settings.SURVEYS_AUTO_COMPUTE_STATS:
            self.update_stats()

    def update_stats(self):
        """Update statistics with the new/updated answer."""

        try:
            self.question.save_answer_data(self)
        except Exception as e:
            # We don't want the user to see a failure when the saving of redis data fails
            # so we catch the exception and just log it.
            logger.exception(
                'Error saving survey answer redis data. Answer=[%s], message=%s', self.pk, e
            )


@post_sync_hook
def update_surveys_stats():
    """Called after a sync to update statistics, on master or slaves."""

    print(Fore.GREEN + '  Compute surveys statistics' + Fore.RESET)
    call_command('update_surveys_stats')
    print(Fore.GREEN + '  => DONE!' + Fore.RESET)
