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

import json
from mock import patch

from django.conf import settings
from django.contrib.auth.models import Group as DjangoGroup, Permission
from django.core import serializers
from django.test import override_settings, TestCase

from mtp.apps.accounts.factories import (
    GroupFactory, GroupSetFactory,
    MTPUserFactory,
)
from mtp.apps.accounts.models import Group, GroupRole, GroupSet, MTPUser
from mtp.apps.common.tests.test_cases import MTPTestCase
from mtp.apps.sync.models import Update

from ..models import Syncable

from .syncable_testapp.models import (
    SyncableChildrenPolymorphicModel,
    SyncableModel,
    SyncablePolymorphicModel,
)


class ManyToManyMixin:
    """Mixin which test that syncable models having auto-M2M are correctly managed.

    Attributes
    ----------
    set1 : GroupSet
    set2 : GroupSet
    set_qs : Queryset(GroupSet)
        ``Queryset`` of ``GroupSet`` filtered to have ``set1`` and ``set2``
    group1 : Group
    group2 : Group
    group3 : Group
    group4 : Group
    group_qs : Queryset(Group)
        ``Queryset`` of ``Group`` filtered to have ``group1``, ``group2``, ``group3``
        and ``group4``
    synchronized_update : Update
        ``Update`` object having the ``synchronized`` flag set to ``True``
    non_synchronized_update : Update
        ``Update`` object having the ``synchronized`` flag set to ``False``

    Notes
    -----
    ``group1`` and ``group2`` are added to ``set1`` and ``set2`` to easily test removal

    """

    @classmethod
    def setUpTestData(cls):
        """Create some groups and group sets."""
        super().setUpTestData()

        cls.set1 = GroupSetFactory(name='testm2msync-groupset1')
        cls.set2 = GroupSetFactory(name='testm2msync-groupset2')
        cls.set_qs = GroupSet.objects.filter(pk__in=(cls.set1.pk, cls.set2.pk)).order_by('name')

        cls.group1 = GroupFactory(name='testm2msync-group1')
        cls.group2 = GroupFactory(name='testm2msync-group1')
        cls.group3 = GroupFactory(name='testm2msync-group3')
        cls.group4 = GroupFactory(name='testm2msync-group4')
        cls.group_qs = Group.objects.filter(pk__in=(
            cls.group1.pk, cls.group2.pk, cls.group3.pk)).order_by('name')

        # Add a group in both sets
        cls.set1.mgroups.add(cls.group1, cls.group2)
        cls.set2.mgroups.add(cls.group1, cls.group2)

        cls.synchronized_update, __ = Update.objects.get_or_create(
            synchronized=True,
        )
        cls.non_synchronized_update, __ = Update.objects.get_or_create(
            synchronized=False,
        )

        # Set the old ``last_update`` (for master) and ``update_locally`` to False (for slave)
        cls.set_qs.update(last_update=cls.synchronized_update, update_locally=False)
        cls.group_qs.update(last_update=cls.synchronized_update, update_locally=False)

    def setUp(self):
        """Refresh all our groups and sets from DB to have fresh values in the python objects."""

        super().setUp()

        for obj in (self.set1, self.set2, self.group1, self.group2, self.group3, self.group4):
            obj.refresh_from_db()

    def get_updated(self):
        """Returns the updated groups and sets.

        Returning groups allow to check that groups, that don't own the m2m field, are not updated.

        Returns
        -------
        tuple(list)
            A tuple with two lists: the updated sets, and the updated groups.

        """

        if settings.INSTANCE_IS_SLAVE:
            filters = {'update_locally': True}
        else:
            filters = {'last_update': self.non_synchronized_update}

        return (
            list(self.set_qs.filter(**filters)),
            list(self.group_qs.filter(**filters)),
        )

    def test_adding_one_item_update_the_owning_instance(self):
        """The instance owning the M2M relation is updated when adding an item."""

        self.set1.mgroups.add(self.group3)

        self.assertEqual(self.get_updated(), (
            [self.set1], []
        ))

    def test_adding_many_items_update_the_owning_instance(self):
        """The instance owning the M2M relation is updated when adding many items."""

        self.set1.mgroups.add(self.group3, self.group4)

        self.assertEqual(self.get_updated(), (
            [self.set1], []
        ))

    def test_adding_one_item_from_other_side_update_the_owning_instance(self):
        """The instance owning the M2M relation is updated when adding item from the other side."""

        self.group3.groupset_set.add(self.set1)

        self.assertEqual(self.get_updated(), (
            [self.set1], []
        ))

    def test_adding_many_items_from_other_side_update_the_owning_instances(self):
        """The instances owning the M2M relation is updated when add items from the other side."""

        self.group3.groupset_set.add(self.set1, self.set2)

        self.assertEqual(self.get_updated(), (
            [self.set1, self.set2], []
        ))

    def test_removing_one_item_update_the_owning_instance(self):
        """The instance owning the M2M relation is updated when removing one item."""

        self.set1.mgroups.remove(self.group1)

        self.assertEqual(self.get_updated(), (
            [self.set1], []
        ))

    def test_removing_many_items_update_the_owning_instance(self):
        """The instance owning the M2M relation is updated when removing many items."""

        self.set1.mgroups.remove(self.group1, self.group2)

        self.assertEqual(self.get_updated(), (
            [self.set1], []
        ))

    def test_removing_one_item_from_other_side_update_the_owning_instance(self):
        """Instance owning the M2M relation is updated when removing item from the other side."""

        self.group2.groupset_set.remove(self.set1)

        self.assertEqual(self.get_updated(), (
            [self.set1], []
        ))

    def test_removing_many_items_from_other_side_update_the_owning_instances(self):
        """Instances owning the M2M relation is updated when removing item from the other side."""

        self.group2.groupset_set.remove(self.set1, self.set2)

        self.assertEqual(self.get_updated(), (
            [self.set1, self.set2], []
        ))

    def test_clearing_items_update_the_owning_instance(self):
        """The instance owning the M2M relation is updated when clearing all items."""

        self.set1.mgroups.clear()

        self.assertEqual(self.get_updated(), (
            [self.set1], []
        ))

    def test_clearing_items_from_other_side_update_the_owning_instance(self):
        """Instance owning the M2M relation is updated when clearing items from the other side."""

        self.group2.groupset_set.clear()

        self.assertEqual(self.get_updated(), (
            [self.set1, self.set2], []
        ))

    def test_non_syncable_models_are_not_updated(self):
        """Updating a M2M relation defined on a non-syncable model doesn't update the instance."""

        group = DjangoGroup.objects.create(name='testm2msync-djgroup')

        with patch.object(DjangoGroup, 'save') as patched:
            group.permissions.add(Permission.objects.first())

        self.assertFalse(patched.called)

    def test_models_owning_syncable_through_tables_are_not_updated(self):
        """Updating a manual through table doesn't update the owning instance."""

        user = MTPUserFactory(username='testm2msync-user')

        with patch.object(MTPUser, 'save') as patched:
            GroupRole.objects.create(user=user, group=self.group1)

        self.assertFalse(patched.called)


