"""Module defining base limpyd models

Both base models are provided:
- ``BaseModel``: A base model for our limpyd models
- ``DjangoLimpydModel``: A base for all limpyd models tied to a django one.

Attributes
----------

limpyd_database : a ``limpyd.database.RedisDatabase`` instance
    A limpyd database to use for our limpyd models, already set by default for models inheriting
    from ``BaseModel`` and ``DjangoLimpydModel``

Notes
-----
A Redis server must be present, its settings being defined in the ``LIMPYD_REDIS`` settings entry

"""

from datetime import datetime, timezone
import json
from logging import (
    ERROR,
    INFO,
    WARNING,
    _levelToName
)

from limpyd import fields, model
from limpyd.contrib.database import PipelineDatabase

from django.conf import settings
from django.db import models
from django.utils.functional import cached_property
from django.utils.timezone import now


limpyd_database = PipelineDatabase(**settings.LIMPYD_REDIS)


class BaseModel(model.RedisModel):
    """A base model for our limpyd models.

    Simply defines the database to use.

    Attributes
    ----------
    database : instance of ``limpyd.database.RedisDatabase``
        The redis database that will be used by all subclasses of this base
        model. Could be redefined in subclasses if needed.

    Example
    -------
    >>> from limpyd.fields import StringField
    >>> from mtp.apps.common.limpyd_base import BaseModel
    >>>
    >>> class SomeLimpydModel(BaseModel):
    ...     some_field = StringField()
    ...

    """
    abstract = True  # this base model is not a real model

    database = limpyd_database


class DjangoBackedRedisModel(BaseModel):
    """A base for all limpyd models tied to a django one.

    For each instance of a subclass of this base model, an instance of the
    django model exists, both sharing the same value for their primary key.

    Attributes
    ----------
    django_model : a django model class
        To avoid recursive imports, this may be defined in a
        ``cached_property``.
        This **must** be defined in subclasses.

    id: limpyd field, instance of ``limpyd.fields.PKField``
        Will store the primary key of the object. Not an ``AutoPKField`` to
        allow (even enforce) the sharing of the same value for the primary key
        of the django and limpyd instances.


    Example
    -------
    >>> from limpyd.fields import StringField
    >>>
    >>> from django.utils.functional import cached_property
    >>>
    >>> from mtp.apps.example.models import ExampleModel
    >>> from mtp.apps.common.limpyd_base import DjangoBackedRedisModel
    >>>
    >>> class ExampleModelData(DjangoBackedRedisModel):
    ...     django_model = ExampleModel
    ...
    ...     some_field = StringField()
    ...
    ...     @cached_property
    ...     def django_model(self):
    ...         '''Other way to define ``django_model``'''
    ...
    ...         from mtp.apps.example.models import ExampleModel
    ...         return ExampleModel
    ...

    """

    abstract = True  # this base model is not a real model

    id = fields.PKField()

    django_model = None

    @cached_property
    def django_instance(self):
        """Return the django model instance tied to this limpyd instance.

        It's a cached property, so this will only be evaluated once for an instance of this class.

        Returns
        -------
        django model instance
            The instance of the django model defined by ``django_model`` tied to
            the current limpyd model instance.

        Raises
        ------
        django.core.exceptions.ObjectDoesNotExist
            When no instances of the django model exists with the ``pk`` of
            the current limpyd model instance.

        Example
        -------
        >>> my_limpyd_instance.django_instance.do_something()


        Notes
        -----
        Both instances share the same value for their primary key so this is
        just a simple object fetching.

        """

        return self.django_model.objects.get(pk=self.pk.get())


