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

"""Persistent file-based caching decorator."""

import hashlib
import pickle  # nosec B403
import tempfile
import time
from pathlib import Path
from typing import Any, Callable, Optional, cast

from .cache_memory_based import L2Backend, _MISS, cache


class _DiskBackend(L2Backend):
    """L2 backend that stores entries as pickle files on disk."""

    def __init__(
        self,
        fcn_qualname: str,
        base_path: Optional[str] = None,
        ttl: Optional[float] = None,
    ) -> None:
        """
        :param fcn_qualname:
            ``__qualname__`` of the decorated function; used to derive a
            unique subdirectory name so different functions never share
            the same pickle files.

        :param base_path:
            Parent directory under which the per-function subdirectory is
            created. Defaults to ``tempfile.gettempdir()``.

        :param ttl:
            Time-to-live in seconds. Entries older than this are treated
            as a miss on load. ``None`` (default) means entries never expire.
        """

        super().__init__(ttl=ttl)
        safe_name = fcn_qualname.replace("<", "_").replace(">", "_")
        base = Path(base_path) if base_path is not None else Path(tempfile.gettempdir())
        self.path = base / safe_name
        self.path.mkdir(parents=True, exist_ok=True)

    def load(self, cache_key: Any) -> Any:
        p = self._pickle_path(cache_key)
        if p is None or not p.exists():
            return _MISS

        try:
            data = pickle.loads(p.read_bytes())  # noqa: S301  # nosec B301
            if isinstance(data, dict) and "v" in data and "t" in data:
                if self.ttl is not None and time.time() - data["t"] > self.ttl:
                    p.unlink(missing_ok=True)
                    return _MISS

                return data["v"]

            return data

        except Exception:  # pylint: disable=broad-exception-caught
            return _MISS

    def save(self, cache_key: Any, value: Any) -> None:
        p = self._pickle_path(cache_key)
        if p is None:
            return

        try:
            p.write_bytes(pickle.dumps({"v": value, "t": time.time()}))
        except Exception:  # pylint: disable=broad-exception-caught  # nosec B110
            pass

    def _pickle_path(self, cache_key: Any) -> Optional[Path]:
        try:
            return self.path / f"{hashlib.md5(pickle.dumps(cache_key), usedforsecurity=False).hexdigest()}.pkl"
        except Exception:  # pylint: disable=broad-exception-caught
            return None


def cache_file_based(
    fcn: Optional[Callable] = None,
    *,
    path: Optional[str] = None,
    maxsize: int = 128,
    ttl: Optional[float] = None,
) -> Callable:
    """
    Write-through caching decorator: L1 is a bounded in-memory LRU
    (:class:`_CacheWrapper`); the fallback is a directory of per-entry
    pickle files on disk (:class:`_DiskBackend`).

    Every new result is written to both L1 and disk immediately. When L1
    is full the least-recently-used entry is evicted from memory only —
    its disk file is kept. A subsequent call with the same arguments
    reloads the value from disk without invoking the wrapped function.

    Because results are always on disk, multiple processes sharing the
    same ``path`` benefit from each other's computed results.

    Supports both bare and parameterized call styles::

        @cache_file_based
        def f(x): ...

        @cache_file_based(path="/tmp/mycache", maxsize=64)
        def g(x): ...

    :param fcn: The function being decorated (set when used bare).
    :param path:
        Directory under which per-function subdirectories are created.
        Defaults to ``tempfile.gettempdir()``.
    :param maxsize:
        Maximum number of entries to keep in memory. Least-recently-used
        entries are evicted from memory (disk file kept) when the limit
        is exceeded. Defaults to ``128``.
    :param ttl:
        Time-to-live in seconds. Expiry is symmetric across both layers:
        expired L1 entries are evicted from memory, and expired L2 files
        are deleted from disk, both lazily on the next access. No
        background thread or external cleanup is required. ``None``
        (default) means entries never expire.
    :return: The wrapped function.
    :rtype: Callable
    """

    def decorator(_fcn: Callable) -> Callable:
        backend = _DiskBackend(cast(Any, _fcn).__qualname__, base_path=path, ttl=ttl)
        wrapper = cache(_fcn, maxsize=maxsize, fallback=backend, ttl=ttl)
        wrapper.path = backend.path  # type: ignore[attr-defined]
        return wrapper

    if fcn is not None:
        return decorator(fcn)

    return decorator
