"""Test the models and managers of the ``trainings`` app"""

from datetime import timedelta
from mock import call, patch

from django.utils import timezone

from mtp.apps.accounts.factories import TraineeFactory
from mtp.apps.assessments.models import CompetencyTree
from mtp.apps.assessments.tests.test_management_commands import BaseComputeAssessmentScoresTestCase
from mtp.apps.brainstormers.factories import BrainstormerTemplateFactory
from mtp.apps.common.tests.test_cases import MTPTestCase
from mtp.apps.metaplans.factories import MetaplanTemplateFactory
from mtp.apps.workshops.factories import AssessingWorkshopFactory

from mtp.discrete_apps.versionable.exceptions import NotDraftable

from ..factories import (
    TrainingActivityFactory,
    TrainingFactory,
    TrainingSessionFactory,
)
from ..models import Training, TrainingSession
from ..redis_models import Training as RedisTraining

from .test_cases import TrainingWithActivitiesTestCase


class TrainingModelTestCase(TrainingWithActivitiesTestCase):
    """Test the ``Training`` model."""

    def test_get_members_to_duplicate_results(self):
        """Test the ``get_members_to_duplicate`` method."""

        to_duplicates = self.training.get_members_to_duplicate()

        self.assertListEqualUnordered(to_duplicates, [
            # trainer owning a group (a "manager") receive a ``DRAFT``
            {
                'version_state': self.training.VERSION_STATES.DRAFT,
                'owner': self.trainer_manager,
                'business_unit': self.business_unit_2
            },
            # trainer not owning a group receive a ``PUBLISHED``
            {
                'version_state': self.training.VERSION_STATES.PUBLISHED,
                'owner': self.new_trainer,
                'business_unit': self.business_unit
            },
        ])

        # Create a training for one of these users (we simply create the training to simulate a
        # duplicate, which is a long operation)
        training = TrainingFactory(
            owner=self.new_trainer,
            business_unit=self.business_unit,
            parent_version=self.training,
            original_version=self.training.original_version,
        )
        training.version_state = training.VERSION_STATES.PUBLISHED
        training.save()

        # A new call to ``get_members_to_duplicate`` should only return one entry

        to_duplicates = self.training.get_members_to_duplicate()

        self.assertListEqualUnordered(to_duplicates, [
            {
                'version_state': self.training.VERSION_STATES.DRAFT,
                'owner': self.trainer_manager,
                'business_unit': self.business_unit_2
            },
        ])

        # Check with ``replace_published`` set to ``True``
        to_duplicates = self.training.get_members_to_duplicate(replace_published=True)

        self.assertListEqualUnordered(to_duplicates, [
            # trainer owning a group (a "manager") receive a ``DRAFT``
            {
                'version_state': self.training.VERSION_STATES.DRAFT,
                'owner': self.trainer_manager,
                'business_unit': self.business_unit_2
            },
            # trainer not owning a group receive a ``PUBLISHED``, even if he already has one
            {
                'version_state': self.training.VERSION_STATES.PUBLISHED,
                'owner': self.new_trainer,
                'business_unit': self.business_unit
            },
            # We also have the original trainer (which received the training in a duplicate done
            # in `BaseMTPTestCase.setUpTestData`
            {
                'version_state': self.training.VERSION_STATES.PUBLISHED,
                'owner': self.trainer,
                'business_unit': self.business_unit
            },
        ])

    def test_get_members_to_duplicate_with_pks_results(self):
        """Test the ``get_members_to_duplicate`` method."""

        to_duplicates = self.training.get_members_to_duplicate(use_pks=True)

        self.assertListEqualUnordered(to_duplicates, [
            # trainer owning a group (a "manager") receive a ``DRAFT``
            {
                'version_state': self.training.VERSION_STATES.DRAFT,
                'owner_id': self.trainer_manager.pk,
                'business_unit_id': self.business_unit_2.pk
            },
            # trainer not owning a group receive a ``PUBLISHED``
            {
                'version_state': self.training.VERSION_STATES.PUBLISHED,
                'owner_id': self.new_trainer.pk,
                'business_unit_id': self.business_unit.pk
            },
        ])

    def test_duplicate_to_member_not_async_should_call_create_new_version(self):
        """Test that the ``duplicate_to_member`` method works in synchronous mode."""

        # We'll only check if the ``create_new_version`` function is called
        with patch.object(Training, 'create_new_version') as patched:
            new_training = self.training.duplicate_to_member(
                new_state=self.training.VERSION_STATES.PUBLISHED,
                params={
                    'owner': self.new_trainer,
                    'business_unit': self.business_unit
                }
            )

        # We should have one call to ``Training.create_new_version``
        patched.assert_called_once_with(
            new_state=self.training.VERSION_STATES.PUBLISHED,
            params={
                'owner': self.new_trainer,
                'business_unit': self.business_unit,
            },
        )

    def test_duplicate_to_member_async_should_duplicate(self):
        """Test that the ``duplicate_to_member`` method works in asynchronous mode."""

        # No mock here to test the full path in async mode
        self.training.duplicate_to_member(
            new_state=self.training.VERSION_STATES.PUBLISHED,
            params={
                'owner': self.new_trainer,
                'business_unit': self.business_unit
            },
            async=True
        )

        # Get the training from the DB as the previous async call didn't return a training
        new_training = self.new_trainer.trainings.first()

        self.check_training(new_training, {
            'parent_version': self.training,
            'version_state': self.training.VERSION_STATES.PUBLISHED,
            'owner': self.new_trainer,
            'business_unit': self.business_unit,
        }, 6)

    def test_duplicate_to_member_should_archive_previous_training(self):
        """Test that the published training on non-manager trainers are archived after duplicate"""

        # Start by a normal duplicate to manager and simple trainer
        new_trainings = self.training.duplicate_to_members()

        self.assertEqual(len(new_trainings), 2)
        for training in new_trainings:
            if training.owner == self.trainer_manager:
                manager_training = training
            elif training.owner == self.new_trainer:
                trainer_training = training
            else:
                self.fail('Unexpected training owner')

        # Publish the one for the manager, to check that it will still be published later
        manager_training.publish()

        # Ensure the status of the trainings are correct
        self.assertEqual(manager_training.version_state, Training.VERSION_STATES.PUBLISHED)
        self.assertEqual(trainer_training.version_state, Training.VERSION_STATES.PUBLISHED)

        # Now do the duplicate again, asking to archive
        new_trainings = self.training.duplicate_to_members(replace_published=True)

        self.assertEqual(len(new_trainings), 2)
        for training in new_trainings:
            # We have one for the simple trainer
            if training.owner == self.new_trainer:
                trainer_training2 = training
            # And one for the original trainer, but we don't care about it
            elif training.owner == self.trainer:
                pass
            else:
                self.fail('Unexpected training owner')

        # Ensure the status of the new training for the simple trainer is correct
        self.assertEqual(trainer_training2.version_state, Training.VERSION_STATES.PUBLISHED)

        # Reload previous training
        manager_training.refresh_from_db()
        trainer_training.refresh_from_db()

        # And check their status
        self.assertEqual(manager_training.version_state, Training.VERSION_STATES.PUBLISHED)
        self.assertEqual(trainer_training.version_state, Training.VERSION_STATES.ARCHIVED)

    def test_duplicate_to_member_should_set_a_lock(self):
        """Test that the ``duplicate_to_member`` method set a lock."""

        params = {
            'owner': self.new_trainer,
            'business_unit': self.business_unit
        }

        # Ensure the lock is not set
        self.assertFalse(self.training.redis_instance.is_duplicate_to_member_locked(**params))

        # We prevent the lock to be released
        with patch.object(RedisTraining, 'release_duplicate_to_member_lock'):

            # We don't need the duplicate to really be done
            with patch.object(Training, 'create_new_version'):
                self.training.duplicate_to_member(
                    new_state=self.training.VERSION_STATES.PUBLISHED,
                    params=params
                )

        # Now we should have the lock set
        self.assertTrue(self.training.redis_instance.is_duplicate_to_member_locked(**params))

    def test_duplicate_to_member_should_stop_if_already_in_progress(self):
        """Test that we cannot run the same duplicate twice at the same time."""

        params = {
            'owner': self.new_trainer,
            'business_unit': self.business_unit
        }

        # Simulate another process running the same duplicate
        self.training.redis_instance.lock_duplicate_to_member(**params)

        # First in async mode, activating the exception
        with self.assertRaises(Training.DuplicateInProgress):
            self.training.duplicate_to_member(
                new_state=self.training.VERSION_STATES.PUBLISHED, params=params,
                async=True, skip_in_progress=False
            )

        # Then still in async mode, but skipping if in progress
        result = self.training.duplicate_to_member(
            new_state=self.training.VERSION_STATES.PUBLISHED, params=params,
            async=True, skip_in_progress=True
        )

        # Should return nothing if the duplicate was skipped
        self.assertIsNone(result)

        # Then in sync mode, activating the exception
        with self.assertRaises(Training.DuplicateInProgress):
            self.training.duplicate_to_member(
                new_state=self.training.VERSION_STATES.PUBLISHED, params=params,
                async=False, skip_in_progress=False
            )

        # Then still in sync mode, but skipping if in progress
        result = self.training.duplicate_to_member(
            new_state=self.training.VERSION_STATES.PUBLISHED, params=params,
            async=False, skip_in_progress=True
        )

        # Should return nothing if the duplicate was skipped
        self.assertIsNone(result)

    def test_duplicate_to_member_lock_is_released_in_case_of_errors(self):
        """Test that even if the duplicate fails, the lock is released."""

        params = {
            'owner': self.new_trainer,
            'business_unit': self.business_unit
        }

        with patch.object(Training, 'create_new_version', side_effect=RuntimeError('mocked!')):

            with self.assertRaises(RuntimeError):

                self.training.duplicate_to_member(
                    new_state=self.training.VERSION_STATES.PUBLISHED,
                    params=params
                )

        # Ensure the lock is removed
        self.assertFalse(self.training.redis_instance.is_duplicate_to_member_locked(**params))

    def test_duplicate_to_members_should_call_duplicate_to_member(self):
        """Test the ``duplicate_to_members`` method."""

        # Do a first duplicate to members

        # We'll only check if the ``duplicate_to_member`` method is called
        with patch.object(Training, 'duplicate_to_member') as patched:
            self.training.duplicate_to_members()

        # The method ``duplicate_to_member`` should have been called twice
        patched.assert_has_calls(
            [
                call(
                    new_state=self.training.VERSION_STATES.PUBLISHED,
                    params={
                        'owner': self.new_trainer,
                        'business_unit': self.business_unit,
                    },
                    async=False,
                    skip_in_progress=True,
                ),
                call(
                    new_state=self.training.VERSION_STATES.DRAFT,
                    params={
                        'owner': self.trainer_manager,
                        'business_unit': self.business_unit_2,
                    },
                    async=False,
                    skip_in_progress=True,
                ),
            ], any_order=True
        )

    def test_create_draft_is_allowed_by_default(self):
        """Test that it's allowed to create a draft."""

        allowed, reason = self.training.is_new_draft_allowed()

        self.assertTrue(allowed)
        self.assertIsNone(reason)

    def test_create_draft_is_not_allowed_if_training_not_draftable(self):
        """Test that it's not allowed to create a draft for a non-draftable training."""

        with self.training.override_version_state(self.training.VERSION_STATES.DRAFT):
            allowed, reason = self.training.is_new_draft_allowed()

        self.assertFalse(allowed)
        self.assertIsInstance(reason, NotDraftable)

    def test_create_draft_is_not_allowed_if_draft_in_progress(self):
        """Test that it's not allowed to create a draft if one is in progress."""

        # Simulate a draft creation in progress
        self.training.redis_instance.lock_create_draft(owner=self.training.owner)

        allowed, reason = self.training.is_new_draft_allowed()

        self.assertFalse(allowed)
        self.assertIsInstance(reason, Training.DuplicateInProgress)

    def test_create_draft_should_call_create_new_version(self):
        """Test that the ``create_draft`` method works."""

        # We'll only check if the ``duplicate_training_to_member`` function is called
        with patch.object(Training, 'create_new_version') as patched:
            new_training = self.training.create_draft()

        # We should have one call to ``Training.create_new_version``
        patched.assert_called_once_with(
            new_state=self.training.VERSION_STATES.DRAFT,
            params={}
        )

    def test_create_draft_async_should_duplicate(self):
        """Test that the ``create_draft`` method works in asynchronous mode."""

        # No mock here to test the full path in async mode
        self.training.create_draft(async=True)

        # One newly created draft
        self.assertEqual(self.trainer.trainings.filter_is_draft().count(), 1)

        # Get the training from the DB as the previous async call didn't return a training
        new_training = self.trainer.trainings.filter_is_draft().first()

        self.check_training(new_training, {
            'parent_version': self.training,
            'version_state': self.training.VERSION_STATES.DRAFT,
            'owner': self.training.owner,
        }, 6)

    def test_create_draft_should_set_a_lock(self):
        """Test that the ``create_draft`` method set a lock."""

        params = {
            'owner': self.training.owner,
        }

        # Ensure the lock is not set
        self.assertFalse(self.training.redis_instance.is_create_draft_locked(**params))

        # We prevent the lock to be released
        with patch.object(RedisTraining, 'release_create_draft_lock'):

            # We don't need the duplicate to really be done
            with patch.object(Training, 'create_new_version'):
                self.training.create_draft()

        # Now we should have the lock set
        self.assertTrue(self.training.redis_instance.is_create_draft_locked(**params))

    def test_create_draft_should_stop_if_already_in_progress(self):
        """Test that we cannot create a draft twice at the same time."""

        params = {
            'owner': self.training.owner,
        }

        # Simulate another process running the same draft creation
        self.training.redis_instance.lock_create_draft(**params)

        # First in async mode, activating the exception
        with self.assertRaises(Training.DuplicateInProgress):
            self.training.create_draft(
                async=True, skip_in_progress=False
            )

        # Then still in async mode, but skipping if in progress
        result = self.training.create_draft(
            async=True, skip_in_progress=True
        )

        # Should return nothing if the duplicate was skipped
        self.assertIsNone(result)

        # Then in sync mode, activating the exception
        with self.assertRaises(Training.DuplicateInProgress):
            self.training.create_draft(
                async=False, skip_in_progress=False
            )

        # Then still in sync mode, but skipping if in progress
        result = self.training.create_draft(
            async=False, skip_in_progress=True
        )

        # Should return nothing if the draft creation was skipped
        self.assertIsNone(result)

    def test_create_draft_lock_is_released_in_case_of_errors(self):
        """Test that even if the draft creation fails, the lock is released."""

        params = {
            'owner': self.training.owner,
        }

        with patch.object(Training, 'create_new_version', side_effect=RuntimeError('mocked!')):

            with self.assertRaises(RuntimeError):
                self.training.create_draft()

        # Ensure the lock is removed
        self.assertFalse(self.training.redis_instance.is_create_draft_locked(**params))

    def test_is_assessing_property(self):
        """Test the ``is_assessing`` property."""

        # The training don't have any assessable activities
        self.assertFalse(self.training.is_assessing)

        AssessingWorkshopFactory(training=self.training)
        del self.training.is_assessing  # We invalidate the cache

        # Now, the training has an assessable activity
        self.assertTrue(self.training.is_assessing)

    def test_media_files(self):
        """Test that the ``media_files`` property should return the list of training media paths"""

        file_list = []

        # Add the media from the activities
        for activity in self.training.activities.non_deleted():
            if hasattr(activity, 'media_files'):
                file_list.extend(activity.media_files)

        # Add the media from the brainstormer activities
        for brainstormer in self.training.brainstormeractivity_set.all():
            file_list.extend(brainstormer.media_files)

        # Add the media from the theme
        file_list.extend(self.training.theme.media_files)

        self.assertCountEqual(
            self.training.media_files,
            file_list
        )