@override_settings(INSTANCE_IS_SLAVE=False, INSTANCE_IS_MASTER=True)
class ManyToManyOnMasterTestCase(ManyToManyMixin, MTPTestCase):
    """Test that syncable models having auto-M2M are correctly managed on master"""
    pass


@override_settings(INSTANCE_IS_SLAVE=True, INSTANCE_IS_MASTER=False)
class ManyToManyOnSlaveTestCase(ManyToManyMixin, MTPTestCase):
    """Test that syncable models having auto-M2M are correctly managed on slave"""
    pass


class RemoveTestCase(MTPTestCase):
    """Test the remove/unremove methods"""

    def setUp(self):
        """Reload our test objects from database"""

        super().setUp()

        self.trainee.refresh_from_db()
        self.trainer.refresh_from_db()

    def test_remove_non_deleted_object_with_commit_true(self):
        """Calling ``remove`` on a non-deleted object with ``commit=True`` should save it"""

        # Using default value
        self.assertFalse(self.trainee.is_deleted)

        with patch.object(MTPUser, 'save') as patched:
            self.trainee.remove()

        self.assertTrue(patched.called)
        self.assertTrue(self.trainee.is_deleted)

        # Passing the parameter explicitly
        self.assertFalse(self.trainer.is_deleted)

        with patch.object(MTPUser, 'save') as patched:
            self.trainer.remove(commit=True)

        self.assertTrue(patched.called)
        self.assertTrue(self.trainer.is_deleted)

    def test_remove_non_deleted_object_with_commit_false(self):
        """Calling ``remove`` on a non-deleted object with ``commit=False`` should not save it"""

        self.assertFalse(self.trainee.is_deleted)

        with patch.object(MTPUser, 'save') as patched:
            self.trainee.remove(commit=False)

        self.assertFalse(patched.called)
        self.assertTrue(self.trainee.is_deleted)

    def test_remove_deleted_object_with_commit_true(self):
        """Calling ``remove`` on a deleted object with ``commit=True`` should save not it"""

        # Using default value

        self.trainee.is_deleted = True

        with patch.object(MTPUser, 'save') as patched:
            self.trainee.remove()

        self.assertFalse(patched.called)
        self.assertTrue(self.trainee.is_deleted)

        # Passing the parameter explicitly

        self.trainer.is_deleted = True

        with patch.object(MTPUser, 'save') as patched:
            self.trainer.remove(commit=True)

        self.assertFalse(patched.called)
        self.assertTrue(self.trainer.is_deleted)

    def test_remove_deleted_object_with_commit_false(self):
        """Calling ``remove`` on a deleted object with ``commit=False`` should not save it"""

        self.trainee.is_deleted = True

        with patch.object(MTPUser, 'save') as patched:
            self.trainee.remove(commit=False)

        self.assertFalse(patched.called)
        self.assertTrue(self.trainee.is_deleted)

    def test_unremove_non_deleted_object_with_commit_true(self):
        """Calling ``unremove`` on a non-deleted object with ``commit=True`` should not save it"""

        # Using default value
        self.assertFalse(self.trainee.is_deleted)

        with patch.object(MTPUser, 'save') as patched:
            self.trainee.unremove()

        self.assertFalse(patched.called)
        self.assertFalse(self.trainee.is_deleted)

        # Passing the parameter explicitly
        self.assertFalse(self.trainer.is_deleted)

        with patch.object(MTPUser, 'save') as patched:
            self.trainer.unremove(commit=True)

        self.assertFalse(patched.called)
        self.assertFalse(self.trainer.is_deleted)

    def test_unremove_non_deleted_object_with_commit_false(self):
        """Calling ``unremove`` on a non-deleted object with ``commit=False`` should not save it"""

        self.assertFalse(self.trainee.is_deleted)

        with patch.object(MTPUser, 'save') as patched:
            self.trainee.unremove(commit=False)

        self.assertFalse(patched.called)
        self.assertFalse(self.trainee.is_deleted)

    def test_unremove_deleted_object_with_commit_true(self):
        """Calling ``unremove`` on a deleted object with ``commit=True`` should save it"""

        # Using default value

        self.trainee.is_deleted = True

        with patch.object(MTPUser, 'save') as patched:
            self.trainee.unremove()

        self.assertTrue(patched.called)
        self.assertFalse(self.trainee.is_deleted)

        # Passing the parameter explicitly

        self.trainer.is_deleted = True

        with patch.object(MTPUser, 'save') as patched:
            self.trainer.unremove(commit=True)

        self.assertTrue(patched.called)
        self.assertFalse(self.trainer.is_deleted)

    def test_unremove_deleted_object_with_commit_false(self):
        """Calling ``unremove`` on a deleted object with ``commit=False`` should not save it"""

        self.trainee.is_deleted = True

        with patch.object(MTPUser, 'save') as patched:
            self.trainee.unremove(commit=False)

        self.assertFalse(patched.called)
        self.assertFalse(self.trainee.is_deleted)


