"""This module contains the test cases for models of the ``questionnaires`` app"""

import os
import pandas as pd
from mock import patch, call

from django.conf import settings
from django.utils.translation import override, ugettext_lazy as _

from mtp.apps.common.tests.test_cases import WithMediaOnDisk
from mtp.apps.data_export.constants import EXCEL_STYLE_CHOICES
from mtp.apps.data_export.exporters import ExcelDataExporter

from ..factories import QuizFactory, OpenQuestionFactory
from ..models import (
    ChoicesAnswer,
    ChoicesQuestionMixin,
    OpenAnswer,
)

from .test_cases import QuestionnairesTestCase


class ChoicesQuestionModelTestCase(QuestionnairesTestCase):
    """Test case for the ``ChoicesQuestion`` model"""

    def test_create_or_update_user_answer_should_work(self):
        """Test that the ``create_or_update_user_answer`` should work correctly"""

        first_question = self.choices_questions[0]
        first_choice_id = first_question.choices.first().id
        last_choice_id = first_question.choices.last().id

        first_question.create_or_update_user_answer(
            session=self.training_session,
            user=self.trainee,
            choices=[first_choice_id]
        )

        choices_answer = ChoicesAnswer.objects.get(
            session=self.training_session,
            user=self.trainee,
            question=first_question
        )

        self.assertCountEqual(
            list(choices_answer.choices.values_list('id', flat=True)),
            [first_choice_id]
        )

        # We can add a new choice...
        first_question.create_or_update_user_answer(
            session=self.training_session,
            user=self.trainee,
            choices=[first_choice_id, last_choice_id]
        )

        choices_answer.refresh_from_db()

        self.assertCountEqual(
            list(choices_answer.choices.values_list('id', flat=True)),
            [first_choice_id, last_choice_id]
        )

        # Or delete one
        first_question.create_or_update_user_answer(
            session=self.training_session,
            user=self.trainee,
            choices=[last_choice_id]
        )

        choices_answer.refresh_from_db()

        self.assertCountEqual(
            list(choices_answer.choices.values_list('id', flat=True)),
            [last_choice_id]
        )

        # Or remove all the choices
        first_question.create_or_update_user_answer(
            session=self.training_session,
            user=self.trainee,
            choices=[]
        )

        choices_answer.refresh_from_db()

        self.assertEqual(
            list(choices_answer.choices.values_list('id', flat=True)),
            []
        )

    def test_get_trainee_session_score(self):
        """Test that the ``get_trainee_session_score`` method should work"""

        first_question = self.choices_questions[0]
        valid_choice_ids = first_question.choices.filter(is_valid=True).values_list(
            'id',
            flat=True
        )
        invalid_choice_ids = first_question.choices.filter(is_valid=False).values_list(
            'id',
            flat=True
        )

        # At first, since the user didn't select any answers, he should get 0 points
        self.assertEqual(
            first_question.get_trainee_session_score(self.trainee, self.training_session),
            0
        )

        # The user select only the valid choices
        first_question.create_or_update_user_answer(
            session=self.training_session,
            user=self.trainee,
            choices=valid_choice_ids
        )

        # The user should get the number of points of the question
        self.assertEqual(
            first_question.get_trainee_session_score(self.trainee, self.training_session),
            1
        )

        # The user select only the first valid choice
        first_question.create_or_update_user_answer(
            session=self.training_session,
            user=self.trainee,
            choices=[valid_choice_ids[0]]
        )

        # He should get 0 points
        self.assertEqual(
            first_question.get_trainee_session_score(self.trainee, self.training_session),
            0
        )

        # The user select one valid choice, and one invalid choice
        first_question.create_or_update_user_answer(
            session=self.training_session,
            user=self.trainee,
            choices=[valid_choice_ids[0], invalid_choice_ids[0]]
        )

        # He should get 0 points
        self.assertEqual(
            first_question.get_trainee_session_score(self.trainee, self.training_session),
            0
        )

        # The user select all the valid choices AND one invalid choice
        selected_choices = list(valid_choice_ids)
        selected_choices.append(invalid_choice_ids[0])

        first_question.create_or_update_user_answer(
            session=self.training_session,
            user=self.trainee,
            choices=selected_choices
        )

        # He should get 0 points
        self.assertEqual(
            first_question.get_trainee_session_score(self.trainee, self.training_session),
            0
        )

    def test_get_results_excel_data(self):
        """Test that the ``get_results_excel_data`` method is working correctly"""

        first_question = self.choices_questions[0]
        first_choice = first_question.choices.first()
        last_choice = first_question.choices.last()

        def get_expected_series_and_styles():
            """Shortcut method to get the expected series and styles"""

            valid_style = EXCEL_STYLE_CHOICES.WORKSHOP_STEP_ANSWER_VALID
            invalid_style = EXCEL_STYLE_CHOICES.DEFAULT

            return [
                (
                    pd.Series(
                        [
                            'x' if choice.is_selected(user, self.training_session) else ''
                            for user in self.training_session.users.non_deleted()
                        ],
                        name=choice.label
                    ),
                    valid_style if choice.is_valid else invalid_style
                )
                for choice in first_question.choices.non_deleted()
            ]

        def assert_series_and_styles_equals(expected, current):
            """Shortcut method to check that the provided series and styles are the same

            Parameters
            ----------
            expected: list(tuple)
                The list of data of reference
            current: list(tuple)
                The list of data we want to check

            """

            for expected_series, current_series in zip(expected, current):
                with self.subTest(expected_series=expected_series):
                    # The series are the same
                    pd.testing.assert_series_equal(
                        expected_series[0],
                        current_series[0]
                    )
                    # The style is correct
                    self.assertEqual(expected_series[1], current_series[1])

        # With one trainee, and no selected choices
        assert_series_and_styles_equals(
            first_question.get_results_excel_data(self.training_session),
            get_expected_series_and_styles()
        )

        # The trainee select the first choice
        first_question.create_or_update_user_answer(
            session=self.training_session,
            user=self.trainee,
            choices=[first_choice.id]
        )

        assert_series_and_styles_equals(
            first_question.get_results_excel_data(self.training_session),
            get_expected_series_and_styles()
        )

        # We add a second trainee to the session
        self.training_session.users.add(self.trainees[1])

        assert_series_and_styles_equals(
            first_question.get_results_excel_data(self.training_session),
            get_expected_series_and_styles()
        )

        # The second trainee select the last choice
        first_question.create_or_update_user_answer(
            session=self.training_session,
            user=self.trainees[1],
            choices=[last_choice.id]
        )

        assert_series_and_styles_equals(
            first_question.get_results_excel_data(self.training_session),
            get_expected_series_and_styles()
        )


