"""This modules holds recommendations models for the ``assessments`` app."""

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 extended_choices import Choices

from mtp.apps.accounts.constants import GROUPTYPE_BUSINESS_UNIT
from mtp.apps.common.managers import ReferenceableSyncableManager
from mtp.apps.common.models import Orderable, Referenceable
from mtp.apps.sync.managers import SyncableManager
from mtp.apps.sync.models import Syncable

from ..managers import (
    CurriculumManager,
    CurriculumQuerySet,
    LessonDimensionWeightManager,
    LessonDimensionWeightQuerySet,
    LessonManager,
    LessonQuerySet,
    LessonRecommendationThresholdManager,
    LessonRecommendationThresholdQuerySet,
    TopicManager,
    TopicQuerySet,
    UserLessonRecommendationManager,
    UserLessonRecommendationQuerySet,
)
from ..models import CertificationProgramUserStatus, DimensionUserScore


class RecommendationLevel(Orderable, Referenceable, Syncable):
    """
    Django model representing a recommendation level for a given lesson.
    Commonly used levels would be words such as "recommended" or "mandatory".
    The main purpose of this whole sub-system is to compute this recommendation
    level for (trainee, lesson) couples.

    Attributes
    ----------
    name : django ``CharField``
        The name of this level.
    description : django ``TextField``
        An optional description for this level.

    Notes
    -----
    This model is ``Orderable``, ``Referenceable`` and ``Syncable``.

    """

    name = models.CharField(max_length=255)
    description = models.TextField(blank=True)

    objects = SyncableManager()
    references = ReferenceableSyncableManager()

    def __str__(self):
        return self.name


