"""This module holds the models for the ``roleplays`` app

A roleplay contains some steps and is attached to a training.
A roleplay has a name, a description and an author.

Each steps has a name, a description a timer, and holds up to 12 criteria.

A Criterion can be one of two different types, qualitative (text value) or quantitative
(numeric value).

"""

from datetime import timedelta

import string

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 django_extensions.db.fields.json import JSONField

from extended_choices import OrderedChoices

from mtp.apps.assessments.models import AssessableActivityPart
from mtp.apps.common.models import Orderable
from mtp.apps.sync.models import Syncable
from mtp.apps.trainings.models import Training, TrainingActivity, TrainingSession
from mtp.discrete_apps.versionable.models import Dependency

from .managers import (
    CriterionQueryset,
    CriterionManager,
    RatingManager,
    StepQueryset,
    StepManager
)


class Roleplay(TrainingActivity):
    """Django model representing a roleplay.

    Attributes
    ----------
    RATING_TYPES : ``Choices`` instance
        It contains all available types of ``criteria_rating_type``:
        - ``LETTERS`` : the trainer will rate the criteria using a rating
        of this type A,B,C,D...
        - ``NUMBERS`` : the trainer will rate the criteria using a rating
        of this type 1,2,3,4...

    id : django field, instance of ``django_extensions.db.fields.UUIDField``
    name : django ``CharField``
        The name of the roleplay, mandatory.
    description : django ``TextField``
        Optional description for this roleplay.
    criteria_rating_scale: django ``PositiveIntegerField``
        The scale of the quantitative criteria for this roleplay
    criteria_rating_type: django ``CharField``
        Hold a value from ``RATING_TYPES``.
    rating_scale: ``django_extensions.db.fields.json.JSONField``
        Optional, a non-linear scale indicating the points value associated
        to each letter-based rating. Structure example below:
        {'A': 100, 'B': 90, 'C': 75, 'D': 60, 'E': 45, 'F': 30}
        If empty, a linear scale will be used instead.
    author : foreign key to ``mtp.apps.accounts.models.MTPUser``
    training : foreign key to ``mtp.apps.trainings.models.Training``
        The training for which this roleplay exists

    """
    RATING_TYPES = OrderedChoices(
        ('LETTERS', 'le', _('Letters')),
        ('NUMBERS', 'nu', _('Numbers')),
    )

    criteria_rating_scale = models.PositiveIntegerField(
        default=2, verbose_name=_('number of steps for quantitative criteria rating'))
    criteria_rating_type = models.CharField(
        max_length=2,
        choices=RATING_TYPES,
        default=RATING_TYPES.LETTERS,
        help_text=_('Type of rating for the criteria'),
        blank=False, null=False,
        verbose_name=_('rating type')
    )
    rating_scale = JSONField(blank=True)

    order = models.PositiveIntegerField(default=0)

    verbose_activity_name = _('Roleplay')

    def __str__(self):
        return self.name

    @cached_property
    def assessing_parts(self):
        return Criterion.objects.filter(step__roleplay=self).all()

    def get_steps(self):
        return Step.objects.non_deleted().filter_for_roleplay(self)

    def get_criteria_rating_type_display(self):
        """Get the displayable value of the ``criteria_rating_type`` field."""

        return self.RATING_TYPES.for_value(self.criteria_rating_type).display

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

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

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

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

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

        """
        return self.get_detail_url()

    def get_detail_url(self):
        """ Get the url to view the details of the current roleplay.

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

        """
        return reverse('roleplays:roleplay-detail', kwargs={'roleplay_uuid': self.pk})

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

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

        """
        return reverse('roleplays:roleplay-update', kwargs={'roleplay_uuid': self.pk})

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

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

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

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

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

        """
        return reverse(
            'roleplays:export-xls',
            kwargs={'roleplay_uuid': self.pk}
        )

    def get_step_create_url(self):
        """ Get the url to create a step for the current roleplay.

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

        """
        return reverse('roleplays:step-create', kwargs={'roleplay_uuid': self.pk})

    def get_steps_sort_url(self):
        """ Get the url to order the steps of the current roleplay.

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

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

    @property
    def should_be_debriefed(self):
        return self.is_assessing

    @cached_property
    def max_points_yield_per_criterion(self):

        rating_scale = self.criteria_rating_scale
        specific_rating_scale = self.rating_scale

        # If no specific rating scale has been defined, no problem.
        if not specific_rating_scale:
            return rating_scale

        # Otherwise, the highest point value is in fact the highest value
        # that is defined in the scale.
        else:
            return max([
                points for letter, points in specific_rating_scale.items()
            ])

    def prepare_debrief(self, trainee, session):

        # We start by computing the debrief data for each step.
        steps_data = [
            step.prepare_debrief(trainee, session)
            for step in self.steps.all()
        ]

        # # However, some of the previous iterations may have returned ``None``,
        # # and we don't care for that, so we must filter them out.
        # steps_data = [data for data in steps_data if data]

        # ^--- NOTE: Leaving this code here just in case the spec changes.

        # If we still have data at that point, it means we actually have
        # stuff to talk about. Let's return it.
        if steps_data:
            return {
                'type': 'roleplay',
                'name': self.name,
                'description': self.description,

                'score': -1,  # FIXME: We should have a choice to display
                              #        that score or not. Right now this
                              #        is a *very ugly* hack to accomodate
                              #        a last-minute demand of the iOS side.

                'steps': steps_data,
            }