class OpenQuestionModelTestCase(QuestionnairesTestCase):
    """Test case for the ``OpenQuestion`` model"""

    def test_create_or_update_user_answer_should_work(self):
        """Test that the ``create_or_update_user_answer`` should work correctly"""

        self.open_question.create_or_update_user_answer(
            session=self.training_session,
            user=self.trainee,
            content='test'
        )

        open_answer = OpenAnswer.objects.get(
            session=self.training_session,
            user=self.trainee,
            question=self.open_question
        )

        self.assertEqual(open_answer.content, 'test')

        # We can update the content
        self.open_question.create_or_update_user_answer(
            session=self.training_session,
            user=self.trainee,
            content='another test'
        )

        open_answer.refresh_from_db()

        self.assertEqual(open_answer.content, 'another test')

        # Or save an empty content
        self.open_question.create_or_update_user_answer(
            session=self.training_session,
            user=self.trainee,
            content=''
        )

        open_answer.refresh_from_db()

        self.assertEqual(open_answer.content, '')

    def test_get_results_excel_data(self):
        """Test that the ``get_results_excel_data`` method is working correctly"""

        # At first, empty answers, and one trainee
        pd.testing.assert_series_equal(
            self.open_question.get_results_excel_data(session=self.training_session)[0][0],
            pd.Series([''], name='Verbatim')
        )

        # The first trainee answer the open question
        self.open_question.create_or_update_user_answer(
            session=self.training_session,
            user=self.trainee,
            content='Answer for the first trainee'
        )

        pd.testing.assert_series_equal(
            self.open_question.get_results_excel_data(session=self.training_session)[0][0],
            pd.Series(['Answer for the first trainee'], name='Verbatim')
        )

        # Another trainee enter the session
        self.training_session.users.add(self.trainees[1])

        pd.testing.assert_series_equal(
            self.open_question.get_results_excel_data(
                session=self.training_session
            )[0][0].sort_values(),
            pd.Series(
                ['Answer for the first trainee', ''],
                name='Verbatim'
            ).sort_values()
        )

        # The second trainee answer the open question
        self.open_question.create_or_update_user_answer(
            session=self.training_session,
            user=self.trainees[1],
            content='Answer for the second trainee'
        )

        pd.testing.assert_series_equal(
            self.open_question.get_results_excel_data(
                session=self.training_session
            )[0][0].sort_values(),
            pd.Series(
                [
                    'Answer for the first trainee',
                    'Answer for the second trainee'
                ],
                name='Verbatim'
            ).sort_values()
        )


