"""Filesystem cache backend. For usage details, see :ref:`Backends: Filesystem <filesystem>`.

.. automodsumm:: requests_cache.backends.filesystem
   :classes-only:
   :nosignatures:
"""

from contextlib import contextmanager
from logging import getLogger
from os import makedirs
from pathlib import Path
from pickle import PickleError
from shutil import rmtree
from threading import RLock
from time import time_ns
from typing import Any, Iterator, Optional

from ..serializers import SerializerType, json_serializer
from . import BaseCache, BaseStorage, StrOrPath
from .sqlite import SQLiteDict, get_cache_path

logger = getLogger(__name__)


class FileCache(BaseCache):
    """Filesystem cache backend.

    Args:
        cache_name: Base directory for cache files
        use_cache_dir: Store database in a user cache directory (e.g., `~/.cache/`)
        use_temp: Store cache files in a temp directory (e.g., ``/tmp/http_cache/``).
            Note: if ``cache_name`` is an absolute path, this option will be ignored.
        decode_content: Decode JSON or text response body into a human-readable format
        extension: Extension for cache files. If not specified, the serializer default extension
            will be used.
        max_cache_bytes: Enable LRU caching, and set the maximum total size (in bytes) of cached
            responses on the file system.
        max_file_bytes: The maximum size of a single file.
            By default, this is the same as ``max_cache_bytes``.
            Only used if ``max_cache_bytes`` is set.
        block_bytes: The size of a block of data on the file system, which will be used when
            computing total file size on disk. Only used if ``max_cache_bytes`` is set.
        sync_index: On startup, sync LRU metadata with any changes on disk since last use. Use this
            if you intend to modify cache files outside of requests-cache. Leave off to reduce
            startup time for larger caches. Only used if ``max_cache_bytes`` is set.
        lock: Replace the default :class:`threading.RLock` object without your own. Use this if you
            want to share the lock between multiple cache instances, and/or use a different lock
            type (such as :py:class:`multiprocessing.RLock` or :py:class:`filelock.FileLock`).
    """

    def __init__(
        self,
        cache_name: StrOrPath = 'http_cache',
        use_temp: bool = False,
        decode_content: bool = True,
        serializer: Optional[SerializerType] = None,
        **kwargs,
    ):
        super().__init__(cache_name=str(cache_name), **kwargs)
        skwargs = {'serializer': serializer, **kwargs} if serializer else kwargs
        self.responses: FileDict = (LRUFileDict if 'max_cache_bytes' in kwargs else FileDict)(
            cache_name, use_temp=use_temp, decode_content=decode_content, **skwargs
        )
        with self.lock:
            self.redirects: SQLiteDict = SQLiteDict(
                self.cache_dir / 'redirects.sqlite', 'redirects', serializer=None, **kwargs
            )

    @property
    def lock(self) -> RLock:
        """The lock used by the cache."""
        return self.responses.lock

    @property
    def cache_dir(self) -> Path:
        """Base directory for cache files"""
        return Path(self.responses.cache_dir)

    def paths(self) -> Iterator[Path]:
        """Get absolute file paths to all cached responses"""
        return self.responses.paths()

    def clear(self):
        """Clear the cache"""
        # FileDict.clear() removes the cache directory, including redirects.sqlite
        with self.lock:
            self.responses.clear()
            self.redirects.init_db()

    def delete(self, *args, **kwargs):
        with self.lock:
            return super().delete(*args, **kwargs)