class Curriculum(Syncable):
    """Django model representing a training course, i.e. a list of lessons.

    These lessons are available to trainees and that may be recommended to them
    according to their previous training results.

    Attributes
    ----------
    name : django ``CharField``
        The name of this training course.
    description : django ``TextField``
        An optional description for this training course.
    mgroups : django ``ManyToManyField``
        The groups to which this curriculum is available.
    job_profile : django ``ForeignKey``
        The job profile this curriculum is defined for.
        Arguably this is an instance of ``mtp.apps.accounts.Group``,
        so it may seem redundant with the previous field, but I think
        its specific semantics deserve a specific field.
    active_recommendation_levels : django ``ManyToManyField``
        The recommendation levels that are used to establish
        recommendations for this curriculum.
    business_level : foreign key to ``mtp.accounts.models.Group``
        The business level the curriculum is defined for.

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

    """

    name = models.CharField(max_length=255)
    description = models.TextField(blank=True)
    mgroups = models.ManyToManyField('accounts.Group', blank=True)
    job_profile = models.ForeignKey(
        'accounts.Group', blank=True, null=True,
        related_name='curricula',
        on_delete=models.CASCADE
    )
    active_recommendation_levels = models.ManyToManyField(
        'RecommendationLevel',
        blank=True
    )
    business_unit = models.ForeignKey(
        'accounts.Group', blank=True, null=True,
        related_name='curriculum_for_business_level',
        limit_choices_to={'type__name': GROUPTYPE_BUSINESS_UNIT},
        verbose_name=_('Business unit'),
        on_delete=models.CASCADE
    )

    objects = CurriculumManager.from_queryset(CurriculumQuerySet)()

    def __str__(self):
        return self.name

    def compute_recommendations(self, session, only_trainees=None):
        """Compute the lesson recommendations for all or some trainees having
        undertaken the given training session.

        These recommendations are recorded as ``UserLessonRecommendation``
        objects.

        Parameters
        ----------
        session : instance of ``mtp.apps.trainings.models.TrainingSession``
            The training session whose results we shall compute
            the recommendations with.
        only_trainees : optional list of ``mtp.apps.accounts.models.MTPUser``
            If specified, the recommendations will be computed
            for these trainees only.

        Returns
        -------
        list of ``UserLessonRecommendation``

        """
        recommendations = []

        # First we determine the trainees for whom to compute recommandations.
        trainees = session.users.prefetch_related('dimensionuserscores')

        if only_trainees:
            only_trainee_pks = [trainee.pk for trainee in only_trainees]
            trainees = trainees.filter(pk__in=only_trainee_pks)

        if not trainees.exists():
            return

        # Then we fetch all the active lessons of the curriculum.
        lessons = self.lessons.non_deleted().filter(
            is_active=True
        ).prefetch_related(
            'dimension_weights', 'recommendation_thresholds',
        )

        # The certification field has been add afterwards, we must delete old instances of
        # UserLessonRecommendation to clean database.
        UserLessonRecommendation.objects.filter(certification__isnull=True).delete()

        # We should wipe all previous recs for these trainees X lessons.
        UserLessonRecommendation.objects.filter(
            certification__user__in=trainees,
            certification__session=session
        ).delete()

        # We can now compute new recommendations for each trainee.
        # Hopefully, with the previous calls to ``prefetch_related``,
        # this shouldn't incur trillions of database queries ^_^;
        for trainee in trainees:

            try:
                user_certification = CertificationProgramUserStatus.objects.get(
                    user=trainee,
                    session=session,
                )
            except CertificationProgramUserStatus.DoesNotExist:
                # If we have no certification for this trainee,
                # it's not necessary to calculate its recommendations.
                continue

            # We iterate on the lessons.
            for lesson in lessons:

                # For a given lesson, we need to find out if the trainee's
                # global score is high enough to reach any of the recommendation
                # levels.

                # First we need to compute their weighted score according to
                # the dimension weights that have been defined for this lesson.
                total_score = total_weight = 0
                all_dimensions_were_assessed = True

                for dimension_weight in lesson.dimension_weights.non_deleted():
                    dimension = dimension_weight.dimension
                    weight = dimension_weight.weight

                    total_weight += weight
                    try:
                        dimension_score = trainee.dimensionuserscores.get(
                            dimension=dimension,
                            session=session,  # Couldn't this be filtered before?
                        )
                    except (DimensionUserScore.DoesNotExist,
                            DimensionUserScore.MultipleObjectsReturned):
                        all_dimensions_were_assessed = False
                        break

                    total_score += dimension_score.score * weight

                # The trainee has no score for at least one of the dimensions.
                # In that case, it is already sure we won't be able to provide
                # a relevant recommendation. Next lesson!
                if not all_dimensions_were_assessed:
                    continue

                # So, the weighted score is...
                try:
                    weighted_score = total_score / total_weight
                except ZeroDivisionError:

                    # Err. Not sure that could actually happen, but you
                    # never know. Better safe than sorry, right?
                    continue

                # We can now iterate through the recommendation level
                # thresholds for this lesson. They have been prefetched,
                # and given the ``ordering`` defined in the ``Meta`` class
                # of the ``LessonRecommendationThreshold`` model, it should
                # be good enough to choose the first level for which our
                # ``weighted_score`` is high enough.
                for recommendation_threshold in lesson.recommendation_thresholds.non_deleted():
                    threshold = recommendation_threshold.threshold
                    level = recommendation_threshold.level

                    # If the score is high enough, we can record this
                    # recommendation and skip to the next lesson.
                    if weighted_score >= threshold:

                        # We can now create the recommendation
                        # We cannot use ``bulk_create`` at the end of the method because it does not
                        # call the ``save`` method and the ``Syncable`` models need to call the
                        # ``save`` method for each update.
                        recommendations.append(UserLessonRecommendation.objects.create(
                            certification=user_certification, lesson=lesson,
                            level=level, score=weighted_score
                        ))
                        break

        return recommendations

    def compute_trainee_recommendations(self, trainee, session):
        """Helper method to compute recommendations for one trainee only."""

        return self.compute_recommendations(session, only_trainees=[trainee])

    def get_absolute_url(self):
        """Get the url to this curriculum.

        If this curriculum is linked to a specific "business_level", append its pk to the url.

        Returns
        -------
        string

        """

        url = reverse('assessments:curriculum', kwargs={
            'jobprofile_uuid': self.job_profile.pk
        })

        return url if not self.business_unit else '%s?group_uuid=%s' % (url, self.business_unit.pk)

    def get_topic_create_url(self):
        """Get the url to create a new topic in the curriculum

        Returns
        -------
        string

        """

        return reverse('assessments:topic-create', kwargs={
            'curriculum_uuid': self.pk
        })


class Topic(Orderable, Syncable):
    """Django model representing a topic.

    Simply used to categorize lessons.

    Attributes
    ----------
    name : django ``CharField``
        The name of this topic.
    curriculum : django ``ForeignKey``
        The course this topic is a part of.
    description : django ``TextField``
        An optional description for this topic.

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

    """

    name = models.CharField(max_length=255)
    curriculum = models.ForeignKey(
        'Curriculum',
        related_name='topics',
        on_delete=models.CASCADE
    )
    description = models.TextField(blank=True)

    objects = TopicManager.from_queryset(TopicQuerySet)()

    class Meta:
        ordering = ('curriculum', 'name')
        unique_together = ('curriculum', 'name')

    def __str__(self):
        return self.name

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

        Returns
        -------
        string

        """

        return reverse('assessments:topic-edit', kwargs={
            'curriculum_uuid': self.curriculum.pk,
            'topic_uuid': self.pk
        })

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

        Returns
        -------
        string

        """

        return reverse('assessments:topic-delete', kwargs={
            'curriculum_uuid': self.curriculum.pk,
            'topic_uuid': self.pk
        })

    def get_lesson_create_url(self):
        """Get the url to create a lesson in the current topic.

        Returns
        -------
        string

        """

        return reverse('assessments:lesson-create', kwargs={
            'topic_uuid': self.pk
        })


