# -*- coding: utf-8 -*-

"""
Caching decorators: pure in-memory (``cache``) and
extensible fallback base.
"""

import threading
import time
from abc import ABC
from abc import abstractmethod
from collections import OrderedDict
from functools import wraps
from typing import Any
from typing import Callable
from typing import Optional
from typing import Tuple

# Distinguishes a cached None from a true cache miss...
_MISS = object()


class L2Backend(ABC):
    """
    Interface for pluggable cache fallback backends. Implement
    this to add a persistence layer (disk, S3, Redis, DynamoDB or any
    other engine) to :class:`_CacheWrapper` via its ``fallback``
    parameter.
    """

    def __init__(self, ttl: Optional[float] = None) -> None:
        self.ttl = ttl

    @abstractmethod
    def load(self, cache_key: Any) -> Any:
        """Return the cached value, or :data:`_MISS` if not present."""

    @abstractmethod
    def save(self, cache_key: Any, value: Any) -> None:
        """Persist *value* under *cache_key*."""


class _CacheWrapper:
    """
    In-memory LRU cache with an optional fallback
    backend.

    Without a ``fallback`` this is a pure in-memory cache (used by
    :func:`cache`).  Pass a :class:`L2Backend` to add a second storage
    layer: on an L1 miss the fallback is checked before calling the real
    function, and every new result is written through to the fallback
    immediately so other processes can share it.
    """

    def __init__(
        self,
        fcn: Callable,
        maxsize: Optional[int] = None,
        fallback: Optional[L2Backend] = None,
        ttl: Optional[float] = None,
    ) -> None:
        """
        :param fcn: The function whose results will be cached.

        :param maxsize:
            Maximum number of entries to keep in memory. When exceeded
            the least-recently-used entry is evicted. ``None`` (default)
            means the cache is unbounded.

        :param fallback:
            Optional :class:`L2Backend` consulted on an L1 miss and
            written to on every new computation. ``None`` (default) gives
            pure in-memory behavior identical to :func:`cache`.

        :param ttl:
            Time-to-live in seconds for L1 entries. Expired entries are
            evicted lazily on the next access. ``None`` (default) means
            entries never expire.
        """

        self.fcn = fcn
        self.cache: OrderedDict[Tuple[Any, ...], Any] = OrderedDict()
        self.maxsize = maxsize
        self.ttl = ttl

        self._timestamps: dict = {}
        self._lock = threading.Lock()
        self._fallback = fallback
        wraps(fcn)(self)

    def __call__(self, *args: Any, **kwargs: Any) -> Any:
        cache_key = (args, tuple(sorted(kwargs.items())))
        with self._lock:
            if cache_key in self.cache:
                if self.ttl is not None and time.time() - self._timestamps[cache_key] > self.ttl:
                    del self.cache[cache_key]
                    del self._timestamps[cache_key]

                else:
                    self.cache.move_to_end(cache_key)
                    return self.cache[cache_key]

            if self._fallback is not None:
                result = self._fallback.load(cache_key)
                if result is not _MISS:
                    self.cache[cache_key] = result
                    self._timestamps[cache_key] = time.time()
                    self._evict_if_needed()
                    return result

            result = self.fcn(*args, **kwargs)
            self.cache[cache_key] = result
            self._timestamps[cache_key] = time.time()

            if self._fallback is not None:
                self._fallback.save(cache_key, result)

            self._evict_if_needed()
            return result

    def _evict_if_needed(self) -> None:
        if self.maxsize is not None and len(self.cache) > self.maxsize:
            evicted_key, _ = self.cache.popitem(last=False)
            self._timestamps.pop(evicted_key, None)


def cache_memory_based(
    fcn: Optional[Callable] = None,
    *,
    maxsize: Optional[int] = None,
    fallback: Optional[L2Backend] = None,
    ttl: Optional[float] = None,
) -> Callable:
    """
    Pure in-memory memoising decorator; the no-fallback base of the
    cache hierarchy.

    Subclasses add persistence by passing a :class:`L2Backend` to
    :class:`_CacheWrapper`: :func:`cache_file_based` uses disk, future
    variants will use S3 or Redis.  All of them fall back to this
    in-memory behavior when their backend is unavailable.

    :func:`functools.lru_cache` is order-sensitive for keyword arguments:
    ``f(a=1, b=2)`` and ``f(b=2, a=1)`` are treated as distinct cache
    entries and the underlying function is called twice. This decorator
    normalises kwargs by sorting them before building the key, so both
    calls map to the same entry.

    If kwargs order-insensitivity is not needed, prefer
    ``@functools.lru_cache(maxsize=N)``, it is implemented in C and
    exposes ``.cache_info()`` / ``.cache_clear()``. Use this decorator
    when you need to memoize functions whose callers may pass the same
    keyword arguments in different orders.

    :param fcn: The function being decorated.

    :param maxsize:
        Maximum number of entries to keep. When the limit is reached the
        least-recently-used entry is evicted. ``None`` (default) means
        the cache is unbounded.

    :param fallback:
        Optional :class:`L2Backend` consulted on an L1 miss and
        written to on every new computation. ``None`` (default) gives
        pure in-memory behavior identical to :func:`cache_memory_based`.

    :param ttl:
        Time-to-live in seconds for L1 entries. Expired entries are
        evicted lazily on the next access. ``None`` (default) means
        entries never expire.

    :return: The wrapped function.
    :rtype: Callable
    """

    def decorator(_fcn: Callable) -> _CacheWrapper:
        return _CacheWrapper(
            _fcn,
            maxsize=maxsize,
            fallback=fallback,
            ttl=ttl,
        )

    if fcn is not None:
        return decorator(fcn)

    return decorator


#: Backward-compatible alias for :func:`cache_memory_based`.
cache = cache_memory_based
