"""This module holds the models for the ``evaluations`` app."""

import pandas as pd
from numpy import NaN

from django.core.urlresolvers import reverse
from django.db import models
from django.utils.translation import ugettext_lazy as _

from extended_choices import OrderedChoices

from mtp.apps.accounts.constants import INTERNAL_GROUP_TRAINEE, INTERNAL_GROUP_TRAINER
from mtp.apps.accounts.models import Group, MTPUser
from mtp.apps.common.models import Orderable
from mtp.apps.common.utils import upload_to_generator
from mtp.apps.data_export.constants import EXCEL_STYLE_CHOICES
from mtp.apps.data_export.exporters import ExcelDataExporter
from mtp.apps.sync.models import Syncable
from mtp.apps.trainings.models import (
    ResultsToExcelActivityMixin,
    Training,
    TrainingActivity
)
from mtp.discrete_apps.versionable.models import Dependency

from .managers import (
    CriterionManager,
    CriterionQueryset,
    ExerciseManager,
    ExerciseQueryset,
)


class EvaluationGroupPermission(Syncable):
    """Django model allowing to give permissions to designated groups during
    a given evaluation.

    Attributes
    ----------
    PERMISSION_TYPES : ``Choices`` instance
        It contains all available permissions:
        - ``MAY_PARTICIPATE``
        - ``MAY_EVALUATE_SELF``
        - ``MAY_EVALUATE_OTHERS``
        - ``MAY_OBSERVE_SELF``
        - ``MAY_OBSERVE_OTHERS``
        - ``MAY_MODERATE``

    evaluation : django ``ForeignKey``
        The evaluation for which we give this permission.
    group : django ``ForeignKey``
        The group to which we give this permission.
    permission : django ``CharField``
        The permission, a value from ``PERMISSION_TYPES``.

    Notes
    -----
    This model is used as the ``through`` attribute of a ``ManyToManyField``
    between the ``Evaluation`` and ``accounts.Group`` models.

    """
    PERMISSION_TYPES = OrderedChoices(
        ('MAY_PARTICIPATE', 'may_participate', _("May participate")),
        ('MAY_EVALUATE_SELF', 'may_evaluate_self', _("May evaluate self")),
        ('MAY_EVALUATE_OTHERS', 'may_evaluate_others', _("May evaluate others")),
        ('MAY_OBSERVE_SELF', 'may_observe_self', _("May observe self")),
        ('MAY_OBSERVE_OTHERS', 'may_observe_others', _("May observe others")),
        ('MAY_MODERATE', 'may_moderate', _("May moderate")),
    )

    evaluation = models.ForeignKey('Evaluation', on_delete=models.CASCADE)
    group = models.ForeignKey('accounts.Group', on_delete=models.CASCADE)
    permission = models.CharField(
        _('permission'),
        choices=PERMISSION_TYPES,
        max_length=32,
    )

    class Meta:
        unique_together = ('group', 'permission', 'evaluation')