class TrainingSessionModelTestCase(MTPTestCase):
    """ The Tests case for the TrainingSession model """

    @classmethod
    def setUpTestData(cls):
        super().setUpTestData()

        MetaplanTemplateFactory.create_batch(2, training=cls.training)
        BrainstormerTemplateFactory.create_batch(3, training=cls.training)

        cls.session = TrainingSessionFactory(training=cls.training)

    def test_new_session_should_create_metaplan_and_brainstormer(self):
        """ Test if the creation of new session create metaplan and brainstormer
        with templates in formation of this session """

        self.assertEqual(self.session.metaplans.count(), 2)
        self.assertEqual(self.session.brainstormer_set.count(), 3)

    def test_old_session_save_should_not_create_metaplan_and_brainstormer(self):
        """ Test if save of session not create new metaplan and brainstormer
        with templates in formation of this session """

        self.session.save()

        self.assertEqual(self.session.metaplans.count(), 2)
        self.assertEqual(self.session.brainstormer_set.count(), 3)

    def test_close_session(self):
        """Test the ``close`` method on the ``TrainingSession`` model"""

        # The session state is ``DURING`` (per default)
        self.assertEqual(
            self.session.state,
            self.session.STATE_CHOICES.DURING
        )

        # Now, we close the session
        closed_session = self.session.close()

        # The ``close`` method should return the session instance
        self.assertEqual(
            self.session,
            closed_session
        )

        # The session state should now be ``CLOSED``
        self.assertEqual(
            self.session.state,
            self.session.STATE_CHOICES.CLOSED
        )

    def test_the_session_is_not_assessing(self):
        """Test the ``is_assessing`` property."""

        # In this case, the session is not assessing
        self.assertFalse(self.session.is_assessing)


