from unittest import mock

from django.core.exceptions import ImproperlyConfigured
from django.test import TestCase
from django.test import TransactionTestCase

from constance import settings
from constance.backends.redisd import RedisBackend
from constance.base import Config
from tests.storage import StorageTestsMixin


class TestRedis(StorageTestsMixin, TestCase):
    _BACKEND = "constance.backends.redisd.RedisBackend"

    def setUp(self):
        self.old_backend = settings.BACKEND
        settings.BACKEND = self._BACKEND
        super().setUp()
        self.config._backend._rd.clear()

    def tearDown(self):
        self.config._backend._rd.clear()
        settings.BACKEND = self.old_backend

    def test_mget_empty_keys(self):
        result = self.config._backend.mget([])
        self.assertEqual(result, {})


class TestCachingRedis(TestRedis):
    _BACKEND = "constance.backends.redisd.CachingRedisBackend"

    def test_mget_empty_keys(self):
        result = self.config._backend.mget([])
        self.assertEqual(result, {})


class TestRedisAsync(TransactionTestCase):
    _BACKEND = "constance.backends.redisd.RedisBackend"

    def setUp(self):
        self.old_backend = settings.BACKEND
        settings.BACKEND = self._BACKEND
        self.config = Config()
        self.config._backend._rd.clear()

    def tearDown(self):
        self.config._backend._rd.clear()
        settings.BACKEND = self.old_backend

    async def test_aget_returns_none_for_missing_key(self):
        result = await self.config._backend.aget("INT_VALUE")
        self.assertIsNone(result)

    async def test_aget_returns_value(self):
        self.config._backend.set("INT_VALUE", 42)
        result = await self.config._backend.aget("INT_VALUE")
        self.assertEqual(result, 42)

    async def test_aset_stores_value(self):
        await self.config._backend.aset("INT_VALUE", 99)
        result = self.config._backend.get("INT_VALUE")
        self.assertEqual(result, 99)

    async def test_amget_returns_empty_for_no_keys(self):
        result = await self.config._backend.amget([])
        self.assertEqual(result, {})

    async def test_amget_returns_values(self):
        self.config._backend.set("INT_VALUE", 10)
        self.config._backend.set("BOOL_VALUE", value=True)
        result = await self.config._backend.amget(["INT_VALUE", "BOOL_VALUE"])
        self.assertEqual(result, {"INT_VALUE": 10, "BOOL_VALUE": True})

    async def test_amget_skips_missing_keys(self):
        self.config._backend.set("INT_VALUE", 10)
        result = await self.config._backend.amget(["INT_VALUE", "MISSING_KEY"])
        self.assertEqual(result, {"INT_VALUE": 10})


class TestCachingRedisAsync(TransactionTestCase):
    _BACKEND = "constance.backends.redisd.CachingRedisBackend"

    def setUp(self):
        self.old_backend = settings.BACKEND
        settings.BACKEND = self._BACKEND
        self.config = Config()
        self.config._backend._rd.clear()
        self.config._backend._cache.clear()

    def tearDown(self):
        self.config._backend._rd.clear()
        self.config._backend._cache.clear()
        settings.BACKEND = self.old_backend

    async def test_aget_caches_value(self):
        # First set a value via sync
        self.config._backend.set("INT_VALUE", 42)
        # Clear the in-memory cache
        self.config._backend._cache.clear()

        # Async get should fetch and cache
        result = await self.config._backend.aget("INT_VALUE")
        self.assertEqual(result, 42)

        # Verify it's cached
        self.assertIn("INT_VALUE", self.config._backend._cache)

    async def test_aget_returns_cached_value(self):
        # Manually set cache
        from time import monotonic

        timeout = self.config._backend._timeout
        self.config._backend._cache["INT_VALUE"] = (monotonic() + timeout, 100)

        result = await self.config._backend.aget("INT_VALUE")
        self.assertEqual(result, 100)

    async def test_aget_refreshes_expired_cache(self):
        from time import monotonic

        # Set expired cache
        self.config._backend._cache["INT_VALUE"] = (monotonic() - 10, 100)
        # Set different value in redis using proper codec format
        self.config._backend._rd.set(
            self.config._backend.add_prefix("INT_VALUE"),
            b'{"__type__": "default", "__value__": 200}',
        )

        result = await self.config._backend.aget("INT_VALUE")
        self.assertEqual(result, 200)

    async def test_aset_updates_cache(self):
        await self.config._backend.aset("INT_VALUE", 55)

        # Verify cache is updated
        self.assertIn("INT_VALUE", self.config._backend._cache)
        self.assertEqual(self.config._backend._cache["INT_VALUE"][1], 55)

    async def test_amget_returns_empty_for_no_keys(self):
        result = await self.config._backend.amget([])
        self.assertEqual(result, {})

    async def test_amget_returns_cached_values(self):
        from time import monotonic

        timeout = self.config._backend._timeout
        self.config._backend._cache["INT_VALUE"] = (monotonic() + timeout, 10)
        self.config._backend._cache["BOOL_VALUE"] = (monotonic() + timeout, True)

        result = await self.config._backend.amget(["INT_VALUE", "BOOL_VALUE"])
        self.assertEqual(result, {"INT_VALUE": 10, "BOOL_VALUE": True})

    async def test_amget_fetches_missing_keys(self):
        from time import monotonic

        timeout = self.config._backend._timeout
        # One key cached, one in Redis only
        self.config._backend._cache["INT_VALUE"] = (monotonic() + timeout, 10)
        self.config._backend._rd.set(
            self.config._backend.add_prefix("BOOL_VALUE"),
            b'{"__type__": "default", "__value__": true}',
        )

        result = await self.config._backend.amget(["INT_VALUE", "BOOL_VALUE"])
        self.assertEqual(result["INT_VALUE"], 10)
        self.assertEqual(result["BOOL_VALUE"], True)

    async def test_amget_refreshes_expired_keys(self):
        from time import monotonic

        # Set expired cache
        self.config._backend._cache["INT_VALUE"] = (monotonic() - 10, 100)
        # Set different value in redis using proper codec format
        self.config._backend._rd.set(
            self.config._backend.add_prefix("INT_VALUE"),
            b'{"__type__": "default", "__value__": 200}',
        )

        result = await self.config._backend.amget(["INT_VALUE"])
        self.assertEqual(result["INT_VALUE"], 200)