class Step(Orderable, Dependency, Syncable):
    """Django model representing a roleplay step.

    Attributes
    ----------
    id : django field, instance of ``django_extensions.db.fields.UUIDField``
    name : django ``CharField``
        The name of the roleplay step, mandatory.
    description : django ``TextField``
        Optional description for this roleplay step.
    duration: django ``DurationField``
        The indicative expected duration for this step.
    roleplay: foreign key to ``mtp.apps.roleplays.models.Roleplay``
        The roleplay for which this roleplay step exists
        related_name: ``steps``

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

    """
    name = models.CharField(max_length=500, verbose_name=_('name'))
    description = models.TextField(blank=True, verbose_name=_('description'))
    duration = models.DurationField(verbose_name=_('expected duration'), default=timedelta())

    roleplay = models.ForeignKey(
        Roleplay,
        related_name='steps',
        on_delete=models.CASCADE
    )

    objects = StepManager.from_queryset(StepQueryset)()

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

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

    def __str__(self):
        return '%s - %s' % (self.name, self.roleplay.name)

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

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

        """
        return reverse('roleplays:step-update', kwargs={'roleplay_uuid': self.roleplay.pk,
                                                        'step_uuid': self.pk})

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

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

        """
        return reverse('roleplays:step-delete', kwargs={'roleplay_uuid': self.roleplay.pk,
                                                        'step_uuid': self.pk})

    def get_criterion_create_url(self):
        """ Get the url to create a criterion for the current step.

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

        """
        return reverse('roleplays:criterion-create', kwargs={'roleplay_uuid': self.pk,
                                                             'step_uuid': self.pk})

    def get_criteria_sort_url(self):
        """ Get the url to order the criteria of the current step.

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

        """
        return reverse('roleplays:criteria-sort', kwargs={'roleplay_uuid': self.pk,
                                                          'step_uuid': self.pk})

    def prepare_debrief(self, trainee, session):

        # We start by computing the debrief data for each criterion.
        criteria_data = [
            criterion.prepare_debrief(trainee, session)
            for criterion in self.criteria.filter(
                criterion_type=Criterion.CRITERION_TYPES.QUALITATIVE)
        ]

        # However, we are not interested in displaying QL criteria without
        # an actual text rating. Let's get rid of those.
        criteria_data = [
            data for data in criteria_data if not(
                data.get('type') == Criterion.CRITERION_TYPES.QUALITATIVE
                and not data.get('text')
            )
        ]

        # If we still have data at that point, it means we actually have
        # stuff to talk about. Let's return it.
        if criteria_data:
            return {
                'name': self.name,
                'description': self.description,
                'criteria': criteria_data,
            }


class Criterion(AssessableActivityPart, Orderable, Dependency, Syncable):
    """Django model representing a criterion.

    Attributes
    ----------
    CRITERION_TYPES : ``Choices`` instance
        It contains all available types of criterion:
        - ``QUALITATIVE`` : the rating expects a text value from the trainer
        - ``QUANTITATIVE`` : the rating expects a numeric value from the trainer
        - ``BOOLEAN`` : the rating expect a boolean value from the trainer

    id : django field, instance of ``django_extensions.db.fields.UUIDField``
    name : django ``CharField``
        The name of the roleplay step, mandatory.
    description : django ``TextField``
        Optional description for this roleplay step.
    step: foreign key to ``mtp.apps.roleplays.models.Step``
        The roleplay step for which this criterion exists, mandatory.
        related_name: ``criteria``
    criterion_type : django ``CharField``
        Hold a value from ``CRITERION_TYPES``.

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

    """
    CRITERION_TYPES = OrderedChoices(
        ('QUALITATIVE', 'QL', _('Qualitative')),
        ('QUANTITATIVE', 'QT', _('Quantitative')),
        ('BOOLEAN', 'BL', _('Boolean')),
    )

    name = models.CharField(max_length=500, verbose_name=_('name'))
    description = models.TextField(blank=True, verbose_name=_('description'))
    step = models.ForeignKey(
        Step,
        related_name='criteria',
        on_delete=models.CASCADE
    )
    criterion_type = models.CharField(
        max_length=2,
        choices=CRITERION_TYPES,
        default=CRITERION_TYPES.QUANTITATIVE,
        help_text=_('Type of criterion'),
        blank=False, null=False,
        verbose_name=_('criterion type')
    )

    objects = CriterionManager.from_queryset(CriterionQueryset)()

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

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

    def __str__(self):
        return '%s - %s' % (self.name, self.step.name)

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

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

        """
        return reverse('roleplays:criterion-update', kwargs={'roleplay_uuid': self.step.roleplay.pk,
                                                             'step_uuid': self.step.pk,
                                                             'criterion_uuid': self.pk})

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

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

        """
        return reverse('roleplays:criterion-delete', kwargs={'roleplay_uuid': self.step.roleplay.pk,
                                                             'step_uuid': self.step.pk,
                                                             'criterion_uuid': self.pk})

    def get_actual_activity(self):
        return self.step.roleplay

    def get_rating(self, trainee, session):
        return self.ratings.filter(trainee=trainee, training_session=session).first()

    @cached_property
    def should_be_assessed(self):

        # Only quantitative and boolean criteria should be assessed.
        return (super().should_be_assessed
                and self.criterion_type == self.CRITERION_TYPES.QUANTITATIVE
                or self.criterion_type == self.CRITERION_TYPES.BOOLEAN)

    @property
    def max_points_yield(self):

        # Non-assessing criteria may yield a grand total of zero points.
        if not self.should_be_assessed:
            return 0

        # Otherwise, this is computed at the roleplay level.
        return self.step.roleplay.max_points_yield_per_criterion

    def prepare_debrief(self, trainee, session):

        score, text = -1, ''

        rating = self.get_rating(trainee, session)
        if rating is not None:
            result = rating.result
            if self.criterion_type == self.CRITERION_TYPES.QUALITATIVE:
                text = result

                # Non-filled qualitative criteria should not appear
                # on the debrief page.
                if not text:
                    return None

            elif self.criterion_type == self.CRITERION_TYPES.QUANTITATIVE:
                score = result

        return {
            'type': self.criterion_type,
            'name': self.name,
            'description': self.description,
            'score': score,
            'text': text,
        }


