"""
File-based cache backend using Python stdlib only.

Stores cached values as serialized files on disk, organized in sharded
directories to avoid filesystem limits. Zero third-party dependencies —
completely BlackDuck safe.

Fixes applied vs naive implementation:
  1. Pluggable serializers  — supports Pickle (default), JSON, Msgpack
                              so any Python object can be cached safely.
  2. Automatic expiry purge — schedule purge_expired() to avoid disk bloat.
  3. Windows write safety   — atomic write via a temp file + os.replace()
                              eliminates PermissionError on concurrent writes.
  4. Windows path safety    — resolves the cache directory to an absolute path
                              early so deeply nested paths don't silently fail.

Directory structure:
    cache_dir/
        ab/
            abcdef1234...       <- serialized value (binary)
            abcdef1234....meta  <- TTL metadata (plain text timestamp)
        cd/
            cdef5678...
"""

from __future__ import annotations

import asyncio
import hashlib
import os
import pathlib
import shutil
import tempfile
import time
from dataclasses import dataclass, field
from typing import Any, Dict, Optional

from cachestack.base import BaseCache, CacheError
from cachestack.serializers import BaseSerializer, PickleSerializer


@dataclass
class FileConfig:
    """Configuration for the file cache backend.

    Attributes:
        directory: Path to the cache directory. Created if it doesn't exist.
        serializer: Serializer for values. Defaults to PickleSerializer so any
                    Python object (datetime, set, custom class) can be cached.
                    Switch to JsonSerializer for human-readable cache files.
        ttl: Default time-to-live in seconds. None means no expiry.
        namespace: Key prefix to avoid collisions between apps sharing a dir.
        silent: If True, suppress errors and return None instead of raising.
        auto_purge_interval: Automatically purge expired files every N seconds
                             during set() calls. None disables this.
    """
    directory: str = "./cachestack_files"
    serializer: BaseSerializer = field(default_factory=PickleSerializer)
    ttl: Optional[int] = None
    namespace: str = "cachestack"
    silent: bool = False
    auto_purge_interval: Optional[int] = None