class TestRedisBackendInit(TestCase):
    """Tests for RedisBackend.__init__ client initialization paths."""

    def setUp(self):
        self.old_conn_cls = settings.REDIS_CONNECTION_CLASS
        self.old_async_conn_cls = settings.REDIS_ASYNC_CONNECTION_CLASS
        self.old_conn = settings.REDIS_CONNECTION

    def tearDown(self):
        settings.REDIS_CONNECTION_CLASS = self.old_conn_cls
        settings.REDIS_ASYNC_CONNECTION_CLASS = self.old_async_conn_cls
        settings.REDIS_CONNECTION = self.old_conn

    def test_no_redis_package_raises_improperly_configured(self):
        settings.REDIS_CONNECTION_CLASS = None
        settings.REDIS_ASYNC_CONNECTION_CLASS = "tests.redis_mockup.AsyncConnection"
        with mock.patch.dict("sys.modules", {"redis": None}), self.assertRaises(ImproperlyConfigured):
            RedisBackend()

    def test_sync_redis_from_url_with_string_connection(self):
        settings.REDIS_CONNECTION_CLASS = None
        settings.REDIS_ASYNC_CONNECTION_CLASS = "tests.redis_mockup.AsyncConnection"
        settings.REDIS_CONNECTION = "redis://localhost:6379/0"
        mock_redis = mock.MagicMock()
        with mock.patch.dict("sys.modules", {"redis": mock_redis, "redis.asyncio": mock_redis.asyncio}):
            backend = RedisBackend()
        mock_redis.from_url.assert_called_once_with("redis://localhost:6379/0")
        self.assertEqual(backend._rd, mock_redis.from_url.return_value)

    def test_sync_redis_with_dict_connection(self):
        settings.REDIS_CONNECTION_CLASS = None
        settings.REDIS_ASYNC_CONNECTION_CLASS = "tests.redis_mockup.AsyncConnection"
        settings.REDIS_CONNECTION = {"host": "localhost", "port": 6379}
        mock_redis = mock.MagicMock()
        with mock.patch.dict("sys.modules", {"redis": mock_redis, "redis.asyncio": mock_redis.asyncio}):
            backend = RedisBackend()
        mock_redis.Redis.assert_called_once_with(host="localhost", port=6379)
        self.assertEqual(backend._rd, mock_redis.Redis.return_value)

    def test_async_redis_not_available_sets_ard_none(self):
        settings.REDIS_CONNECTION_CLASS = "tests.redis_mockup.Connection"
        settings.REDIS_ASYNC_CONNECTION_CLASS = None
        mock_redis = mock.MagicMock()
        # Simulate redis.asyncio not being available
        with mock.patch.dict("sys.modules", {"redis": mock_redis, "redis.asyncio": None}):
            backend = RedisBackend()
        self.assertIsNone(backend._ard)

    def test_async_redis_from_url_with_string_connection(self):
        settings.REDIS_CONNECTION_CLASS = "tests.redis_mockup.Connection"
        settings.REDIS_ASYNC_CONNECTION_CLASS = None
        settings.REDIS_CONNECTION = "redis://localhost:6379/0"
        mock_aredis = mock.MagicMock()
        mock_redis = mock.MagicMock()
        mock_redis.asyncio = mock_aredis
        with mock.patch.dict("sys.modules", {"redis": mock_redis, "redis.asyncio": mock_aredis}):
            backend = RedisBackend()
        mock_aredis.from_url.assert_called_once_with("redis://localhost:6379/0")
        self.assertEqual(backend._ard, mock_aredis.from_url.return_value)

    def test_async_redis_with_dict_connection(self):
        settings.REDIS_CONNECTION_CLASS = "tests.redis_mockup.Connection"
        settings.REDIS_ASYNC_CONNECTION_CLASS = None
        settings.REDIS_CONNECTION = {"host": "localhost", "port": 6379}
        mock_aredis = mock.MagicMock()
        mock_redis = mock.MagicMock()
        mock_redis.asyncio = mock_aredis
        with mock.patch.dict("sys.modules", {"redis": mock_redis, "redis.asyncio": mock_aredis}):
            backend = RedisBackend()
        mock_aredis.Redis.assert_called_once_with(host="localhost", port=6379)
        self.assertEqual(backend._ard, mock_aredis.Redis.return_value)

    def test_check_async_support_raises_when_ard_is_none(self):
        backend = RedisBackend()
        backend._ard = None
        with self.assertRaises(ImproperlyConfigured):
            backend._check_async_support()