class LessonReference(Syncable):
    """Django model representing a reference to a lesson in an external information system.

    Ex: the Nissan's SABA LMS.

    Attributes
    ----------
    external_id : django ``CharField``
        The identifier of the lesson in the external system.
    external_name : django ``CharField``
        The name of the lesson in the external system.

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

    """

    external_id = models.CharField(max_length=255)
    external_name = models.CharField(max_length=255)

    class Meta:
        ordering = ('external_name',)

    def __str__(self):
        return "%s ('%s')" % (self.external_name, self.external_id)


class Lesson(Orderable, Syncable):
    """Django model representing an individual lesson in a curriculum.

    It is important to note that such a lesson is _not_ necessarily
    an MTP ``Training`` -- in fact, most of them are not.

    Attributes
    ----------
    name : django ``CharField``
        The name of this lesson.
    type : django ``CharField``
        The type of this lesson.
        Must be chosen in ``Lesson.LESSON_TYPES``.
    curriculum : django ``ForeignKey``
        The course this lesson is a part of.
    topic : django ``ForeignKey``
        The topic of this lesson.
    duration : django ``DurationField``
        The estimated duration of this lesson.
    description : django ``TextField``
        An optional description for this lesson.
    dimensions : django ``ManyToManyField``
        The dimensions linked to this lesson.
        The associated weights are recorded via the ``through` model.
    recommendation_levels : django ``ManyToManyField``
        The recommendation levels linked to this lesson.
        The associated thresholds are recorded via the ``through`` model.
    is_active : django ``BooleanField``
        A boolean indicating if this lesson should be displayed and
        possibly recommended in the curriculum.
    reference : django ``ForeignKey``
        An optional reference to this lesson in an external IS.

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

    """
    LESSON_TYPES = Choices(
        ('ELEARNING', 'el', _('E-Learning')),
        ('DISTANCE', 'di', _('Distance')),
        ('PRESENCE', 'pr', _('Presence')),
    )

    name = models.CharField(max_length=255)
    type = models.CharField(
        max_length=2, choices=LESSON_TYPES,
        default=LESSON_TYPES.ELEARNING
    )

    curriculum = models.ForeignKey(
        'Curriculum',
        related_name='lessons',
        on_delete=models.CASCADE
    )
    topic = models.ForeignKey(
        'Topic',
        null=True,
        blank=True,
        related_name='lessons',
        on_delete=models.CASCADE
    )
    duration = models.CharField(max_length=100, blank=True)
    description = models.TextField(blank=True)

    dimensions = models.ManyToManyField(
        'assessments.Dimension', blank=True,
        through='LessonDimensionWeight'
    )
    recommendation_levels = models.ManyToManyField(
        'RecommendationLevel', blank=True,
        through='LessonRecommendationThreshold'
    )

    is_active = models.BooleanField(default=True)

    reference = models.ForeignKey(
        'LessonReference',
        null=True,
        blank=True,
        related_name='lessons',
        on_delete=models.CASCADE
    )

    objects = LessonManager.from_queryset(LessonQuerySet)()

    def __str__(self):
        return self.name

    @cached_property
    def can_add_recommendation_level_threshold(self):
        """Test if a new recommendation level threshold can be created for this lesson

        A new recommendation can be created only if not all the ``RecommendationLevel`` are linked
        to this ``Lesson`` via a ``LessonRecommendationThreshold``.

        Returns
        -------
        bool
            True if a new ``LessonRecommendationThreshold`` can be created for this ``Lesson``,
            False otherwise.

        See Also
        --------
        ``LessonDependencyForm``

        """

        # Fetch all the ``RecommendationLevel`` already linked
        existing_level_pks = self.recommendation_thresholds.non_deleted().values_list(
            'level_id',
            flat=True,
        )

        return RecommendationLevel.objects.non_deleted().exclude(
            id__in=existing_level_pks
        ).exists()

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

        Returns
        -------
        string

        """

        return reverse('assessments:lesson-edit', kwargs={
            'lesson_uuid': self.pk
        })

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

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

        """

        return reverse('assessments:lesson-delete', kwargs={
            'lesson_uuid': self.pk
        })

    def get_dimension_weight_create_url(self):
        """Get the url to add a dimension weight to the current lesson.

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

        """
        return reverse('assessments:lesson-dimension-weight-create', kwargs={
            'lesson_uuid': self.pk,
        })

    def get_recommendation_level_create_url(self):
        """Get the url to add a dimension weight to the current lesson.

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

        """
        return reverse('assessments:lesson-recommendation-level-create', kwargs={
            'lesson_uuid': self.pk,
        })

    @property
    def reference_recommendation_threshold(self):  # pylint: disable=invalid-name
        """Get the reference recommendation threshold of this lesson.

        Notes
        -----
        The reference recommendation threshold of this lesson is determined
        thanks to the reference level.

        Returns
        -------
        int
            The reference recommendation threshold.

        """

        reference_level = RecommendationLevel.references.first()

        # If there is no reference level, there is no reference recommendation threshold
        if not reference_level:
            return None

        recommendation_threshold = self.recommendation_thresholds.filter(
            level=reference_level
        ).first()

        return recommendation_threshold or None