class AssessingTrainingSessionModelTestCase(BaseComputeAssessmentScoresTestCase):
    """Tests case for the TrainingSession model when is used with assessment."""

    def test_the_session_is_assessing(self):
        """Test the ``is_assessing`` property."""

        # In this case, the session is assessing
        self.assertTrue(self.training_session.is_assessing)

    def test_compute_assessment_scores(self):
        """Test the ``compute_assessment_scores`` method."""

        with self.assertSessionRecomputed():
            self.training_session.compute_assessment_scores()

    def test_close_session(self):
        """Test the ``close`` method compute assessment scores."""

        self.assertFalse(self.training_session.is_closed)

        # The competency tree score is already computed in ``setUp`` method.
        # We don't want force recomputed it because we don't have ``AssessmentPartScore``
        with patch.object(CompetencyTree, 'compute_scores', return_value=None):
            with self.assertSessionRecomputed():
                self.training_session.close()

        del self.training_session.is_closed  # We invalidate the cache
        self.assertTrue(self.training_session.is_closed)


class TrainingSessionChallengeTestCase(TrainingWithActivitiesTestCase):
    """The Tests case for the ``compute_challenge_score`` method on the ``TrainingSession model"""

    def test_compute_challenge_score(self):
        """Test the behavior of the ``compute_challenge_score`` method"""

        # At the beginning, the trainee got no data, so his score should be 0
        trainee_score = self.training_session.compute_challenge_score(self.trainee)
        self.assertEqual(trainee_score, 0)

        # We record a new answer for the workshop (the valid one)
        self.add_user_answers([self.workshop_question.answers.first()])

        trainee_score = self.training_session.compute_challenge_score(self.trainee)
        self.assertEqual(trainee_score, 1)  # One point, the value of the workshop question

    def test_debrief_challenge_scores(self):
        """Test the behavior of the ``debrief_challenge_scores`` method"""

        # At the beginning, the trainee got no data, so his score should be 0
        self.assertEqual(
            self.training_session.debrief_challenge_scores(),
            [
                {
                    'score': 0,
                    'trainee': self.trainee
                }
            ]
        )

        # We record a new answer for the workshop (the valid one)
        self.add_user_answers([self.workshop_question.answers.first()])

        self.assertEqual(
            self.training_session.debrief_challenge_scores(),
            [
                {
                    'score': 1,
                    'trainee': self.trainee
                }
            ]
        )

        # We add a new trainee in the session
        new_trainee = TraineeFactory()
        self.training_session.users.add(new_trainee)

        # The score list should be updated, with the newly added trainee
        self.assertCountEqual(
            self.training_session.debrief_challenge_scores(),
            [
                {
                    'score': 1,
                    'trainee': self.trainee
                },
                {
                    'score': 0,
                    'trainee': new_trainee
                }
            ]
        )