class Rating(Syncable):
    """Django model representing the rating of a criterion.

    Attributes
    ----------
    id : django field, instance of ``django_extensions.db.fields.UUIDField``
    training_session : foreign key to ``TrainingSession``
    criterion: foreign key to ``Criterion``
    trainee: foreign key to ``mtps.apps.accounts.MTPUser``
    text: django ``TextField``
        Filled when the type of criterion is ``QUALITATIVE``
    score: django ``PositiveIntegerField``
        Filled when the type of criterion is ``QUANTITATIVE``
    """

    training_session = models.ForeignKey(TrainingSession, on_delete=models.CASCADE)
    trainee = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
    criterion = models.ForeignKey(
        Criterion,
        related_name='ratings',
        on_delete=models.CASCADE
    )
    text = models.TextField(blank=True)
    score = models.PositiveIntegerField(null=True, blank=True)

    objects = RatingManager()

    def save(self, *args, **kwargs):
        super().save(*args, **kwargs)

        # Record assessment score if needed.
        criterion = self.criterion
        if criterion.should_be_assessed:
            criterion.record_assessment_score(
                user=self.trainee,
                points=self.result,
                session=self.training_session,
            )

    @property
    def result(self):
        """Return the result that should be considered for this rating,
        i.e. the ``text`` if this is a qualitative criterion or the ``score``
        if this is a quantitative one.

        Returns
        -------
        The score, in the relevant datatype (int or string)

        Notes
        -----
        This is where we convert a letter-codified rating into an actual
        numeric score. This is done by checking if a specific scale has
        been declared at the roleplay level.

        """
        criterion = self.criterion
        criterion_type = criterion.criterion_type
        roleplay = criterion.step.roleplay

        # The result of a qualitative criterion is simply the text
        # that has been input by the assessor.
        if criterion_type == Criterion.CRITERION_TYPES.QUALITATIVE:
            return self.text

        # If this is a quantitative criterion, though, we have to
        # distinguish between letter- and number-codified ratings.
        elif criterion_type == Criterion.CRITERION_TYPES.QUANTITATIVE:

            rating_type = roleplay.criteria_rating_type

            # We need to check if such a scale has been declared and act
            # accordingly.
            rating_scale = roleplay.rating_scale
            if not rating_scale:
                return self.score
            else:
                if rating_type == roleplay.RATING_TYPES.NUMBERS:
                    return rating_scale.get(str(self.score))
                elif rating_type == roleplay.RATING_TYPES.LETTERS:
                    return rating_scale.get(self._score_to_letter())

        # If this is a boolean criterion, we just return the score
        # (100 for ``True``, and 0 for ``False``)
        elif criterion_type == Criterion.CRITERION_TYPES.BOOLEAN:
            return self.score

    def _score_to_letter(self):
        """Convert the numeric score coming from the frontend app into a letter
        code.

        Returns
        -------
        string
            The letter code.

        Notes
        -----
        The frontend does not send back letter codes, even if the roleplay
        has been configured to use them. It basically only _displays_ ratings
        as letters, but treats them internally as numbers. We could have
        converted these numbers directly into percentage-normalized scores,
        but in the name of readability we kept the letter-codification in the
        code.

        """
        num_letters = self.criterion.step.roleplay.criteria_rating_scale
        available_letters = string.ascii_uppercase[:num_letters]
        return available_letters[num_letters - self.score]