class FileBackend(BaseCache):
    """
    File-based cache backend using pure Python stdlib.

    Values are serialized (Pickle by default) and stored as binary files in
    sharded directories. TTL is managed via a small .meta sidecar file so
    expiry metadata stays human-readable without polluting the value file.

    Production fixes built in:
      - Pluggable serializers: cache any Python object, not just JSON types
      - Atomic writes via temp file + os.replace() — safe on Windows
      - Namespace isolation: multiple apps can share the same directory
      - purge_expired(): proactively reclaim disk space
      - auto_purge_interval: passive background purge on every N seconds of writes

    Args:
        config: FileConfig instance.

    Example:
        cache = FileBackend(FileConfig(directory="./cache", ttl=3600))
        await cache.set("key", {"data": 123})
        value = await cache.get("key")

        # Cache any Python object — not just JSON types
        import datetime
        await cache.set("ts", datetime.datetime.now())

        # Use as L2 behind memory in a tiered setup
        tiered = TieredCache([MemoryBackend(), FileBackend(FileConfig("./cache"))])
    """

    def __init__(self, config: Optional[FileConfig] = None) -> None:
        self._config = config or FileConfig()
        # FIX 4: resolve to absolute path early to avoid Windows deep-path issues
        self._base = pathlib.Path(self._config.directory).resolve()
        self._base.mkdir(parents=True, exist_ok=True)
        self._hits = 0
        self._misses = 0
        self._errors = 0
        self._write_count = 0
        self._last_purge: float = time.time()

    # ─── Path helpers ─────────────────────────────────────────────────────

    def _data_path(self, key: str) -> pathlib.Path:
        """Sharded path for the serialized value file."""
        namespaced = f"{self._config.namespace}:{key}"
        hashed = hashlib.sha256(namespaced.encode()).hexdigest()
        return self._base / hashed[:2] / hashed

    def _meta_path(self, key: str) -> pathlib.Path:
        """Sidecar .meta file storing TTL expiry as a plain float timestamp."""
        return self._data_path(key).with_suffix(".meta")

    # ─── FIX 3: Atomic write (Windows-safe) ──────────────────────────────

    def _atomic_write(self, path: pathlib.Path, data: bytes) -> None:
        """
        Write bytes atomically using a temp file + os.replace().

        On Windows, os.replace() can raise PermissionError when another thread
        has the target file open at the exact same moment. We retry up to 5
        times with exponential back-off before giving up. This covers all
        realistic concurrent-write scenarios on Windows.
        """
        import time as _time
        path.parent.mkdir(parents=True, exist_ok=True)
        fd, tmp_path = tempfile.mkstemp(dir=str(path.parent))
        try:
            with os.fdopen(fd, "wb") as f:
                f.write(data)
            for attempt in range(5):
                try:
                    os.replace(tmp_path, str(path))
                    return  # success
                except PermissionError:
                    if attempt == 4:
                        raise
                    _time.sleep(0.005 * (2 ** attempt))  # 5ms, 10ms, 20ms, 40ms
        except Exception:
            try:
                os.unlink(tmp_path)
            except OSError:
                pass
            raise

    # ─── Sync internals (offloaded to thread executor) ───────────────────

    def _sync_get(self, key: str) -> Optional[Any]:
        data_path = self._data_path(key)
        meta_path = self._meta_path(key)

        if not data_path.exists():
            self._misses += 1
            return None

        # Check TTL sidecar
        try:
            if meta_path.exists():
                expires_at = float(meta_path.read_text(encoding="utf-8").strip())
                if time.time() > expires_at:
                    data_path.unlink(missing_ok=True)
                    meta_path.unlink(missing_ok=True)
                    self._misses += 1
                    return None
        except Exception:
            pass  # Corrupt meta — still attempt to return value

        try:
            raw = data_path.read_bytes()
            # FIX 1: pluggable serializer — supports any Python object
            value = self._config.serializer.loads(raw)
            self._hits += 1
            return value
        except Exception as e:
            self._errors += 1
            if self._config.silent:
                return None
            raise CacheError(f"FileBackend get failed for key '{key}': {e}") from e

    def _sync_set(self, key: str, value: Any, ttl: Optional[int] = None) -> None:
        data_path = self._data_path(key)
        meta_path = self._meta_path(key)
        effective_ttl = ttl or self._config.ttl

        try:
            # FIX 1: serialize with pluggable serializer
            raw = self._config.serializer.dumps(value)
            # FIX 3: atomic write — no PermissionError on concurrent Windows writes
            self._atomic_write(data_path, raw)

            if effective_ttl:
                expires_bytes = str(time.time() + effective_ttl).encode("utf-8")
                self._atomic_write(meta_path, expires_bytes)
            else:
                meta_path.unlink(missing_ok=True)

        except Exception as e:
            self._errors += 1
            if self._config.silent:
                return
            raise CacheError(f"FileBackend set failed for key '{key}': {e}") from e

        # FIX 2: optional auto-purge — prevents unbounded disk growth
        self._write_count += 1
        interval = self._config.auto_purge_interval
        if interval and (time.time() - self._last_purge) >= interval:
            self._sync_purge_expired()
            self._last_purge = time.time()

    def _sync_delete(self, key: str) -> None:
        self._data_path(key).unlink(missing_ok=True)
        self._meta_path(key).unlink(missing_ok=True)

    def _sync_exists(self, key: str) -> bool:
        return self._sync_get(key) is not None

    def _sync_clear(self) -> None:
        shutil.rmtree(str(self._base), ignore_errors=True)
        self._base.mkdir(parents=True, exist_ok=True)

    def _sync_purge_expired(self) -> int:
        """Scan all .meta files and delete expired data+meta pairs."""
        count = 0
        now = time.time()
        for meta_file in self._base.rglob("*.meta"):
            try:
                expires_at = float(meta_file.read_text(encoding="utf-8").strip())
                if now > expires_at:
                    data_file = meta_file.with_suffix("")
                    data_file.unlink(missing_ok=True)
                    meta_file.unlink(missing_ok=True)
                    count += 1
            except Exception:
                pass
        return count

    def _sync_size(self) -> int:
        """Count data files only — excludes .meta sidecars."""
        return sum(
            1 for f in self._base.rglob("*")
            if f.is_file() and f.suffix != ".meta"
        )

    # ─── Async public API ─────────────────────────────────────────────────

    async def get(self, key: str) -> Optional[Any]:
        """Retrieve a value by key."""
        loop = asyncio.get_event_loop()
        return await loop.run_in_executor(None, self._sync_get, key)

    async def set(self, key: str, value: Any, ttl: Optional[int] = None) -> None:
        """Store a value by key."""
        loop = asyncio.get_event_loop()
        await loop.run_in_executor(None, self._sync_set, key, value, ttl)

    async def delete(self, key: str) -> None:
        """Delete a key and its TTL metadata."""
        loop = asyncio.get_event_loop()
        await loop.run_in_executor(None, self._sync_delete, key)

    async def exists(self, key: str) -> bool:
        """Check if a key exists and is not expired."""
        loop = asyncio.get_event_loop()
        return await loop.run_in_executor(None, self._sync_exists, key)

    async def clear(self) -> None:
        """Delete all cached files and recreate the cache directory."""
        loop = asyncio.get_event_loop()
        await loop.run_in_executor(None, self._sync_clear)

    async def purge_expired(self) -> int:
        """
        Proactively delete all expired cache entries.

        Call this periodically (via a scheduler or cron job) to reclaim
        disk space. Alternatively set auto_purge_interval in FileConfig
        for passive background purging during writes.

        Returns:
            Number of expired entries deleted.
        """
        loop = asyncio.get_event_loop()
        return await loop.run_in_executor(None, self._sync_purge_expired)

    async def stats(self) -> Dict[str, Any]:
        """Return cache statistics."""
        loop = asyncio.get_event_loop()
        size = await loop.run_in_executor(None, self._sync_size)
        return {
            "backend": "file",
            "directory": str(self._base),
            "namespace": self._config.namespace,
            "serializer": type(self._config.serializer).__name__,
            "hits": self._hits,
            "misses": self._misses,
            "errors": self._errors,
            "files_on_disk": size,
            "writes": self._write_count,
        }