"""This module contains the tasks for the ``saba`` discrete app"""

import copy
import logging

from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned
from django.db.models import Q

from constance import config

from mtp.apps.assessments.models.certifications import CertificationProgramUserStatus
from mtp.apps.assessments.models import Lesson
from mtp.apps.tasks.tasks import DjangoBackedCeleryTask
from mtp.celery import app
from mtp.custom.nissan.apps.import_export.saba import UserImporter

from .client import SabaApiClient, SabaApiException
from .errors import (
    ExemptOrRecommendedCourseError,
    MissingMTPDataError,
    MissingSabaDataError,
    MissingKeyInDataError,
)
from .exporters import SabaResultsExporter
from .mixins import SabaStepsMixin
from .models import CertificationProgramUserStatusDjangoTask

logger = logging.getLogger('mtp.discrete_apps.saba')


class CertificationProgramUserStatusTask(DjangoBackedCeleryTask):
    """``CertificationProgramUserStatus`` related linked with a ``DjangoTask``

    This a specialized case of DjangoBackedCeleryTask that stores a relation
    with a ``CertificationProgramUserStatus``.

    Notes
    -----
    RFC https://github.com/magencydigital/mhr/issues/1294
    """

    django_task_model = CertificationProgramUserStatusDjangoTask

    def create_django_task(self, task_id, user, **kwargs):
        """Create the ``django_task`` from the provided params

        For the parameters, see ``mtp.apps.tasks.tasks.DjangoBackedCeleryTask``
        """

        certification = kwargs.get('certification', None)

        if not certification:
            raise TypeError('No certification provided')

        django_task, __ = self.django_task_model.objects.get_or_create(
            celery_task_id=task_id,
            user=user,
            certification=certification
        )

        return django_task, kwargs


@app.task
def sync_assessments_to_saba(saba_api_client=None):
    """Master task to export assessments results to Saba

    This task will launch one ``AssessmentsResultsToSabaTask``
    for each assessments pending synchronization.

    We do not need a ``DjangoBackedCeleryTask`` here since we don't need
    to follow them in DB.

    Parameters
    ----------
    saba_api_client: instance of ``SabaApiClient`` or mock, optionnal
        To overload the client used in sub-tasks

    Notes
    -----
    We have been unable to overload ``task.saba_api_client`` with saba_api_client
    directly from here (maybe because of the way Celery handles Classes), so I have
    to pass it as kwarg to the task.

    """

    if not config.SABA_SYNCHRO_ENABLED:
        return

    # get all CertificationProgramUserStatuses to be sent to Saba
    certifications = CertificationProgramUserStatus.objects.filter(
        sync_status=CertificationProgramUserStatus.SYNC_STATUSES.QUEUED
    )

    # do the job for each certification
    for certification in certifications:
        AssessmentsResultsToSabaTask().delay(
            certification.sync_trigger_user,
            certification=certification,
            saba_api_client=saba_api_client,
        )


@app.task
def send_saba_recap_email():
    """Task to send a recap email of the Saba tasks completed in last 24h"""

    if not config.SABA_SYNCHRO_ENABLED:
        return

    exporter = SabaResultsExporter()
    exporter.send_recap_email()