class FileDict(BaseStorage):
    """A dictionary-like interface to files on the local filesystem.

    The cache directory will be created if it doesn't already exist.
    """

    def __init__(
        self,
        cache_name: StrOrPath,
        use_temp: bool = False,
        use_cache_dir: bool = False,
        extension: Optional[str] = None,
        serializer: Optional[SerializerType] = json_serializer,
        lock: Optional[RLock] = None,
        **kwargs,
    ):
        super().__init__(serializer=serializer, **kwargs)
        self.cache_dir = get_cache_path(cache_name, use_cache_dir=use_cache_dir, use_temp=use_temp)
        self.extension = _get_extension(extension, self.serializer)
        self.is_binary = getattr(self.serializer, 'is_binary', False)
        self._lock = lock if lock is not None else RLock()
        makedirs(self.cache_dir, exist_ok=True)

    @property
    def lock(self) -> RLock:
        """The lock used by the cache."""
        return self._lock

    @contextmanager
    def _try_io(self, key: Optional[str] = None, ignore_errors: bool = False):
        """Attempt an I/O operation, and either ignore errors or re-raise them as KeyErrors"""
        try:
            with self._lock:
                yield
        except (EOFError, IOError, OSError, PickleError) as e:
            if not ignore_errors:
                raise KeyError(f'File for key {key!r} not found.') from e

    def _key2path(self, key: str) -> Path:
        return self.cache_dir / f'{key}{self.extension}'

    def __getitem__(self, key: str):
        mode = 'rb' if self.is_binary else 'r'
        with self._try_io(key):
            with self._key2path(key).open(mode) as f:
                return self.deserialize(key, f.read())

    def __delitem__(self, key):
        with self._try_io(key):
            self._key2path(key).unlink()

    def __setitem__(self, key, value):
        with self._try_io(key):
            with self._key2path(key).open(mode='wb' if self.is_binary else 'w') as f:
                f.write(self.serialize(value))

    def __contains__(self, key) -> bool:
        with self._lock:
            return self._key2path(key).exists()

    def __iter__(self) -> Iterator[str]:
        yield from self.keys()

    def __len__(self) -> int:
        return sum(1 for _ in self.paths())

    def clear(self) -> None:
        """Empty the cache directory."""
        with self._try_io(ignore_errors=True):
            rmtree(self.cache_dir, ignore_errors=True)
            self.cache_dir.mkdir()

    def keys(self):
        return [path.stem for path in self.paths()]

    def paths(self) -> Iterator[Path]:
        """Get absolute file paths to all cached responses"""
        with self._lock:
            return self.cache_dir.glob(f'*{self.extension}')

    def size(self) -> int:
        """Return the size of the database, in bytes"""
        with self._lock:
            return sum(path.stat().st_size for path in self.paths())


class LRUFileDict(FileDict):
    """A size-restricted version of FileDict, using LRU eviction.

    Args:
        block_bytes: The size of a block of data on the file system.
            File sizes will be aligned with this.
        max_cache_bytes: The maximum total size of all files in the cache.
        max_file_bytes: The maximum size of a single file.
            By default, this is the same as ``max_cache_bytes``.
        sync_index: Check for filesystem changes since last use. Use this if you intend to modify
            cache files outside of requests-cache. Leave off to reduce startup time for larger caches.
    """

    def __init__(
        self,
        *args,
        block_bytes: int = 1,
        max_cache_bytes: int = 100 * 1024 * 1024,  # 100MB
        max_file_bytes: Optional[int] = None,
        sync_index: bool = False,
        **kwargs,
    ):
        super().__init__(*args, **kwargs)
        self.max_cache_bytes = max_cache_bytes
        self.block_bytes = block_bytes
        self.max_file_bytes = max_file_bytes or max_cache_bytes
        if self.max_file_bytes > self.max_cache_bytes:
            raise ValueError(
                f'max_file_bytes must be smaller or equal to max_cache_bytes ({max_cache_bytes})'
            )
        if self.block_bytes > self.max_file_bytes:
            raise ValueError(f'block_bytes must be smaller than max_file_bytes ({max_file_bytes})')
        if self.block_bytes < 1:
            raise ValueError(f'block_bytes must be greater than 0, not {block_bytes}')

        self.lru_index = LRUDict(self.cache_dir / 'lru.db', 'lru', **kwargs)
        # Rebuild LRU index if explicitly asked,
        # or for a new cache (potentially with existing files but no metadata)
        if sync_index or len(self.lru_index) == 0:
            self._sync_lru_index()

    def __getitem__(self, key):
        """Get a value and update its access time in the LRU index"""
        value = super().__getitem__(key)

        # Update access time in LRU index
        try:
            self.lru_index.update_access_time(key)
        # File is missing from LRU index
        except KeyError:
            file_path = self._key2path(key)
            if file_path.exists():
                file_size = self._get_size_on_disk(file_path.stat().st_size)
                self.lru_index[key] = file_size

        return value

    def __setitem__(self, key: str, value: Any) -> None:
        content = self.serialize(value)
        data = content.encode('UTF-8') if isinstance(content, str) else content
        del content
        file_size = self._get_size_on_disk(len(data))

        if file_size > self.max_file_bytes:
            logger.debug(
                f'Not caching {key!r} because it is larger than {self.max_file_bytes} bytes.'
            )
            return

        with self._try_io(key):
            try:
                super().__delitem__(key)
            except KeyError:
                pass

            # Make space if needed before writing file
            self._evict(file_size)
            super().__setitem__(key, value)

            # Update LRU index with new file size and access time
            self.lru_index[key] = file_size

    def __delitem__(self, key):
        """Delete a file and remove it from the LRU index"""
        # Remove from LRU index
        try:
            del self.lru_index[key]
        except KeyError:
            pass

        super().__delitem__(key)

    def _evict(self, desired_free_bytes: int):
        """Make space in the cache to fit the given number of bytes, if needed.

        This starts deleting the least recently used entries first.
        """
        current_size = self.lru_index.total_size()
        space_needed = current_size + desired_free_bytes - self.max_cache_bytes
        if space_needed <= 0:
            return

        # Get LRU keys to evict based on how much space we need
        keys_to_evict = self.lru_index.get_lru(space_needed)
        for key in keys_to_evict:
            try:
                del self[key]
            except KeyError:
                pass

    def _sync_lru_index(self):
        """Rebuild the LRU index from files on disk"""
        with self._lock:
            self.lru_index.clear()
            for path in self.paths():
                key = path.stem
                self.lru_index[key] = self._get_size_on_disk(path.stat().st_size)

    def _get_size_on_disk(self, file_size: int) -> int:
        """Return a file size on disk, rounded up to fit the blocks on the file system"""
        sign = -1 if file_size < 0 else 1
        return (
            (file_size * sign + self.block_bytes - 1) // self.block_bytes * self.block_bytes * sign
        )

    def clear(self):
        """Clear the cache directory and LRU index."""
        super().clear()
        self.lru_index.clear()
        self._lru_initialized = False

    def size(self) -> int:
        """Return the size of the database, in bytes"""
        return self.lru_index.total_size()