class LessonDimensionWeight(Syncable):
    """Django model used to store the weight of a dimension for a given lesson.

    It is declared as the ``through`` model for the ``dimensions`` ``ManyToManyField``
    of the ``Lesson`` model above.

    Attributes
    ----------
    lesson : django ``ForeignKey``
        The lesson for which the weight is declared.
    dimension : django ``ForeignKey``
        The dimension for which the weight is declared.
    weight : django ``PositiveIntegerField``
        The weight.

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

    """
    lesson = models.ForeignKey(
        'Lesson',
        related_name='dimension_weights',
        on_delete=models.CASCADE
    )
    dimension = models.ForeignKey(
        'assessments.Dimension',
        related_name='lesson_weights',
        on_delete=models.CASCADE
    )
    weight = models.PositiveIntegerField(default=0)

    objects = LessonDimensionWeightManager.from_queryset(
        LessonDimensionWeightQuerySet)()

    class Meta:
        ordering = ('-weight',)

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

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

        """
        return reverse('assessments:lesson-dimension-weight-edit', kwargs={
            'lesson_uuid': self.lesson.pk,
            'lessondimensionweight_uuid': self.pk
        })

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

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

        """
        return reverse('assessments:lesson-dimension-weight-delete', kwargs={
            'lesson_uuid': self.lesson.pk,
            'lessondimensionweight_uuid': self.pk
        })


class LessonRecommendationThreshold(Syncable):
    """
    Django model used to store the threshold that a trainee has to reach to
    earn a given recommendation for a given lesson. It is declared as the
    ``through`` model for the ``recommendation_levels`` ``ManyToManyField``
    of the ``Lesson`` model above.

    Attributes
    ----------
    lesson : django ``ForeignKey``
        The lesson for which the threshold is declared.
    level : django ``ForeignKey``
        The recommendation level for which the threshold is declared.
    threshold : django ``PositiveIntegerField``
        The threshold, i.e. minimal score to reach.

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

    """
    lesson = models.ForeignKey(
        'Lesson',
        related_name='recommendation_thresholds',
        on_delete=models.CASCADE
    )
    level = models.ForeignKey(
        'RecommendationLevel',
        related_name='lesson_thresholds',
        on_delete=models.CASCADE
    )
    threshold = models.PositiveIntegerField(default=0)

    objects = LessonRecommendationThresholdManager.from_queryset(
        LessonRecommendationThresholdQuerySet)()

    class Meta:
        ordering = ('-threshold',)

    def get_edit_url(self):
        """ Get the url to edit the current recommendation level threshold.

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

        """
        return reverse('assessments:lesson-recommendation-level-edit', kwargs={
            'lesson_uuid': self.lesson.pk,
            'lessonrecommendationthreshold_uuid': self.pk
        })

    def get_delete_url(self):
        """ Get the url to delete the current recommendation level threshold.

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

        """
        return reverse('assessments:lesson-recommendation-level-delete', kwargs={
            'lesson_uuid': self.lesson.pk,
            'lessonrecommendationthreshold_uuid': self.pk
        })


class UserLessonRecommendation(Syncable):
    """
    Django model used to store the recommendation level of a given trainee
    for the given lesson.

    Attributes
    ----------
    certification : django ``ForeignKey``
        The certification for which the recommendation has been made.
    lesson : django ``ForeignKey``
        The lesson related to this recommendation.
    level : django ``ForeignKey``
        The level of this recommendation.
    scores : django ``PositiveIntegerField``
        The computed score to obtain this recommendation.

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

    """

    # This field shouldn't be null. It has been add afterwards and we can't create
    # a migration with a RunPython script to populate it because it's impossible to find
    # their certification without information on the concerned session.
    certification = models.ForeignKey(
        CertificationProgramUserStatus,
        related_name='recommendations',
        null=True,
        on_delete=models.CASCADE
    )
    lesson = models.ForeignKey(
        'Lesson',
        related_name='user_recommendations',
        on_delete=models.CASCADE
    )
    level = models.ForeignKey(
        'RecommendationLevel',
        related_name='recommendations',
        on_delete=models.CASCADE
    )

    score = models.PositiveIntegerField()

    objects = UserLessonRecommendationManager.from_queryset(
        UserLessonRecommendationQuerySet)()

    class Meta:
        ordering = ('-level__sort_order',)