class ChoiceModelTestCase(QuestionnairesTestCase):
    """Test case for the ``Choice`` model"""

    def test_is_selected(self):
        """Test that the ``is_selected`` method is working correctly"""

        first_question = self.choices_questions[0]
        first_choice = first_question.choices.first()

        # At first, the choice shouldn't be selected
        self.assertFalse(
            first_choice.is_selected(user=self.trainee, session=self.training_session)
        )

        # We add one (another) choice
        first_question.create_or_update_user_answer(
            session=self.training_session,
            user=self.trainee,
            choices=[first_question.choices.last().id]
        )

        # Our choice is not selected
        self.assertFalse(
            first_choice.is_selected(user=self.trainee, session=self.training_session)
        )

        # We add our choice
        first_question.create_or_update_user_answer(
            session=self.training_session,
            user=self.trainee,
            choices=[first_choice.id]
        )

        # Our choice is now selected
        self.assertTrue(
            first_choice.is_selected(user=self.trainee, session=self.training_session)
        )


class AnswerModelTestCase(QuestionnairesTestCase):
    """Test case for the ``Answer`` model"""

    @classmethod
    def setUpTestData(cls):
        """Create a new answer"""

        super().setUpTestData()

        cls.first_question = cls.choices_questions[0]
        cls.first_choice_id = cls.first_question.choices.first().id

        cls.answer = cls.first_question.create_or_update_user_answer(
            session=cls.training_session,
            user=cls.trainee,
            choices=[cls.first_choice_id]
        )

    def test_get_broker_topic_method_should_work(self):
        """Test that the ``get_broker_topic``should correctly return the broker topic"""

        self.assertEqual(
            self.answer.get_broker_topic(),
            '%s/questionnaires/%s/answers' % (
                self.answer.session_id,
                self.answer.question.questionnaire_id
            )
        )

    def test_get_serialized_representation_should_work(self):
        """Test that the ``get_serialized_representation`` should work"""

        self.assertEqual(
            self.answer.get_serialized_representation(),
            self.answer.serializer_class(self.answer).data
        )

    def test_creating_a_new_answer_should_post_to_the_broker(self):
        """Test that creating a new answer should post to the broker"""

        with patch('mtp.apps.broker_models_hooks.tasks.publish_to_broker.delay') as mocked:
            new_answer = self.first_question.create_or_update_user_answer(
                session=self.training_session,
                user=self.trainees[2],
                choices=[self.first_choice_id]
            )

        serialized_answer = new_answer.get_serialized_representation()
        broker_topic = new_answer.get_broker_topic()

        serialized_answer_without_choices = serialized_answer.copy()
        serialized_answer_without_choices['choices'] = []

        # One CREATE call without the choices, and one UPDATE call with the choices
        calls = [
            call(
                serialized_answer_without_choices,
                broker_topic,
                'CREATE'
            ),
            call(
                serialized_answer,
                broker_topic,
                'UPDATE'
            ),
        ]

        mocked.assert_has_calls(calls)

    def test_updating_an_existing_answer_should_call_publish_to_broker(self):
        """Test that updating an existing answer should post to the broker"""

        with patch('mtp.apps.broker_models_hooks.tasks.publish_to_broker.delay') as mocked:
            self.first_question.create_or_update_user_answer(
                session=self.training_session,
                user=self.trainee,
                choices=[self.first_question.choices.last().id]
            )

        mocked.assert_called_with(
            self.answer.get_serialized_representation(),
            self.answer.get_broker_topic(),
            'UPDATE'
        )

        with patch('mtp.apps.broker_models_hooks.tasks.publish_to_broker.delay') as mocked:
            self.first_question.create_or_update_user_answer(
                session=self.training_session,
                user=self.trainee,
                choices=[]
            )

        mocked.assert_called_with(
            self.answer.get_serialized_representation(),
            self.answer.get_broker_topic(),
            'UPDATE'
        )


