"""Test the ``Lockable`` base model."""

from contextlib import contextmanager

from django.db import transaction
from django.test import TestCase

from ..exceptions import LockedField
from ..utils import _no_fields_locking  # pylint:disable=protected-access

from .lockable_testapp.models import *


class LockableTestCase(TestCase):
    """Test the ``Lockable`` base model"""

    @classmethod
    def setUpTestData(cls):
        """Create instances of models defined in ``lockable_testapp.models``"""
        super().setUpTestData()

        cls.lockable_fk1 = LockableFK.objects.create(name='lockable_fk1')
        cls.lockable_fk2 = LockableFK.objects.create(name='lockable_fk2')

        cls.unlockable_fk1 = UnlockableFK.objects.create(name='unlockable_fk1')
        cls.unlockable_fk2 = UnlockableFK.objects.create(name='unlockable_fk2')

        cls.lockable = LockableModel.objects.create(
            name='lockable1',
            non_lockable_field='free1',
            lockable=cls.lockable_fk1,
            unlockable=cls.unlockable_fk1,
        )

        # Simulate a "unlocked" state
        cls.lockable.locked = False

        cls.unlockable_m2m1 = UnlockableM2M.objects.create(name='unlockable_m2m1')
        cls.unlockable_m2m2 = UnlockableM2M.objects.create(name='unlockable_m2m2')

        cls.lockable.unlockables = [cls.unlockable_m2m1]

        cls.lockable_m2m1 = LockableM2M.objects.create(name='lockable_m2m1')
        cls.lockable_m2m2 = LockableM2M.objects.create(name='lockable_m2m2')

        cls.lockable.lockables = [cls.lockable_m2m1]

    @contextmanager
    def assertFieldLocked(self, instance, field_name):
        """Helped that check that the correct ``LockedField`` exception is raised."""

        with self.assertRaises(LockedField) as context:
            yield

        self.assertEqual(context.exception.attribute, field_name)
        self.assertEqual(context.exception.instance, instance)
        self.assertEqual(
            str(context.exception),
            'The "%s" field of "%s" is locked and cannot be edited' % (
                field_name,
                repr(instance)
            ))

    def test_non_lockable_fields(self):
        """Test that ``get_non_lockable_fields`` returns the expected set."""

        self.assertSetEqual(LockableModel.get_non_lockable_fields(),
                            {'non_lockable_field', 'unlockables', 'unlockable_id', 'locked'})

    def test_loading_is_not_locked(self):
        """No field should be locked during loading."""

        lockable = LockableModel.objects.get(name='lockable1')

        self.assertEqual(lockable.non_lockable_field, 'free1')
        self.assertEqual(lockable.name, 'lockable1')
        self.assertEqual(lockable.lockable_id, self.lockable_fk1.pk)
        self.assertEqual(lockable.unlockable_id, self.unlockable_fk1.pk)
        self.assertEqual(
            [o.pk for o in lockable.lockables.all()],
            [self.lockable_m2m1.pk]
        )
        self.assertEqual(
            [o.pk for o in lockable.unlockables.all()],
            [self.unlockable_m2m1.pk]
        )

    def test_reloading_from_db_is_not_locked(self):
        """No field should be locked during refreshing data from db."""

        self.lockable.refresh_from_db()

        self.assertEqual(self.lockable.non_lockable_field, 'free1')
        self.assertEqual(self.lockable.name, 'lockable1')
        self.assertEqual(self.lockable.lockable_id, self.lockable_fk1.pk)
        self.assertEqual(self.lockable.unlockable_id, self.unlockable_fk1.pk)
        self.assertEqual(
            [o.pk for o in self.lockable.lockables.all()],
            [self.lockable_m2m1.pk]
        )
        self.assertEqual(
            [o.pk for o in self.lockable.unlockables.all()],
            [self.unlockable_m2m1.pk]
        )

    def test_non_lockable_field_can_be_edited(self):
        """A field defined in ``non_lockable_field`` can always be edited."""

        # Simulate a "unlocked" state
        self.lockable.locked = False

        self.lockable.non_lockable_field = 'free2'

        # Simulate a "locked" state
        self.lockable.refresh_from_db()
        self.lockable.locked = True

        self.lockable.non_lockable_field = 'free3'

    def test_lockable_field_can_be_edited_if_not_locked(self):
        """A field not defined in ``non_lockable_field`` can be edited only if not locked."""

        # Simulate a "unlocked" state
        self.lockable.locked = False

        self.lockable.name = 'lockable1 foo1'

        # Simulate a "locked" state
        self.lockable.refresh_from_db()
        self.lockable.locked = True

        # Not changing the value should pass
        self.lockable.name = 'lockable1'

        # But changing it should fail
        with self.assertFieldLocked(self.lockable, 'name'):
            self.lockable.name = 'lockable1 foo2'

    def test_non_lockable_foreign_key_can_be_edited(self):
        """A foreign_key defined in ``non_lockable_field`` can always be edited."""

        # Simulate a "unlocked" state
        self.lockable.locked = False

        self.lockable.unlockable = self.unlockable_fk2

        # Simulate a "locked" state
        self.lockable.refresh_from_db()
        self.lockable.locked = True

        self.lockable.unlockable = self.unlockable_fk2

        # Setting it with attname instead of name should pass too
        self.lockable.unlockable_id = self.unlockable_fk2.pk

    def test_lockable_foreign_key_can_be_edited_if_not_locked(self):
        """A foreign_key not defined in ``non_lockable_field`` can be edited only if not locked."""

        # Simulate a "unlocked" state
        self.lockable.locked = False

        self.lockable.lockable = self.lockable_fk2

        # Simulate a "locked" state
        self.lockable.refresh_from_db()
        self.lockable.locked = True

        # Not changing the value should pass
        self.lockable.lockable = self.lockable_fk1

        # But changing it should fail
        with self.assertFieldLocked(self.lockable, 'lockable'):
            self.lockable.lockable = self.lockable_fk2

        # Setting it with attname instead of name should fail too
        with self.assertFieldLocked(self.lockable, 'lockable_id'):
            self.lockable.lockable_id = self.lockable_fk2.pk

    def test_non_lockable_m2m_can_be_edited(self):
        """A M2M defined in ``non_lockable_field`` can always be edited."""

        # Simulate a "unlocked" state
        self.lockable.locked = False

        # None of the accesses should fail
        self.lockable.unlockables.add(self.unlockable_m2m2)
        self.lockable.unlockables.clear()
        self.lockable.unlockables = [self.unlockable_m2m2]
        self.lockable.unlockables.remove(self.unlockable_m2m2)

        # Restore
        self.lockable.unlockables = [self.unlockable_m2m1]

        # It should work on the other side too
        self.unlockable_m2m2.lockablemodel_set.add(self.lockable)
        self.unlockable_m2m2.lockablemodel_set.clear()
        self.unlockable_m2m2.lockablemodel_set = [self.lockable]
        self.unlockable_m2m1.lockablemodel_set.remove(self.lockable)

        # Restore
        self.lockable.unlockables = [self.unlockable_m2m1]

        # Simulate a "locked" state
        self.lockable.refresh_from_db()
        self.lockable.locked = True
        self.lockable.save()

        # None of the accesses should fail
        self.lockable.unlockables.add(self.unlockable_m2m2)
        self.lockable.unlockables.clear()
        self.lockable.unlockables = [self.unlockable_m2m2]
        self.lockable.unlockables.remove(self.unlockable_m2m2)

        # Restore
        self.lockable.unlockables = [self.unlockable_m2m1]

        # It should work on the other side too
        self.unlockable_m2m2.lockablemodel_set.add(self.lockable)
        self.unlockable_m2m2.lockablemodel_set.clear()
        self.unlockable_m2m2.lockablemodel_set = [self.lockable]
        self.unlockable_m2m1.lockablemodel_set.remove(self.lockable)

    def test_lockable_m2m_can_be_edited_if_not_locked(self):
        """A M2M not defined in ``non_lockable_field`` can be edited only if not locked."""

        # Simulate a "unlocked" state
        self.lockable.locked = False

        # None of the accesses should fail
        self.lockable.lockables.add(self.lockable_m2m2)
        self.lockable.lockables.clear()
        self.lockable.lockables = [self.lockable_m2m2]
        self.lockable.lockables.remove(self.lockable_m2m2)

        # Restore
        self.lockable.lockables = [self.lockable_m2m1]

        # It should work on the other side too
        self.lockable_m2m2.lockablemodel_set.add(self.lockable)
        self.lockable_m2m2.lockablemodel_set.clear()
        self.lockable_m2m2.lockablemodel_set = [self.lockable]
        self.lockable_m2m1.lockablemodel_set.remove(self.lockable)

        # Restore
        self.lockable.lockables = [self.lockable_m2m1]

        # Simulate a "locked" state
        self.lockable.refresh_from_db()
        self.lockable.locked = True
        self.lockable.save()

        # All accesses should fail
        # We surround m2m updates with ``transaction.atomic`` because a m2m operation
        # is in an atomic block and a DatabaseError will be raised if we don't handle
        # it properly with our own atomic block to rollback to the previous state

        with transaction.atomic():
            with self.assertFieldLocked(self.lockable, 'lockables'):
                self.lockable.lockables.add(self.lockable_m2m2)

        with transaction.atomic():
            with self.assertFieldLocked(self.lockable, 'lockables'):
                self.lockable.lockables.clear()

        with transaction.atomic():
            with self.assertFieldLocked(self.lockable, 'lockables'):
                self.lockable.lockables = [self.lockable_m2m2]

        with transaction.atomic():
            with self.assertFieldLocked(self.lockable, 'lockables'):
                self.lockable.lockables.remove(self.lockable_m2m1)

        # It should fail on the other side too

        with transaction.atomic():
            with self.assertFieldLocked(self.lockable, 'lockables'):
                self.lockable_m2m2.lockablemodel_set.add(self.lockable)

        with transaction.atomic():
            with self.assertFieldLocked(self.lockable, 'lockables'):
                self.lockable_m2m2.lockablemodel_set = [self.lockable]

        with transaction.atomic():
            with self.assertFieldLocked(self.lockable, 'lockables'):
                self.lockable_m2m1.lockablemodel_set.clear()

        with transaction.atomic():
            with self.assertFieldLocked(self.lockable, 'lockables'):
                self.lockable_m2m1.lockablemodel_set.remove(self.lockable)

    def test_no_fields_locking(self):
        """Test that ``_no_fields_locking`` works at instance or class level."""

        # Tell pylint that we are allowed to access ``_track_fields``
        # pylint:disable=protected-access

        # Set the model to be locked
        self.lockable.locked = True

        # Must raise if no call to ``_no_fields_locking``
        with self.assertFieldLocked(self.lockable, 'name'):
            self.lockable.name = 'lockable1 foo'

        # Must not raise when calling it on the instance
        with _no_fields_locking(self.lockable):
            self.lockable.name = 'lockable1 foo1'

        # Must not raise when calling it on the class
        with _no_fields_locking(LockableModel):
            self.lockable.name = 'lockable1 foo2'

        # Must not raise when calling it on a parent class

        with _no_fields_locking(Lockable):
            self.lockable.name = 'lockable1 foo3'

    def test_no_fields_locking_cache(self):
        """Test that ``_no_fields_locking`` works for other instances of the same db object."""

        same_lockable = LockableModel.objects.get(pk=self.lockable.pk)

        self.lockable.locked = same_lockable.locked = True

        # Call the context-manager on an instance and try to update another one
        with _no_fields_locking(self.lockable):
            same_lockable.name = 'lockable1 foo1'

