"""Define a limpyd database to use in tests

This database will use a fake redis connection to avoid the need of a real redis server.

Attributes
----------

limpyd_test_database : a ``LimpydTestDatabase`` instance
    A limpyd database to use for our limpyd tests models, connected to a fake redis server
    via ``fakeredis``.

Example
-------

>>> from mtp.apps.common.limpyd_base import BaseModel
>>> from mtp.apps.common.tests.limpyd_base.limpyd_database import LIMPYD_TEST_DATABASE
>>> class ExampleTestLimpydModel(BaseModel):
...     # ...
...     database = LIMPYD_TEST_DATABASE

Or to set it for all our limpyd models during tests:

>>> BaseModel.use_database(LIMPYD_TEST_DATABASE)

"""

from collections import Iterable

from fakeredis import FakePipeline
from limpyd.contrib.database import PipelineDatabase
from limpyd.fields import RedisField, RedisProxyCommand
from limpyd.collection import CollectionManager

from mtp.apps.common.tests.fake_redis import FakeStrictRedis


class TestPipeline(FakePipeline):
    """Pipeline for a PipelineDatabase that uses ``fakeredis``."""

    def __init__(self, database, transaction=True):
        """Store the previous connexion when opening a pipeline.

        For the parameters, see ``fakeredis.FakePipeline``"""
        self._database = database
        self._original_connection = database._connection
        super().__init__(database._connection, transaction)
        database._connection = self

    def __exit__(self, exc_type, exc_value, traceback):
        """Restore the previous connexion at the end of the pipeline.

        For the parameters, see ``fakeredis.FakePipeline``"""
        self._database._connection = self._original_connection
        super().__exit__(exc_type, exc_value, traceback)

    def watch(self, *names):
        """
        Override the default watch method to allow the user to pass RedisField
        objects as names, which will be translated to their real keys and passed
        to the default watch method

        For the parameters, see ``fakeredis.FakePipeline``
        """
        watches = []
        for watch in names:
            if isinstance(watch, RedisField):
                watch = watch.key
            watches.append(watch)
        return super().watch(*watches)


class LimpydTestDatabase(PipelineDatabase):
    """A limpyd database to use in test, avoiding a real redis server

    Notes
    -----
    ``fakeredis`` does not support auto-decoding (``decode_responses=True``) which is the default
    in redis-limpyd, so to test value, donc forget to call ``.decode()`` to compare string values
    """

    def connect(self, **settings):
        """Use a ``fakeredis`` connection

        For the parameters, see ``limpyd.database.RedisDatabase``

        Notes
        -----
        The whole method had to be rewritten because the ``RedisDatabase`` class doesn't
        provide an easy way to change the class to use to connect to redis
        """
        if not settings:
            settings = self.connection_settings
        connection_key = ':'.join([str(settings[k]) for k in sorted(settings)])
        if connection_key not in self._connections:
            self._connections[connection_key] = FakeStrictRedis(**settings)
        return self._connections[connection_key]

    def pipeline(self, transaction=True):
        """Use the pipeline from ``fakeredis`` instead of the one in ``PipelineDatabase``.

        For the parameters, see ``limpyd.database.RedisDatabase``"""
        return TestPipeline(self, transaction=transaction)


# Create a limpyd database using ``FakeRedis``
LIMPYD_TEST_DATABASE = LimpydTestDatabase()


def post_command(self, sender, name, result, args, kwargs):  # pylint: disable=unused-argument
    """Emulate for ``fakeredis`` the decoding stuff in ``redis-py``.

    ``redis.StrictRedis`` accepts a ``decode_responses`` argument to decode everything that is
    received from redis. It's ``False`` by default, but force to ``True`` by ``limpyd.
    ``fakeredis`` doesn't have this argument, and then bytes are not decoded.

     Luckily, every response form ``limpyd`` goes through the ``post_command`` method of the
     main ``limpyd`` class, ``RedisProxyCommand``, so there is only one endpoint to update to
     simulate the same behavior.

     So this method tries do decode to a real string every results. If it's a dict, it tries to
     decode keys and values, if it's an iterable, every values, and all of this in a recursive
     manner.



        For the parameters, see ``limpyd.model.RedisModel``

    """
    # If we are in a normal redis-py, we don't want to try to decode: it's already done.
    if not isinstance(self.connection, FakeStrictRedis):
        return result

    # Get the encoding from the ``FakeStrictRedis`` instance to use it to decode.
    encoding = self.connection._encoding

    def dec_bytes(string):
        return string.decode(encoding)

    def dec_iter(val):
        """Iterate over each entries, and returns an object of the same kind as the one given."""
        return val.__class__((dec(v) for v in val))

    def dec_items(val):
        """Iterate over each dict items, and returns an object of the same kind as the one given."""
        return val.__class__(((dec(k), dec(v)) for k, v in val.items()))

    def dec(val):
        """Main function that will call the ``dec_*`` depending on the type of the value."""

        # Decode bytes.
        if isinstance(val, bytes):
            return dec_bytes(val)

        # Decode dicts or dict-like objects
        if hasattr(val, 'items'):
            return dec_items(val)

        # Decode other iterable, but not strings.
        if not isinstance(val, str) and isinstance(val, Iterable):
            return dec_iter(val)

        # No decoding for int, float, str
        return val

    return dec(result)

RedisProxyCommand.post_command = post_command


def update_final_redis_call(cls):
    """Update collection manager final redis call to return strings, not bytes."""
    cls.original_final_redis_call = cls._final_redis_call

    def str_casted_final_redis_call(self, *args, **kwargs):
        encoding = self.cls.get_connection()._encoding
        result = self.original_final_redis_call(*args, **kwargs)
        return result.__class__(i.decode(encoding) for i in result)

    cls._final_redis_call = str_casted_final_redis_call

update_final_redis_call(CollectionManager)