class AssessmentsResultsToSabaTask(SabaStepsMixin, CertificationProgramUserStatusTask):
    """Celery task to populate assessments results to saba

    This task is responsible to populate the assessment results for a given trainee / session
    to the SABA platform, using the ``SabaApiClient``.

    The actions done by this task are the following, in this order:

    I - Gather info from DB and Saba
        1 - Find the ``CertificationProgramUserStatus``
        2 - Find the SABA user which corresponds to our trainee
        3 - Find the SABA course which corresponds to our training via external_id
        4 - Find the SABA details of enrollment for given course and user
    II - Update the "attendance" status and the results of the assessed trainee
        1 - Update the "attendance" of the trainee
        2 - Update the global score and the status (Successful/Unsuccessful)
    III - For each classes recommendations of our ``Curriculum``:
        1 - Find the corresponding SABA class, and, depending of the trainee's level of
            recommendation regarding this class:
            - MASTERED: Create a new SABA transcript with a "delivery type" set to "exempt"
            - RECOMMENDED|MANDATORY: Create a new "CourseActivity"
    IV - Set `sync_status` attribute on ```CertificationProgramUserStatus``

    Attributes
    ----------
    CERTIFICATION_STATUSES_FAIL: tuple
        List of ``CertificationStatus.name`` that map to a failure in Saba.
    CERTIFICATION_STATUSES_SUCCESS: tuple
        List of ``CertificationStatus.name`` that map to a success in Saba.
    STEPS: tuple
        The immutable and ordered list of steps taken in this task.
    certification: ``CertificationProgramUserStatus`` instance
        The certification we are working on.
    data: dict
        Holds data for each step of the process, also acts as a return value.
    saba_api_client: ``SabaApiClient`` instance
        Our interface to the Saba API.

    Notes
    -----
    The process is divided in steps as atomic as possible, see the ``STEPS`` attribute.

    Along the way, the process writes its results in the data attribute. It is a dict holding all
    the output from each step. It has a success key for each step relative to the success or
    failure of a particular step. It also has a global status key (OK or KO for now) relative
    to the success or failure of the whole process, and an exception message if need be.

    You can see a full example of the data structure in ``mtp.discrete_apps.saba.tests.tests.tasks``
    in the function ``test_certification_program_user_status_task_run``.

    This data structure is stored in a redis-backed ``results`` attribute on
    ``CertificationProgramUserStatusDjangoTask`` so that we can retrieve it later
    (e.g. for the recap email after the cron).

    The data structure is also used in the steps to retrieve information gathered by previous
    steps. This has several advantages over just storing those results on dedicated
    attributes on the class:
    - easily testable: just build the required steps output and feed it to the step you want to test
    - will be used to resume tasks that failed and can be retried: by feeding the previous task's
    data to the new task, the process will resume at the first step that failed
    - acts as a complete log of the process, useful for the recap email and for debug purposes

    At the end of the process, be it a success or managed failure, the
    ``CertificationProgramUserStatus.sync_status`` is updated accordingly.

    """

    CERTIFICATION_STATUSES_FAIL = ('Referred', )
    CERTIFICATION_STATUSES_SUCCESS = ('Qualified', )
    ENROLLMENT_VALID_STATUSES = ('INPROGRESS', )
    STEPS = (
        'parse_certification',
        'get_trainee_details',
        'get_enrollment_details',
        'set_registration',
        'set_exempt_courses',
        'set_mandatory_courses',
    )

    certification = None
    data = None
    saba_api_client = None

    def init_client(self):
        """Create and returns the ``SabaApiClient``

        Returns
        -------
        SabaApiClient
            The Saba Api Client

        """

        # Connect to SABA using our ``SabaApiClient``
        return SabaApiClient(
            config.SABA_API_BASE_URL,
            config.SABA_API_USERNAME,
            config.SABA_API_PASSWORD
        )

    def parse_certification(self):
        """Extract needed info from the ``CertificationProgramUserStatus`` instance

        Returns
        -------
        dict
            The certification result details, for example:
            {
                "certification_id": "85797bf8-7dbd-4340-8d4a-464083bc4ff0",
                "saba_trainee_id": "FR020406",
                "course_external_id": "2015-11-SALE-ASSESS",
                "session_start_date": "2016-05-30",
                "session_status": "Qualified",
                "session_score": 50,
                "exempted_courses": [
                    {
                        'external_id': '2013-06-SALE-FLL1E1',
                        'success': False,
                    }
                ]
                "mandatory_courses": [
                    {
                        'external_id': '2013-06-SALE-FLL1E2',
                        'success': False,
                    }
                ]
            }

        Raises
        ------
        MissingMTPDataError
            If one of those exceptions is encountered:
                - task has not been provided a ``certification`` attribute
                - ``certification.user.external_id`` is empty
                - ``certification.session`` is empty
                - ``certification.session.training.external_id`` is empty

        """

        # Get the trainee `external_id`
        trainee = self.certification.user
        if not trainee.original_or_self_external_id:
            exception_message = 'User with id %s does not seem to be linked to a SABA user' % (
                trainee.id
            )
            user_message = 'The user does not have a NAO id in NAD. \
                Treat this case manually in NAO as you used to do in the past'
            raise MissingMTPDataError(exception_message, user_message=user_message)

        # Get the course `external_id`
        if not self.certification.session:
            exception_message = 'No session linked to certification %s' % (
                self.certification.pk
            )
            user_message = 'Please contact support Magency'
            raise MissingMTPDataError(exception_message, user_message=user_message)

        # NB: per the model, certification.session.training can not be null

        course_external_id = self.certification.session.training.external_id

        if not course_external_id:
            exception_message = 'Missing or invalid external_id for training %s' % (
                self.certification.session.training.id
            )
            user_message = 'The reference of the training is missing in NAD. \
                Add the SABA reference to the course of your curriculum'
            raise MissingMTPDataError(exception_message, user_message=user_message)

        # get mastered courses
        mastered_recos = self.certification.recommendations.filter(
            level__name='Mastered'
        ).all()
        exempted_courses = self._get_recommendations_initial_data(mastered_recos)

        # get recommended courses
        recos = self.certification.recommendations.filter(
            Q(level__name='Recommended') | Q(level__name='Mandatory')
        ).all()
        mandatory_courses = self._get_recommendations_initial_data(recos)

        return {
            'certification_id': self.certification.pk,
            'saba_trainee_id': trainee.original_or_self_external_id,
            'course_external_id': course_external_id,
            'session_start_date': str(self.certification.session.start_date.date()),
            'session_status': self.certification.status.name,
            'session_score': self.certification.score,
            'exempted_courses': exempted_courses,
            'mandatory_courses': mandatory_courses
        }

    def get_trainee_details(self):
        """Get the trainee details from Saba

        Returns
        -------
        dict
            The trainee details, for example:
            {
                "id": "persn000000000018835"
            }

        Raises
        ------
        MissingSabaDataError
            If the ``id`` is not found in Saba results.

        """

        saba_trainee_id = self._get_step_data('parse_certification', 'saba_trainee_id')

        # Get the user details from SABA, using his "external_id"
        saba_trainee_details = self.saba_api_client.user_details(
            username=saba_trainee_id,
            fields=['id']
        )

        # Did we get the much needed ``saba_trainee_details.id``?
        if not saba_trainee_details.get('id'):
            exception_message = 'SABA did not reply to user_details with an id for user %s' % (
                saba_trainee_id
            )
            user_message = 'The user is missing in NAO. Please contact NAO Helpdesk.'
            raise MissingSabaDataError(exception_message, user_message=user_message)

        return saba_trainee_details

    def get_enrollment_details(self):
        """Get the enrollment details from Saba

        Returns
        -------
        dict
            The enrollment details, for example:
            {
                "id": "regdw000000000003201",
                "class_id": {
                    "id": "class000000000200443",
                    "displayName": "CE04ILT04"
                },
            },

        Raises
        ------
        MissingSabaDataError
            If the ``id`` or ``class_id.id`` is not found in Saba results, or if the status
            is not found or not valid.

        """

        date = self._get_step_data('parse_certification', 'session_start_date')
        saba_id = self._get_step_data('get_trainee_details', 'id')
        course_external_id = self._get_step_data('parse_certification', 'course_external_id')

        enrollment = self.saba_api_client.get_enrollment(
            saba_id,
            course_external_id,
            date
        )

        # we may need this multiple times below
        exception_info = {
            'user_id': saba_id,
            'course_external_id': course_external_id,
            'date': date,
        }

        # Did we get ``enrollment.id``?
        if not enrollment.get('id'):
            exception_message = 'SABA did not reply to get_enrollment with an id for\
                course %(course_external_id)s, user %(user_id)s and date %(date)s' % exception_info
            user_message = 'The user is not registered to a class in NAO. \
                Go to NAO and register the person to the class'
            raise MissingSabaDataError(exception_message, user_message=user_message)

        # Did we get ``enrollment.offering_action_id.id``?
        if not enrollment.get('class_id', {}).get('id'):
            exception_message = 'SABA did not reply to get_enrollment with \
                enrollment.class_id.id for\
                course %(course_external_id)s, user %(user_id)s and date %(date)s' % exception_info
            user_message = 'The user is not enrolled to the right qualification session in NAO. \
                Go to NAO and change the date of the assessment session to match the one in NAD.'
            raise MissingSabaDataError(exception_message, user_message=user_message)

        # now verify the enrollment is "in progress"
        enrollement_details = self.saba_api_client.get_enrollment_details(enrollment['id'])

        status = enrollement_details.get(
            'registrationInfo', {}
        ).get(
            'enrollmentStatus', {}
        ).get(
            'name'
        )

        if not status:
            exception_message = 'Could not find `enrollmentStatus.name` for enrollment %s' % (
                enrollment.get('id')
            )
            user_message = 'The enrollement details from Saba are not complete for course %s' % (
                course_external_id
            )
            raise MissingSabaDataError(exception_message, user_message=user_message)

        if status not in self.ENROLLMENT_VALID_STATUSES:
            exception_message = 'Enrollment %s status is not valid (%s)' % (
                enrollment.get('id'),
                status,
            )
            user_message = 'The enrollment is not in progress for course %s' % (
                course_external_id
            )
            raise MissingSabaDataError(exception_message, user_message=user_message)

        return enrollment

    def set_registration(self):
        """Set the registration on Saba

        Mark attendance, then complete enrollment with score and status

        Raises
        ------
        MissingMTPDataError
            If the ``session_status`` is not known.

        """

        offering_id = self._get_step_data('get_enrollment_details', 'class_id', subkey='id')
        saba_id = self._get_step_data('get_trainee_details', 'id')

        # Mark attendance
        self.saba_api_client.mark_attendance(
            saba_id,
            offering_id
        )

        registration_id = self._get_step_data('get_enrollment_details', 'id')
        db_status = self._get_step_data('parse_certification', 'session_status')
        score = self._get_step_data('parse_certification', 'session_score')

        if db_status in self.CERTIFICATION_STATUSES_FAIL:
            status = 400
        elif db_status in self.CERTIFICATION_STATUSES_SUCCESS:
            status = 200
        else:
            exception_message = 'Unknown status in db: %s' % db_status
            user_message = 'Unknown error in NAD'
            raise MissingMTPDataError(exception_message, user_message=user_message)

        # Complete enrollment
        self.saba_api_client.complete_enrollment(
            registration_id,
            status,
            score
        )

    def _get_recommendations_initial_data(self, recommendations):
        """Extract `lesson.external_id`s from a list of ``UserLessonRecommendation``
        and init a data dict that will be used for storing results later.

        Parameters
        ----------
        recommendations : list(``mtp.assessments.models.UserLessonRecommendation``)
            The list of recommendations to parse

        Returns
        -------
        list
            List of initial data structure for a lesson, for example:
            [
                {
                    'external_id': '2013-06-SALE-FLL1E1',
                    'success': False,
                    'exception': 'No external_id for lesson XXX',
                }
            ]

        """

        initial_data = []

        for reco in recommendations:
            if reco.lesson.reference and reco.lesson.reference.external_id != '':
                initial_data.append({
                    'local_id': reco.lesson.id,
                    'external_id': reco.lesson.reference.external_id,
                    'success': False,
                })
            else:
                exception_message = 'No external_id for lesson %s (%s)' % (
                    reco.lesson.id,
                    reco.lesson.name,
                )
                initial_data.append({
                    'local_id': reco.lesson.id,
                    'external_id': None,
                    'success': False,
                    'exception': exception_message,
                })

        return initial_data

    def _check_course_initial_status(self, course):
        """Helper to make pre-requisite check against a recommended or exempt course
        and get it's details from Saba.

        Parameters
        ----------
        course: dict
            The course to check against

        Returns
        -------
        tuple(bool, dict)
            Are the pre-requisites ok?, amended course w/ saba info or error

        """

        course_external_id = course.get('external_id')

        # If the external_id was empty, check again against the real database object
        # using the local_id entry (the reference was maybe updated after the first attempt)
        if not course_external_id:
            try:
                course_external_id = Lesson.objects.get(
                    id=course.get('local_id')
                ).reference.external_id
            except (AttributeError, ObjectDoesNotExist, MultipleObjectsReturned):
                return False, course

        # let's not hit Saba with an undefined course external_id and
        # let's not replay an already successfully sent transcript
        if course_external_id and not course.get('success'):
            try:
                course_details = self.saba_api_client.get_course_details(
                    course_external_id
                )
            except SabaApiException:
                exception_message = 'The course id %s is not recognized in NAO' % (
                    course_external_id
                )
                course['exception'] = exception_message
                course['success'] = False
                return False, course
            else:
                course['internal_id'] = course_details['id']
                return True, course
        else:
            return False, course

    def set_exempt_courses(self):
        """Try to create a transcript on Saba for each Mastered lessons of the Curriculum

        Notes
        -----
        This method never fails willingly. Instead, error collection is done by the parent
        process, see ``_execute_step``.

        Returns
        -------
        list(dict)
            A list of data structures holding the results of the process for each course,
            for example:
            [
                {
                    'external_id': '2013-06-SALE-FLL1E1',
                    'success': True,
                    'internal_id': 'cours00000312312321',
                }, {
                    'external_id': '2013-06-SALE-FLL1E2',
                    'success': False,
                    'exception': 'An error occured',
                },
            ]

        """

        saba_id = self._get_step_data('get_trainee_details', 'id')

        # let's see if we have a previous run data, or use ``parse_certification`` output
        try:
            exempted_courses = self._get_step_data('set_exempt_courses')
        except KeyError:
            # in this case, we do not want to modify original values, so let's copy it
            exempted_courses = copy.deepcopy(
                self._get_step_data('parse_certification', 'exempted_courses')
            )

        for exempted_course in exempted_courses:
            is_ok_to_be_sent, exempted_course = self._check_course_initial_status(exempted_course)
            if not is_ok_to_be_sent:
                continue

            # Create transcript
            delivery_type = 'exempt'
            try:
                response = self.saba_api_client.create_transcript(
                    saba_id,
                    exempted_course['internal_id'],
                    delivery_type
                )

                # Store the SABA response, just in case we need to say "not our bad" :)
                exempted_course['response'] = response

                # We check 3 things here:
                # - The 'successCount' key exists in the response and must be equal to 1
                # - The 'errorCount' key exists in the response and must be equal to 0
                # - The 'successMap' key must exists in the response
                try:
                    assert (
                        response['successCount'] == 1 and response['errorCount'] == 0 and
                        'successMap' in response
                    )
                except (AssertionError, KeyError):
                    raise SabaApiException(
                        500,
                        'Unable to create transcript for course %s. Unknown error from Saba' % (
                            exempted_course['internal_id']
                        ),
                        user_message='Retry the synchronization. If the problem persists,'
                                     'please contact NAO/NAD support.'
                    )

                exempted_course['success'] = True
            except SabaApiException as exception:
                exempted_course['success'] = False
                exempted_course['exception'] = str(exception)

        return exempted_courses

    def set_mandatory_courses(self):
        """Try to create a course activity on Saba for each Recommended and Mandatory lessons of
        the Curriculum

        Notes
        -----
        This method never fails willingly. Instead, error collection is done by the parent
        process, see ``_execute_step``.

        Returns
        -------
        list(dict)
            A list of data structures holding the results of the process for each course,
            for example:
            [
                {
                    'external_id': '2013-06-SALE-FLL1E1',
                    'success': True,
                    'internal_id': 'cours00000312312321',
                }, {
                    'external_id': '2013-06-SALE-FLL1E2',
                    'success': False,
                    'exception': 'An error occured',
                },
            ]

        """

        saba_id = self._get_step_data('get_trainee_details', 'id')

        # let's see if we have a previous run data, or use ``parse_certification`` output
        try:
            mandatory_courses = self._get_step_data('set_mandatory_courses')
        except KeyError:
            # in this case, we do not want to modify original values, so let's copy it
            mandatory_courses = copy.deepcopy(self._get_step_data(
                'parse_certification',
                'mandatory_courses'
            ))

        for mandatory_course in mandatory_courses:
            is_ok_to_be_sent, mandatory_course = self._check_course_initial_status(
                mandatory_course
            )
            if not is_ok_to_be_sent:
                continue

            # Check if the course was already done, ie: transcripts already exist for this course
            transcripts = self.saba_api_client.get_transcripts(
                saba_id,
                mandatory_course['external_id']
            )

            # If that's the case, we need to delete theses
            for transcript in transcripts:
                self.saba_api_client.delete_transcript(transcript['id'])

            # Do we have any activities for this course ?
            activities = self.saba_api_client.get_course_activities(
                saba_id,
                mandatory_course['external_id']
            )

            # If that's the case, we need to delete theses
            for activity in activities:
                self.saba_api_client.delete_course_activity(
                    activity['id']
                )

            # Create course activity
            try:
                self.saba_api_client.course_activity(
                    config.SABA_API_USERNAME_INTERNAL_ID,
                    saba_id,
                    mandatory_course['internal_id']
                )
                mandatory_course['success'] = True
            except SabaApiException as exception:
                mandatory_course['success'] = False
                mandatory_course['exception'] = str(exception)

        return mandatory_courses

    def run(self, certification=None, saba_api_client=None, **kwargs):
        """Main task routine

        Parameters
        ----------
        certification: ``CertificationProgramUserStatus`` instance
            The ``CertificationProgramUserStatus`` from which we want to send the information
            to SABA
        saba_api_client: instance of ``SabaApiClient`` or mock, optionnal
            To override the client used in task.

        Notes
        -----
        We are using this method to set the required arguments, since defining theses in the
        ``__init__`` will make all the tasks in the same process using them.

        See Also
        --------
        http://docs.celeryproject.org/en/latest/userguide/tasks.html#instantiation

        """

        if not isinstance(certification, CertificationProgramUserStatus):
            exception_message = 'No CertificationProgramUserStatus provided'
            raise MissingMTPDataError(exception_message)
        self.certification = certification

        # let's check if we have a previous result for the same certification
        previous_run = certification.tasks.exclude(
            celery_task_id=self.request.id
        ).order_by('-started_at').first()

        # and inject its output data as input to the current task if any
        self.data = (
            previous_run.redis_instance.results
            if previous_run and previous_run.redis_instance.results
            else {}
        )

        if previous_run:
            # Reset the error messages
            self.data['exception_user_message'] = ''
            self.data['exception'] = ''

            # Remove the data of the last executed step, because we are replaying this one
            self.data['executed_steps'].pop()

        try:
            # create the API client, can be overriden in tests
            self.saba_api_client = saba_api_client if saba_api_client else self.init_client()

            # execute all the steps
            for step in self.STEPS:
                self._execute_step(step)

            # set the sync_status on certification
            if self.certification:
                ok_status = CertificationProgramUserStatus.SYNC_STATUSES.SENT_OK
                self.certification.sync_status = ok_status
                self.certification.save()

            logger.info('AssessmentsResultsToSabaTask completed w/ data: %s', self.data)

            self.data['status'] = str(CertificationProgramUserStatus.SYNC_STATUSES.SENT_OK.display)
            self._persist_results()

            # dummy celery result
            return 'OK'

        except (
                SabaApiException,
                MissingMTPDataError,
                MissingSabaDataError,
                MissingKeyInDataError,
                ExemptOrRecommendedCourseError,
        ) as exception:

            self.data['status'] = str(CertificationProgramUserStatus.SYNC_STATUSES.SENT_KO.display)
            self.data['exception'] = str(exception)
            self.data['exception_user_message'] = exception.user_message
            self._persist_results()

            exception_message = 'AssessmentsResultsToSabaTask exception: %s, data was:\n %s' % (
                exception,
                self.data
            )
            logger.error(exception_message)

            # set the sync_status on certification
            if self.certification:
                ko_status = CertificationProgramUserStatus.SYNC_STATUSES.SENT_KO
                self.certification.sync_status = ko_status
                self.certification.save()

            # dummy celery result
            return 'KO'


@app.task
def import_users_from_saba():
    """Celery task to import users from a the file path defined in the config

    Basically, we are just calling the ``UserImporter``.

    See Also
    --------
    ``saba.UserImporter``

    """

    if not config.SABA_ENABLED:
        return

    UserImporter(file_path=config.SABA_IMPORT_USERS_FILE_PATH).import_users()