class RedisBackedDjangoModel(models.Model):
    """A mixin for django models to associate an instance.

    Attributes
    ----------
    redis_model : a Limpyd model class
        To avoid recursive imports, this may be defined in a
        ``cached_property``.

    Example
    -------
    >>> from django.db import models
    >>> from django.utils.functional import cached_property
    >>>
    >>> from mtp.apps.example.limpyd_models import ExampleModelData
    >>> from mtp.apps.common.limpyd_base import RedisBackedDjangoModel
    >>>
    >>> class ExampleModel(RedisBackedDjangoModel, models.Model):
    ...     redis_model = ExampleModelData
    ...
    ...     some_field = models.CharField(max_length=255)
    ...
    ...     @cached_property
    ...     def redis_model(self):
    ...         '''Other way to define ``redis_model``'''
    ...
    ...         from mtp.apps.example.limpyd_models import ExampleModelData
    ...         return ExampleModelData
    ...

    """

    redis_model = None

    class Meta:
        abstract = True  # it's an abstract base model class

    @cached_property
    def redis_instance(self):
        """Return the limpyd model instance tied to this django instance, creating it if needed.

        It's a cached property, so this will only be evaluated once for an instance of this class.

        Returns
        -------
        Limpyd model instance
            The instance of the limpyd model defined by ``redis_model`` tied to the
            current django model instance.

        Raises
        ------
        limpyd.exceptions.DoesNotExist
            When no instances of the limpyd model exists with the ``pk`` of
            the current django model instance.

        Example
        -------
        >>> my_django_instance.redis_instance.do_something()

        Notes
        -----
        Both instances share the same value for their primary key so this is
        just a simple object fetching.

        """

        result, __ = self.redis_model.get_or_connect(pk=self.pk)
        return result