class SyncableTestCase(TestCase):
    """Test case for the ``Syncable`` model"""

    def test_get_objects_and_files_to_sync(self):
        """Test that the ``get_objects_and_files_to_sync`` method is working correctly"""

        # At first, we don't have any "local" updates
        objects_to_sync, files_to_sync, queryset_list = Syncable.get_objects_and_files_to_sync()

        self.assertFalse(objects_to_sync)
        self.assertFalse(files_to_sync)
        self.assertNotIn(
            SyncableModel.objects.filter(update_locally=True),
            [list(queryset) for queryset in queryset_list]
        )

        # Create a new ``SyncableModel``
        syncable_object = SyncableModel.objects.create()

        objects_to_sync, files_to_sync, queryset_list = Syncable.get_objects_and_files_to_sync()

        self.assertEqual(objects_to_sync, [syncable_object])
        self.assertFalse(files_to_sync)
        self.assertIn(
            list(SyncableModel.objects.filter(update_locally=True)),
            [list(queryset) for queryset in queryset_list]
        )

    def test_get_objects_and_files_to_sync_with_polymorphic_objects(self):
        """Test that the "polymorphic" objects are handled correctly"""

        # New polymorphic instance
        polymorphic_object = SyncableChildrenPolymorphicModel.objects.create()

        objects_to_sync, __, __ = Syncable.get_objects_and_files_to_sync()

        # We get both the parent and children instance in the objects_to_sync
        self.assertEqual(len(objects_to_sync), 2)

        # We got the children instance...
        self.assertIn(polymorphic_object, objects_to_sync)

        # ...And the parent instance (the non-polymorphic one)
        self.assertIn(
            SyncablePolymorphicModel.objects.non_polymorphic().first(),
            objects_to_sync,
        )

    def test_serialize_for_sync(self):
        """Test that the ``serialize_for_sync`` should work correctly"""

        # Create a new ``SyncableModel``
        syncable_object = SyncableModel.objects.create()

        self.assertEqual(
            syncable_object.serialize_for_sync(),
            [
                {
                    'pk': syncable_object.pk,
                    'fields': {
                        'first_update': None,
                        'last_update': None,
                        'update_locally': True,
                        'is_deleted': False
                    },
                    'model': 'syncable_testapp.syncablemodel'
                }
            ]
        )

    def test_serialize_for_sync_with_polymorphic_object(self):
        """Test that the ``serialize_for_sync`` should work correctly with polymorphic objects"""

        # New polymorphic instance
        polymorphic_object = SyncableChildrenPolymorphicModel.objects.create()

        with patch('django.core.serializers.serialize', return_value='{}') as mocked_serialize:
            polymorphic_object.serialize_for_sync()

        # The serializer method should have been called with the ``use_natural_foreign_keys`` flag
        mocked_serialize.assert_called_once_with(
            'json',
            [polymorphic_object],
            use_natural_foreign_keys=True
        )

    def test_polymorphic_models_should_be_correctly_synchronized(self):
        """Test that the polymorphic should be able to be synchronized"""

        # New polymorphic instance
        polymorphic_object = SyncableChildrenPolymorphicModel.objects.create()
        object_pk = polymorphic_object.pk

        objects_to_sync, __, __ = Syncable.get_objects_and_files_to_sync()

        polymorphic_object.delete()

        # Serialize the data to sync
        serialized_objects = []
        for data in objects_to_sync:
            serialized_objects.extend(data.serialize_for_sync())
        json_data = json.dumps(serialized_objects)

        # Deserialize the data to sync
        up_objects = serializers.deserialize('json', json_data)
        for deserialized_object in up_objects:
            deserialized_object.save()

        self.assertTrue(
            SyncableChildrenPolymorphicModel.objects.filter(pk=object_pk).exists()
        )