class Evaluation(ResultsToExcelActivityMixin, TrainingActivity):
    """Django model representing an evaluation.

    Attributes
    ----------
    name : django ``CharField``
        The name of this evaluation.
        Inherited from ``TrainingActivity``.
    training : django ``ForeignKey``
        The training this evaluation belongs to.
        Inherited from ``TrainingActivity``.
    sort_order : django ``PositiveIntegerField``
        The sort order of this evaluation.
        Inherited from ``Orderable``.

    EVALUATION_TYPES : ``Choices`` instance
        It contains all available permissions:
        - ``MAY_EVALUATE_SELF``
        - ``MAY_EVALUATE_OTHERS``
        - ``MAY_EVALUATE_ALL``

    evaluation_type : django ``CharField``
        The type of the evaluation, a value from ``EVALUATION_TYPES``
    permissions : django ``ManyToManyField``
        The permissions granted to specific user groups during
        this evaluation.
    preview : django ``ForeignKey``
        A downloadable preview for this evaluation.

    """

    EVALUATION_TYPES = OrderedChoices(
        ('MAY_EVALUATE_SELF', 'may_evaluate_self', _(
            "The candidate may evaluate themselves"
        )),
        ('MAY_EVALUATE_OTHERS', 'may_evaluate_others', _(
            "The candidate may evaluate others"
        )),
        ('MAY_EVALUATE_ALL', 'may_evaluate_all', _(
            "The candidate may evaluate themselves and others"
        )),
    )

    evaluation_type = models.CharField(
        _("evaluation type"),
        choices=EVALUATION_TYPES,
        default=EVALUATION_TYPES.MAY_EVALUATE_OTHERS,
        max_length=32,
    )

    permissions = models.ManyToManyField(
        'accounts.Group',
        blank=True,
        through='EvaluationGroupPermission'
    )

    verbose_activity_name = _('Evaluation')

    non_duplicable_relations = ['permissions']

    def __str__(self):
        return self.name

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

        # First, we actually save the evaluation.
        super().save(*args, **kwargs)

        # Then, we are going to need a few groups...
        trainee_group = Group.objects.get(name=INTERNAL_GROUP_TRAINEE)
        trainer_group = Group.objects.get(name=INTERNAL_GROUP_TRAINER)

        perms = EvaluationGroupPermission.PERMISSION_TYPES

        # First, clean all permissions previously set
        EvaluationGroupPermission.objects.filter(evaluation=self).delete()

        # We set the appropriates permissions on the trainer group...
        trainer_permissions = [
            perms.MAY_MODERATE,
            perms.MAY_EVALUATE_SELF,
            perms.MAY_EVALUATE_OTHERS
        ]

        for permission in trainer_permissions:
            EvaluationGroupPermission.objects.get_or_create(
                evaluation=self,
                group=trainer_group,
                permission=permission
            )

        # ...and on the trainee group, depending on the ``evaluation_type`` attribute
        trainee_permissions_set = {
            self.EVALUATION_TYPES.MAY_EVALUATE_SELF: [perms.MAY_EVALUATE_SELF,
                                                      perms.MAY_PARTICIPATE],
            self.EVALUATION_TYPES.MAY_EVALUATE_OTHERS: [perms.MAY_EVALUATE_OTHERS,
                                                        perms.MAY_PARTICIPATE],
            self.EVALUATION_TYPES.MAY_EVALUATE_ALL: [perms.MAY_EVALUATE_SELF,
                                                     perms.MAY_EVALUATE_OTHERS,
                                                     perms.MAY_PARTICIPATE]
        }

        for permission in trainee_permissions_set[self.evaluation_type]:
            EvaluationGroupPermission.objects.get_or_create(
                evaluation=self,
                group=trainee_group,
                permission=permission
            )

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

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

        """
        return reverse('evaluations:evaluation-create')

    @classmethod
    def get_training_create_url(cls, training):
        """Get the url to create a new evaluation for the provided training

        Parameters
        ----------
        training: Training
            The training for which we want to create a new evaluation

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

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

    def results_to_excel(self, session, path=None):
        """Get the session results of this evaluation as an excel file

        For the parameters, see ``ResultsToExcelActivityMixin``

        """

        excel_exporter = ExcelDataExporter(path)

        excel_data = self.get_results_excel_data(session)
        excel_data['sheet_name'] = self.name
        excel_exporter.add_sheet(**excel_data)

        return excel_exporter.save()

    def get_results_excel_data(self, session):
        """Get the results data of this evaluation for a given session

        Parameters
        ----------
        session: TrainingSession
            The training session we want the results for

        Returns
        -------
        dict
            A dict containing the parameters for the ``ExcelDataExporter.add_sheet``

        """

        exercises = self.exercises.non_deleted()
        users = session.users.non_deleted()

        exercise_data_frames = []
        header_styles = []

        for exercise in exercises:
            # pylint: disable=bad-builtin
            series, styles = map(
                list, zip(*exercise.get_results_excel_data(session))
            )

            # New data frame containing the results for this exercise
            data_frame = pd.concat(series, axis=1)

            # New column for the average ratings of this exercise
            data_frame.insert(
                0,
                '%s (%s)' % (exercise.name, str(_('Mean'))),
                data_frame.mean(axis=1)
            )
            header_styles.extend([EXCEL_STYLE_CHOICES.WORKSHOP_STEP] + styles)

            exercise_data_frames.append(data_frame)

        # Concatenate all the exercises data frames into one
        data_frame = pd.concat(exercise_data_frames, axis=1)

        # Pop the number of ratings given and the max range
        number_of_ratings = list(data_frame.loc[len(users)].values)
        max_range_values = list(data_frame.loc[len(users) + 1].values)
        data_frame = data_frame[:-2]

        # Add a "Mean" row containing the mean value for each columns
        mean_values = []
        for index, __ in enumerate(data_frame):
            try:
                mean = data_frame.iloc[:, index].mean()
            except TypeError:
                mean = NaN

            mean_values.append(mean)

        data_frame.loc[str(_('Mean'))] = mean_values

        # Round all the numeric values in the data frame
        data_frame = data_frame.round(2)

        # Finally, populate the indexes using the users last and first name (username)
        data_frame = data_frame.rename(
            index={
                index: '%(last_name)s %(first_name)s (%(username)s)' % {
                    'last_name': user.last_name,
                    'first_name': user.first_name,
                    'username': user.username
                }
                for index, user in enumerate(users)
            }
        )

        # Re-add the "Participation" row
        data_frame.loc[str(_('Maximum range'))] = max_range_values
        data_frame.loc[str(_('Total of ratings'))] = number_of_ratings

        return {
            'data': data_frame,
            'header_styles': header_styles,
            'freeze_panes': [1, 1]
        }

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

        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 evaluation.

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

        """
        return reverse('evaluations:evaluation-detail', kwargs={'evaluation_uuid': self.pk})

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

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

        """
        return reverse('evaluations:evaluation-update', kwargs={'evaluation_uuid': self.pk})

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

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

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

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

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

        """
        return reverse('evaluations:evaluation-export-xls', kwargs={'evaluation_uuid': self.pk})

    def get_exercise_create_url(self):
        """ Get the url to create an exercise for the current evaluation.

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

        """
        return reverse('evaluations:exercise-create', kwargs={'evaluation_uuid': self.pk})

    def get_exercises_sort_url(self):
        """ Get the url to order the exercises of the current evaluation.

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

        """
        return reverse('evaluations:exercises-sort', kwargs={'evaluation_uuid': self.pk})

    def get_exercises_delete_url(self):
        """ Get the url to delete the exercises of the current evaluation.

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

        """
        return reverse('evaluations:exercises-delete', kwargs={'evaluation_uuid': self.pk})


class Exercise(Orderable, Syncable, Dependency):
    """Django model representing an evaluation exercise.

    Attributes
    ----------
    name : django ``CharField``
        The name of the exercise, mandatory.
    description : django ``TextField``
        Optional description for this exercise.

    start_range : django ``PositiveIntegerField``
    end_range : django ``PositiveIntegerField``
    step : django ``PositiveIntegerField``
    preview: django ``ImageField``

    sort_order : django ``PositiveIntegerField``
        The sort order of this exercise.
        Inherited from ``Orderable``.

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

    """
    name = models.CharField(max_length=50, verbose_name=_('name'))
    description = models.TextField(verbose_name=_('description'), blank=True)
    evaluation = models.ForeignKey(
        'Evaluation',
        related_name='exercises',
        on_delete=models.CASCADE
    )

    start_range = models.PositiveIntegerField(
        default=1,
        verbose_name=_('Start range')
    )
    end_range = models.PositiveIntegerField(
        default=5,
        verbose_name=_('End range')
    )
    step = models.PositiveIntegerField(
        default=1,
        verbose_name=_('Step')
    )

    preview = models.ImageField(
        blank=True, null=True,
        verbose_name=_('preview'),
        upload_to=upload_to_generator,
    )

    objects = ExerciseManager.from_queryset(ExerciseQueryset)()

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

    sync_files_to_master = True

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

    def __str__(self):
        return self.name

    def get_results_excel_data(self, session):
        """Get the session users answers for this exercise

        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 scores / texts
            - One of the ``EXCEL_STYLE_CHOICES`` choice. The style to apply to this column.

        """

        users = session.users.non_deleted()
        series_styles_list = []

        # For each criterion of this exercise, a new series containing the results of each trainee
        for criterion_index, criterion in enumerate(self.criteria.non_deleted(), start=1):
            series = pd.Series(
                name='%d - %s' % (criterion_index, criterion.description)
            )
            is_qualitative_criterion = (
                criterion.criterion_type == criterion.CRITERION_TYPES.QUALITATIVE
            )

            for index, user in enumerate(users):
                if is_qualitative_criterion:
                    series.set_value(
                        index,
                        criterion.get_session_user_texts(session=session, user=user) or ''
                    )
                else:
                    session_score = criterion.get_session_user_score(session=session, user=user)
                    if session_score is None:
                        session_score = NaN

                    series.set_value(
                        index,
                        session_score
                    )

            # Append the total number of ratings given for this criterion
            series.set_value(
                len(users),
                criterion.ratings.filter(session=session).non_deleted().count()
            )

            # If applicable, add the maximum points for this criterion
            series.set_value(
                len(users) + 1,
                NaN if is_qualitative_criterion else self.end_range
            )

            series_styles_list.append((series, EXCEL_STYLE_CHOICES.DEFAULT))

        return series_styles_list

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

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

        """
        return reverse('evaluations:exercise-update', kwargs={
            'evaluation_uuid': self.evaluation.pk,
            'exercise_uuid': self.pk
        })

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

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

        """
        return reverse('evaluations:exercise-delete', kwargs={
            'evaluation_uuid': self.evaluation.pk,
            'exercise_uuid': self.pk
        })

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

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

        """
        return reverse('evaluations:criterion-create', kwargs={
            'evaluation_uuid': self.evaluation.pk,
            'exercise_uuid': self.pk
        })

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

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

        """
        return reverse('evaluations:criteria-sort', kwargs={
            'evaluation_uuid': self.evaluation.pk,
            'exercise_uuid': self.pk
        })


class Criterion(Orderable, Syncable, Dependency):
    """Django model representing an evaluation criterion.

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

    description : django ``TextField``
        A mandatory description for this criterion.
    exercise: django ``ForeignKey``
        The exercise for which this criterion exists, mandatory.
    criterion_type : django ``CharField``
        Hold a value from ``CRITERION_TYPES``.

    sort_order : django ``PositiveIntegerField``
        The sort order of this criterion.
        Inherited from ``Orderable``.

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

    """

    CRITERION_TYPES = OrderedChoices(
        ('QUALITATIVE', 'QL', _('Qualitative')),
        ('QUANTITATIVE', 'QT', _('Quantitative')),
    )

    exercise = models.ForeignKey(
        'Exercise',
        related_name='criteria',
        on_delete=models.CASCADE
    )
    description = models.TextField(verbose_name=_('description'))
    criterion_type = models.CharField(
        max_length=2, blank=False, null=False,
        choices=CRITERION_TYPES,
        help_text=_('Type of criterion'),
        verbose_name=_('criterion type')
    )

    objects = CriterionManager.from_queryset(CriterionQueryset)()

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

    class Meta:
        verbose_name_plural = 'criteria'
        ordering = ['sort_order']
        unique_together = ('sort_order', 'exercise')

    def __str__(self):
        return '%s - %s (%s)' % (
            self.exercise.name,
            self.description,
            self.criterion_type,
        )

    def get_session_user_texts(self, session, user):
        """Get the session texts for a given user for this criterion

        Parameters
        ----------
        session: TrainingSession
            The session for which we want the texts
        user: MTPUser
            The user for whom we want the texts

        Returns
        -------
        str or None
            The texts for the given session / user
            or None if no texts were found

        Raises
        ------
        ValueError
            If the criterion is not a ``QUALITATIVE`` one.

        """

        # We can only have texts for the ``QUALITATIVE`` criterion
        if not self.criterion_type == self.CRITERION_TYPES.QUALITATIVE:
            raise ValueError('Cannot have texts if criterion is not a "QUALITATIVE" one')

        texts = self.ratings.filter(
            trainee=user,
            session=session
        ).values_list('text', flat=True)

        return '\n + '.join(texts) if texts else None

    def get_session_user_score(self, session, user):
        """Get the session score for a given user for this criterion

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

        Returns
        -------
        float or None
            The score for the given session / user
            or None if no ratings were found

        Raises
        ------
        ValueError
            If the criterion is not a ``QUANTITATIVE`` one.

        """

        # We can only have a rating for the ``QUANTITATIVE`` criterion
        if not self.criterion_type == self.CRITERION_TYPES.QUANTITATIVE:
            raise ValueError('Cannot have a rating if criterion is not a "QUANTITATIVE" one')

        return self.ratings.filter(
            trainee=user,
            session=session
        ).aggregate(
            models.Avg('score')
        )['score__avg']

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

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

        """
        return reverse('evaluations:criterion-update', kwargs={
            'evaluation_uuid': self.exercise.evaluation.pk,
            'exercise_uuid': self.exercise.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('evaluations:criterion-delete', kwargs={
            'evaluation_uuid': self.exercise.evaluation.pk,
            'exercise_uuid': self.exercise.pk,
            'criterion_uuid': self.pk
        })

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


class Rating(Syncable):
    """Django model representing a rating specified for a criterion.

    Attributes
    ----------
    criterion: django ``ForeignKey``
        The criterion for which this rating applies.
    judge: django ``ForeignKey``
        The user who emitted this rating.
    trainee: django ``ForeignKey``
        The user who received this rating.
    session : foreign key to ``TrainingSession``
        The training session during which this rating was given.
    text: django ``TextField``
        Filled when the type of criterion is ``QUALITATIVE``
    score: django ``PositiveIntegerField``
        Filled when the type of criterion is ``QUANTITATIVE``
    """
    criterion = models.ForeignKey(
        'Criterion',
        related_name='ratings',
        on_delete=models.CASCADE
    )

    judge = models.ForeignKey(
        MTPUser,
        related_name='given_ratings',
        on_delete=models.CASCADE
    )
    trainee = models.ForeignKey(
        MTPUser,
        related_name='received_ratings',
        on_delete=models.CASCADE
    )
    session = models.ForeignKey(
        'trainings.TrainingSession',
        null=True,
        blank=True,
        related_name='evaluation_ratings',
        on_delete=models.CASCADE
    )

    text = models.TextField(blank=True)
    score = models.PositiveIntegerField(null=True, blank=True)