class DjangoBackedRedisModelWithLogs(DjangoBackedRedisModel):
    """A limpyd model with logging capability

    The django model tied to this lympid model must inherit from the
    ``RedisBackedDjangoModelWithLogs`` below.
    The base methods are inspired by the ``logging.Logger`` class, but
    the logs are tied to a django instance and can be easily retrieved.

    Attributes
    ----------
    logs: SortedSetField
        A sorted set of log entries

    Examples
    --------
    >>> from limpyd.fields import StringField
    ...
    ... from django.utils.functional import cached_property
    ...
    ... from mtp.apps.example.models import ExampleModel
    ... from mtp.apps.common.limpyd_base import DjangoBackedRedisModel
    ...
    ... class ExampleModelData(DjangoBackedRedisModelWithLogs):
    ...     django_model = ExampleModel
    ...
    ...     some_field = StringField()
    ...
    ...     @cached_property
    ...     def django_model(self):
    ...         '''Other way to define ``django_model``'''
    ...
    ...         from mtp.apps.example.models import ExampleModel
    ...         return ExampleModel
    ...
    ... example_instance = ExampleModel()
    ... example_instance.save()
    ...
    >>> example_instance.logger.info('This is an "%s" message', 'info')

    """

    logs = model.SortedSetField()

    def info(self, msg, *args):
        """Log 'msg % args' with severity 'INFO'.

        Parameters
        ----------
        msg: str
            The message to log
        *args: list
            The arguments to the msg formatting

        Examples
        --------
        >>> limpyd_instance.info("Houston, we have a %s", "interesting problem")

        """

        self._log(INFO, msg, args)

    def warning(self, msg, *args):
        """Log 'msg % args' with severity 'WARNING'.

        Parameters
        ----------
        msg: str
            The message to log
        *args: list
            The arguments to the msg formatting

        Examples
        --------
        >>> limpyd_instance.warning("Houston, we have a %s", "bit of a problem")

        """

        self._log(WARNING, msg, args)

    def error(self, msg, *args):
        """Log 'msg % args' with severity 'ERROR'.

        Parameters
        ----------
        msg: str
            The message to log
        *args: list
            The arguments to the msg formatting

        Examples
        --------
        >>> limpyd_instance.error("Houston, we have a %s", "major problem")

        """

        self._log(ERROR, msg, args)

    def log(self, level, msg, *args):
        """Log 'msg % args' with the integer severity 'level'.

        Parameters
        ----------
        level: int
            The level of this log entry
        msg: str
            The message to log
        *args: list
            The arguments to the msg formatting

        Examples
        --------
        >>> limpyd_instance.log(level, "We have a %s", "mysterious problem")

        """

        if not isinstance(level, int):
            raise TypeError('level must be an integer')

        self._log(level, msg, *args)

    def _log(self, level, msg, *args):
        """Low-level logging routine which create a new entry in the tied limpyd model

        Parameters
        ----------
        level: int
            The level of this log entry
        msg: str
            The message to log
        *args: list
            The arguments to the msg formatting

        """

        msg = msg % list(*args)

        # Get the current timestamp from django ``now()``
        unix_timestamp = now().timestamp()

        json_log_entry = {
            'level': level,
            'msg': msg,
            'unix_timestamp': unix_timestamp
        }

        self.logs.zadd(
            unix_timestamp,
            json.dumps(json_log_entry)
        )

    def _decode_entry(self, log):
        """Format the given log item to a readable dict representation

        Parameters
        ----------
        log: string
            A json encoded log

        Returns
        -------
        dict
            A readable log entry

        Examples
        --------
        >>> log_entry = '{"level": 10, "unix_timestamp": 1453392772.863292, "msg": "Info message"}'
        >>> limpyd_instance._decode_entry(log_entry)
        {'level': 10, 'level_str': 'INFO', 'date': datetime.datetime(2016, 1, 21, 16, 12, 52,
         863292, tzinfo=<UTC>), 'msg': 'Info message'}

        Notes
        -----
        If the log level is not recognized, the ``level_str`` key is omitted.

        """

        json_log = json.loads(log)

        # Try to decode the log level as a string
        try:
            json_log['level_str'] = _levelToName[json_log['level']]
        except KeyError:
            pass

        json_log['date'] = datetime.fromtimestamp(
            json_log['unix_timestamp'],
            tz=timezone.utc
        )

        del json_log['unix_timestamp']

        return json_log

    def entries_by_index(self, start=0, stop=-1, reverse=False):
        """Get the log entries for the provided indexes

        Parameters
        ----------
        start: int, 0 per default
            The start index
        stop: int, -1 per default
            The stop index
        reverse: bool
            If True the entries will be ordered by their indexes DESC,
            if False the entries will be ordered by their indexes ASC

        Yields
        ------
        dict
            A dict representation of a log entry

        """

        method = 'zrevrange' if reverse else 'zrange'

        for entry in getattr(self.logs, method)(start, stop):
            yield self._decode_entry(entry)

    def last_entry(self):
        """Shortcut method to get the last added log entry

        Returns
        -------
        entry or None
            The last log entry,
            or None, if no entries can be found

        """

        try:
            return next(self.entries_by_index(0, 1, reverse=True))
        except StopIteration:
            return None

    def entries_by_date(self, start_date=None, end_date=None, reverse=False):
        """Get the log entries for the provided datetime interval

        Both ``start_date`` and ``end_date`` can be None.
        If both are None, this method return the whole list of log entries.
        Both ``start_date`` and ``end_date`` are inclusive.

        Parameters
        ----------
        start_date: datetime or None
            The start date from we want the log entries, inclusive
        end_date: datetime or None
            The end date we want the log entries to, inclusive
        reverse: bool
            If True the entries will be ordered by date DESC,
            if False the entries will be ordered by date ASC

        Yields
        ------
        dict
            A dict representation of a log entry

        """

        if start_date:
            start_range = start_date.timestamp()
        else:
            start_range = '-inf'

        if end_date:
            end_range = end_date.timestamp()
        else:
            end_range = '+inf'

        method = 'zrevrangebyscore' if reverse else 'zrangebyscore'
        args = [end_range, start_range] if reverse else [start_range, end_range]

        for entry in getattr(self.logs, method)(*args):
            yield self._decode_entry(entry)


class RedisBackedDjangoModelWithLogs(RedisBackedDjangoModel):
    """A mixin for django models to associate a limpyd instance.

    Use it with the ``RedisBackedDjangoModel`` base.

    This base model offer a ``logger`` property with logging capabilities.

    """

    class Meta:
        abstract = True  # it's an abstract base model class

    @property
    def logger(self):
        """Alias for the redis limpyd instance

        So we can do something like that:
        >>> django_instance.logger.info('Info message')

        """

        return self.redis_instance