class ScoringQuestionnaireExportToExcelTestCase(WithMediaOnDisk, QuestionnairesTestCase):
    """Test case for the excel exporting behaviour ot the ``Questionnaire`` subclasses"""

    @staticmethod
    def get_user_index_name(user):
        """Shortcut method to get the user index name

        Parameters
        ----------
        user: MTPUser
            The user we want the index name for

        Returns
        -------
        str

        """

        return '%(last_name)s %(first_name)s (%(username)s)' % {
            'last_name': user.last_name,
            'first_name': user.first_name,
            'username': user.username
        }

    @override('en')
    def check_data_frame_values(self, data_frame, scores, mean_points, max_points):
        """Shortcut method to check the content of the provided data frame

        Parameters
        ----------
        data_frame: pandas.DataFrame
            The data frame we want to check
        scores: list(dict)
            The list of expected scores by trainee
        mean_points: list
            The list of expected values for the "Mean" index
        max_points: list
            The list of expected values for the "Maximum points" index

        """

        for trainee, score in scores.items():
            self.assertEqual(list(data_frame.loc[trainee].values), score)

        self.assertEqual(list(data_frame.loc[str(_('Mean'))].values), mean_points)
        self.assertEqual(list(data_frame.loc[str(_('Maximum points'))].values), max_points)

    def test_is_exportable(self):
        """Test that the ``is_exportable`` method is working"""

        # Brand new quiz
        new_quiz = QuizFactory()

        # We shouldn't be able to export this quiz (it contains no questions)
        self.assertFalse(new_quiz.is_exportable(self.training_session))

        # Create a new question on this quiz
        question = OpenQuestionFactory(questionnaire=new_quiz)

        # We shouldn't be able to export (contains no answers)
        self.assertFalse(new_quiz.is_exportable(self.training_session))

        # Create an answer on this question
        answer = question.create_or_update_user_answer(
            session=self.training_session,
            user=self.trainee,
            content='test'
        )

        # We should now be able to export
        self.assertTrue(new_quiz.is_exportable(self.training_session))

        # If we soft-delete the question, can't export
        question.remove()

        self.assertFalse(new_quiz.is_exportable(self.training_session))

        # If we soft-deleted the answer, can't export
        question.unremove()
        answer.remove()

        self.assertFalse(new_quiz.is_exportable(self.training_session))

    @override('en')
    def test_questionnaire_get_data_results_name(self):
        """Test "sheet_name" key from the ``get_data_results`` of the ``WithScoreMixin`` model"""

        sheet_name = self.quiz.get_results_excel_data(self.training_session)['sheet_name']
        self.assertEqual(sheet_name, str(_('Global scores')))

    def test_questionnaire_get_data_results_header_styles(self):
        """Test "header_styles" key from the ``get_data_results`` of the ``WithScoreMixin`` model"""

        # The style for the "Total" column + one style for each "assessing part"
        header_styles = self.quiz.get_results_excel_data(self.training_session)['header_styles']
        self.assertEqual(
            header_styles,
            [EXCEL_STYLE_CHOICES.HEADER] + [
                EXCEL_STYLE_CHOICES.WORKSHOP_STEP
            ] * self.quiz.questions.non_deleted().count()
        )

    @override('en')
    def test_questionnaire_get_data_results_data(self):
        """Test the "data" key from the ``get_data_results`` of the ``WithScoreMixin`` model"""

        data_frame = self.quiz.get_results_excel_data(self.training_session)['data']

        # Check the indexes of the data frame
        indexes = [
            self.get_user_index_name(self.trainee),
            str(_('Mean')),
            str(_('Maximum points'))
        ]
        self.assertEqual(list(data_frame.index.values), indexes)

        # Check the columns of the data frame
        columns = [
            '%d - %s' % (index, question.label)
            for index, question in enumerate(self.quiz.questions.non_deleted(), start=1)
        ]
        columns.insert(0, str(_('Total')))
        self.assertEqual(list(data_frame.columns.values), columns)

        # Check the values
        self.check_data_frame_values(
            data_frame,
            {
                self.get_user_index_name(self.trainee): [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
            },
            [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
            [6.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]
        )

        # New trainee
        self.training_session.users.add(self.trainees[1])

        data_frame = self.quiz.get_results_excel_data(self.training_session)['data']
        self.check_data_frame_values(
            data_frame,
            {
                self.get_user_index_name(self.trainee): [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
                self.get_user_index_name(self.trainees[1]): [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
            },
            [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
            [6.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]
        )

        # The first trainee answer correctly to the first question
        self.choices_questions[0].create_or_update_user_answer(
            session=self.training_session,
            user=self.trainee,
            choices=list(
                self.choices_questions[0].choices.filter(is_valid=True).values_list(
                    'id',
                    flat=True
                )
            )
        )

        data_frame = self.quiz.get_results_excel_data(self.training_session)['data']
        self.check_data_frame_values(
            data_frame,
            {
                self.get_user_index_name(self.trainee): [1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0],
                self.get_user_index_name(self.trainees[1]): [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
            },
            [0.5, 0.5, 0.0, 0.0, 0.0, 0.0, 0.0],
            [6.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]
        )

        # The second trainee do the same
        self.choices_questions[0].create_or_update_user_answer(
            session=self.training_session,
            user=self.trainees[1],
            choices=list(
                self.choices_questions[0].choices.filter(is_valid=True).values_list(
                    'id',
                    flat=True
                )
            )
        )

        data_frame = self.quiz.get_results_excel_data(self.training_session)['data']
        self.check_data_frame_values(
            data_frame,
            {
                self.get_user_index_name(self.trainee): [1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0],
                self.get_user_index_name(self.trainees[1]): [1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0],
            },
            [1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0],
            [6.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]
        )

    def test_questionnaire_get_data_results_with_same_question_title(self):
        """Test that we should still be allowed to export if two questions got the same title"""

        with self.training.override_version_state(self.training.VERSION_STATES.DRAFT):
            first_question = self.quiz.questions.non_deleted()[0]
            second_question = self.quiz.questions.non_deleted()[1]

            # Make the label of the second question the same as the first step
            second_question.label = first_question.label
            second_question.save()

        # This will raise if the test is not a success
        self.quiz.get_results_excel_data(self.training_session, detailed=True)

    def test_questionnaire_get_results_excel_data(self):
        """Test to check calling the ``get_results_excel_data`` with the "detailed" flag"""

        with patch.object(ChoicesQuestionMixin,
                          'get_results_excel_data') as mocked:
            self.quiz.get_results_excel_data(self.training_session)

        # Without the "detailed" flag, the mock should be not be called
        mocked.assert_not_called()

        return_value = [(pd.Series(), EXCEL_STYLE_CHOICES.DEFAULT)]

        # Now with the "detailed" flag
        with patch.object(ChoicesQuestionMixin,
                          'get_results_excel_data',
                          return_value=return_value) as mocked:
            self.quiz.get_results_excel_data(self.training_session, detailed=True)

        # One call for each of the questions of the workshop
        calls = [
            call(self.training_session)
        ] * self.quiz.questions.non_deleted().instance_of(ChoicesQuestionMixin).count()

        mocked.assert_has_calls(calls, any_order=True)

    def test_export_excel_from_data_results(self):
        """Test that exporting an excel file from the ``get_data_results`` should work"""

        path = os.path.join(settings.MEDIA_ROOT, 'test.xlsx')
        exporter = ExcelDataExporter(path)
        results = self.quiz.get_results_excel_data(self.training_session)

        exporter.add_sheet(**results)
        exporter.save()

        self.assertTrue(os.path.isfile(path))

    def test_export_excel_from_data_results_with_details(self):
        """Test exporting an excel file from the ``get_results_excel_data`` with details"""

        path = os.path.join(settings.MEDIA_ROOT, 'test.xlsx')
        exporter = ExcelDataExporter(path)
        results = self.quiz.get_results_excel_data(self.training_session, detailed=True)

        exporter.add_sheet(**results)
        exporter.save()

        self.assertTrue(os.path.isfile(path))

    def test_get_trainee_session_score(self):
        """test that the ``get_trainee_session_score`` method is working correctly"""

        # At first, no trainee answer, he should get 0
        self.assertEqual(
            self.quiz.get_trainee_session_score(session=self.training_session, user=self.trainee),
            0
        )

        # The trainee select all the valid answers on the first question
        self.choices_questions[0].create_or_update_user_answer(
            session=self.training_session,
            user=self.trainee,
            choices=list(
                self.choices_questions[0].choices.filter(is_valid=True).values_list(
                    'id',
                    flat=True
                )
            )
        )

        # The trainee should get 1 point
        self.assertEqual(
            self.quiz.get_trainee_session_score(session=self.training_session, user=self.trainee),
            1
        )

        # The trainee select all the invalid answers on the second question
        self.choices_questions[1].create_or_update_user_answer(
            session=self.training_session,
            user=self.trainee,
            choices=list(
                self.choices_questions[1].choices.filter(is_valid=False).values_list(
                    'id',
                    flat=True
                )
            )
        )

        # Still one point
        self.assertEqual(
            self.quiz.get_trainee_session_score(session=self.training_session, user=self.trainee),
            1
        )

        # The trainee select all the valid choices on the third question
        self.choices_questions[2].create_or_update_user_answer(
            session=self.training_session,
            user=self.trainee,
            choices=list(
                self.choices_questions[2].choices.filter(is_valid=True).values_list(
                    'id',
                    flat=True
                )
            )
        )

        # Now, 2 points
        self.assertEqual(
            self.quiz.get_trainee_session_score(session=self.training_session, user=self.trainee),
            2
        )

        # The trainee answer the open question
        self.open_question.create_or_update_user_answer(
            session=self.training_session,
            user=self.trainee,
            content=''
        )

        # Still 2 points
        self.assertEqual(
            self.quiz.get_trainee_session_score(session=self.training_session, user=self.trainee),
            2
        )