class LRUDict(SQLiteDict):
    """A SQLite db used to track LRU metadata for cached items:

    * ``key``: The cache key
    * ``access_time``: The last access time, as a UNIX timestamp in nanoseconds
    * ``size``: The size of the cached item, in bytes
    * ``total_size`` Combined size of all cache items in bytes, accessed with :py:meth:`total_size`.

    Implementation Notes:

    * ``total_size`` is managed by triggers and stored in a separate single-row table.
    * As a dict-like interface, ``size`` is treated as the main value and ``access_time`` is set
      automatically and updated with :py:meth:`update_access_time`.
    * :py:meth:`get_lru` Can select multiple keys to evict, up to an arbitrary total size, within a
      single query using a window function.
    """

    def __init__(self, *args, **kwargs):
        kwargs.pop('serializer', None)
        super().__init__(*args, **kwargs)

    def init_db(self):
        self.close()
        with self.connection(commit=True) as con:
            # Table for LRU metadata
            con.execute(
                f'CREATE TABLE IF NOT EXISTS {self.table_name} ('
                '    key TEXT PRIMARY KEY,'
                '    access_time INTEGER NOT NULL,'
                '    size INTEGER NOT NULL'
                ')'
            )
            con.execute(
                f'CREATE INDEX IF NOT EXISTS idx_access_time ON {self.table_name}(access_time)'
            )
            con.execute(f'CREATE INDEX IF NOT EXISTS idx_size ON {self.table_name}(size)')

            # Single-row table to persist total cache size
            con.execute(
                f'CREATE TABLE IF NOT EXISTS {self.table_name}_size ('
                '    total_size INTEGER NOT NULL'
                ')'
            )
            con.execute(f'INSERT OR IGNORE INTO {self.table_name}_size (total_size) VALUES (0)')

            # Triggers to update total size
            con.execute(
                f"""
                CREATE TRIGGER IF NOT EXISTS {self.table_name}_insert
                AFTER INSERT ON {self.table_name}
                BEGIN
                    UPDATE {self.table_name}_size
                    SET total_size = total_size + NEW.size;
                END;
                """
            )
            con.execute(
                f"""
                CREATE TRIGGER IF NOT EXISTS {self.table_name}_delete
                AFTER DELETE ON {self.table_name}
                BEGIN
                    UPDATE {self.table_name}_size
                    SET total_size = total_size - OLD.size;
                END;
                """
            )
            con.execute(
                f"""
                CREATE TRIGGER IF NOT EXISTS {self.table_name}_update
                AFTER UPDATE OF size ON {self.table_name}
                WHEN OLD.size != NEW.size
                BEGIN
                    UPDATE {self.table_name}_size
                    SET total_size = total_size + (NEW.size - OLD.size);
                END;
                """
            )

    def __delitem__(self, key):
        with self.connection(commit=True) as con:
            cur = con.execute(f'DELETE FROM {self.table_name} WHERE key=?', (key,))
        if not cur.rowcount:
            raise KeyError

    def __getitem__(self, key) -> int:
        with self.connection() as con:
            # Using placeholders here with python 3.12+ and concurrency results in the error:
            # sqlite3.InterfaceError: bad parameter or other API misuse
            row = con.execute(f"SELECT size FROM {self.table_name} WHERE key='{key}'").fetchone()
            if not row:
                raise KeyError(key)
            return row[0]

    def __setitem__(self, key: str, size: int):
        """Save a value (file size), and update access time and total cache size"""

        timestamp = int(time_ns())
        with self.connection(commit=True) as con:
            con.execute(
                f"""
                INSERT INTO {self.table_name} (key, access_time, size)
                VALUES (?, ?, ?)
                ON CONFLICT(key) DO UPDATE
                SET access_time = excluded.access_time, size = excluded.size
                """,
                (key, timestamp, size),
            )

    def clear(self):
        super().clear()
        with self.connection(commit=True) as con:
            con.execute(f'UPDATE {self.table_name}_size SET total_size = 0')

    def count(self, *args, **kwargs):
        with self.connection() as con:
            return con.execute(f'SELECT COUNT(key) FROM {self.table_name}').fetchone()[0]

    def get_lru(self, total_size: int):
        """Get the least recently used keys with a combined size >= total_size"""

        with self.connection() as con:
            cur = con.execute(
                f"""
                WITH ordered AS (
                    SELECT key, size, access_time, SUM(size) OVER (ORDER BY access_time) AS running_total
                    FROM {self.table_name}
                )
                SELECT * FROM ordered WHERE running_total - size < ?
                ORDER BY access_time;
                """,
                (total_size,),
            )
            rows = cur.fetchall()
            cur.close()
            return [row[0] for row in rows]

    def sorted(  # type: ignore
        self,
        key: str = 'access_time',
        reversed: bool = False,
        limit: Optional[int] = None,
        **kwargs,
    ) -> Iterator[str]:
        """Get LRU entries in sorted order, by either ``access_time`` or ``size``"""
        # Get sort key, direction, and limit
        if key not in ['access_time', 'size', 'key']:
            raise ValueError(f'Invalid sort key: {key}')
        direction = 'DESC' if reversed else 'ASC'
        limit_expr = f'LIMIT {limit}' if limit else ''

        with self.connection() as con:
            for row in con.execute(
                f'SELECT key FROM {self.table_name} ORDER BY {key} {direction} {limit_expr}',
            ):
                yield row[0]

    def total_size(self) -> int:
        with self.connection() as con:
            row = con.execute(f'SELECT total_size FROM {self.table_name}_size').fetchone()
            return row[0] if row else 0

    def update_access_time(self, key: str):
        """Update the given key with the current timestamp

        Raises:
            KeyError: If the key doesn't exist in the LRU index
        """
        timestamp = int(time_ns())
        with self.connection(commit=True) as con:
            cur = con.execute(
                f'UPDATE {self.table_name} SET access_time = ? WHERE key = ?',
                (timestamp, key),
            )
        if not cur.rowcount:
            raise KeyError(key)


def _get_extension(extension: Optional[str] = None, serializer=None) -> str:
    """Use either the provided file extension, or get the serializer's default extension"""
    if extension:
        return f'.{extension}'
    subs = {
        'bson': 'bson',
        'safe_pickle': 'pkl',
        'pickle': 'pkl',
        'orjson': 'json',
        'ujson': 'json',
    }
    if serializer and (name := serializer.name):
        for k, v in subs.items():
            name = name.replace(k, v)
        return f'.{name}'
    return '.dat'