class TrainingSessionManagerTestCase(MTPTestCase):
    """Test cases for the ``TrainingSessionManager`` class"""

    @classmethod
    def setUpTestData(cls):
        """Set up the test data"""

        super().setUpTestData()

        # One "outdated" session (with a ``date_end`` in the past)
        cls.outdated_session = TrainingSessionFactory(
            end_date=timezone.now() - timedelta(days=1)
        )
        # One "during" session (with a ``date_end`` in the future)
        cls.during_session = TrainingSessionFactory(
            end_date=timezone.now() + timedelta(days=1)
        )

    def test_close_outdated_with_no_grace_period(self):
        """Test the ``close_outdated`` method with ``days_after`` at 0"""

        # We got no ``CLOSED`` sessions
        self.assertEqual(
            TrainingSession.objects.closed().count(),
            0
        )

        # Call the method
        result = TrainingSession.objects.close_outdated()

        # We got two results, because of the the one from ``MTPTestCase``
        self.assertCountEqual(
            result,
            [self.outdated_session.pk, self.training_session.pk]
        )

        # We got 2 ``CLOSED`` sessions
        self.assertEqual(
            TrainingSession.objects.closed().count(),
            2
        )

        # Call again the method
        result = TrainingSession.objects.close_outdated()

        # We got no results, the sessions were already closed
        self.assertListEqual(
            result,
            []
        )

        # Modify the "during_session" to make it "outdated"
        self.during_session.end_date = timezone.now() - timedelta(days=1)
        self.during_session.save()

        # Call again the method
        result = TrainingSession.objects.close_outdated()

        # We got one result
        self.assertListEqual(
            result,
            [self.during_session.pk]
        )

        # We got now 3 ``CLOSED`` sessions
        self.assertEqual(
            TrainingSession.objects.closed().count(),
            3
        )

    def test_close_outdated_with_a_grace_period(self):
        """Test the ``close_outdated`` method with ``days_after`` at 1"""

        # We got no ``CLOSED`` sessions
        self.assertEqual(
            TrainingSession.objects.closed().count(),
            0
        )

        # Call the method with 3 day of "grace period"
        result = TrainingSession.objects.close_outdated(delta_after=timedelta(days=3))

        # We got no results, as there are no sessions who ended three days ago (or before)
        self.assertListEqual(
            result,
            []
        )

    def test_close_outdated_without_dry_run(self):
        """Test the ``close_outdated`` method with ``dry_run`` to ``False``"""

        # We got no ``CLOSED`` sessions
        self.assertEqual(
            TrainingSession.objects.closed().count(),
            0
        )

        # We check that the ``close`` method is called
        with patch.object(TrainingSession, 'close', autospec=True) as patched:
            TrainingSession.objects.close_outdated(dry_run=False)

        # The close method was called twice
        self.assertEqual(
            patched.call_count,
            2
        )

    def test_close_outdated_with_dry_run(self):
        """Test the ``close_outdated`` method with ``dry_run`` to ``True``"""

        # We got no ``CLOSED`` sessions
        self.assertEqual(
            TrainingSession.objects.closed().count(),
            0
        )

        # We check that the ``close`` method is not called
        with patch.object(TrainingSession, 'close') as patched:
            result = TrainingSession.objects.close_outdated(dry_run=True)

        # We got two results, because of the the one from ``MTPTestCase``
        self.assertCountEqual(
            result,
            [self.outdated_session.pk, self.training_session.pk]
        )

        # But the close method was not called...
        patched.assert_not_called()

        # ..and we still got no ``CLOSED`` sessions
        self.assertEqual(
            TrainingSession.objects.closed().count(),
            0
        )

    def test_get_assessing_session(self):
        """Test the ``get_assessing_session``."""

        # We have no assessing session because no assessing activity
        assessing_session = TrainingSession.objects.get_assessing_sessions()
        self.assertEqual(len(assessing_session), 0)

        # We add an assessing activity in current session training
        TrainingActivityFactory(training=self.training_session.training, is_assessing=True)

        # Now, we should have 1 assessing session, the current
        assessing_session = TrainingSession.objects.get_assessing_sessions()
        self.assertEqual(len(assessing_session), 1)
        self.assertTrue(self.training_session in assessing_session)
