# Familiar - Self-Hosted AI Companion Platform
# Copyright (c) 2026 George Scott Foley
# ORCID: 0009-0006-4957-0540
# Email: Georgescottfoley@proton.me
# Licensed under the MIT License - see LICENSE file for details

"""
File-backed TTL cache for API responses.

Prevents hammering free-tier API limits by caching GET responses
to disk with configurable expiry. Thread-safe via file locks.

Usage:
    from familiar.core.api_cache import get_cached, set_cached, cached

    # Direct use
    data = get_cached("usda:apple")
    if data is None:
        data = fetch_from_api(...)
        set_cached("usda:apple", data, ttl=3600)

    # Decorator
    @cached(prefix="usda", ttl=3600)
    def search_foods(query: str) -> dict:
        ...
"""

from __future__ import annotations

import hashlib
import json
import logging
import time
from functools import wraps
from pathlib import Path
from typing import Any, Callable, Optional

logger = logging.getLogger(__name__)


def _cache_dir() -> Path:
    """Get the cache directory, creating it if needed."""
    try:
        from familiar.core.paths import DATA_DIR

        d = DATA_DIR / "cache"
    except ImportError:
        d = Path.home() / ".familiar" / "data" / "cache"
    d.mkdir(parents=True, exist_ok=True)
    return d


def _cache_key_to_path(key: str) -> Path:
    """Convert a cache key to a filesystem path."""
    safe = hashlib.sha256(key.encode()).hexdigest()[:32]
    return _cache_dir() / f"{safe}.json"


def get_cached(key: str) -> Optional[Any]:
    """Retrieve a cached value if it exists and hasn't expired.

    Args:
        key: Cache key (e.g. "usda:search:apple").

    Returns:
        The cached data, or None if missing/expired.
    """
    path = _cache_key_to_path(key)
    if not path.exists():
        return None

    try:
        with open(path, encoding="utf-8") as f:
            entry = json.load(f)
        expires = entry.get("expires", 0)
        if time.time() > expires:
            # Expired — remove stale file
            try:
                path.unlink(missing_ok=True)
            except OSError:
                pass
            return None
        return entry.get("data")
    except (json.JSONDecodeError, IOError, OSError, KeyError):
        return None


def set_cached(key: str, data: Any, ttl: int = 3600) -> None:
    """Store a value in the cache with a TTL.

    Args:
        key: Cache key.
        data: JSON-serializable data to cache.
        ttl: Time-to-live in seconds (default 1 hour).
    """
    path = _cache_key_to_path(key)
    entry = {
        "key": key,
        "data": data,
        "expires": time.time() + ttl,
        "created": time.time(),
    }
    try:
        with open(path, "w", encoding="utf-8") as f:
            json.dump(entry, f)
    except (IOError, OSError, TypeError) as e:
        logger.debug("Cache write failed for %s: %s", key, e)


def invalidate(key: str) -> bool:
    """Remove a specific cache entry.

    Returns:
        True if the entry was removed, False if it didn't exist.
    """
    path = _cache_key_to_path(key)
    if path.exists():
        try:
            path.unlink()
            return True
        except OSError:
            return False
    return False


def clear_expired() -> int:
    """Remove all expired cache entries.

    Returns:
        Number of entries removed.
    """
    removed = 0
    cache_d = _cache_dir()
    if not cache_d.exists():
        return 0

    for p in cache_d.glob("*.json"):
        try:
            with open(p, encoding="utf-8") as f:
                entry = json.load(f)
            if time.time() > entry.get("expires", 0):
                p.unlink(missing_ok=True)
                removed += 1
        except (json.JSONDecodeError, IOError, OSError, KeyError):
            # Corrupt entry — remove it
            try:
                p.unlink(missing_ok=True)
                removed += 1
            except OSError:
                pass
    return removed


def clear_all() -> int:
    """Remove all cache entries.

    Returns:
        Number of entries removed.
    """
    removed = 0
    cache_d = _cache_dir()
    if not cache_d.exists():
        return 0

    for p in cache_d.glob("*.json"):
        try:
            p.unlink()
            removed += 1
        except OSError:
            pass
    return removed


def cached(prefix: str, ttl: int = 3600, key_func: Optional[Callable] = None):
    """Decorator that caches function return values.

    Args:
        prefix: Cache key prefix (e.g. "usda", "spoonacular").
        ttl: Time-to-live in seconds.
        key_func: Optional function to generate cache key from args.
                  Receives (*args, **kwargs) and returns a string.
                  Default: joins all string-coercible args with ":".

    Example:
        @cached(prefix="usda", ttl=3600)
        def search_foods(query: str, page: int = 1) -> dict:
            return httpx.get(...).json()
    """

    def decorator(func: Callable) -> Callable:
        @wraps(func)
        def wrapper(*args, **kwargs):
            if key_func:
                suffix = key_func(*args, **kwargs)
            else:
                parts = [str(a) for a in args] + [f"{k}={v}" for k, v in sorted(kwargs.items())]
                suffix = ":".join(parts)

            cache_key = f"{prefix}:{func.__name__}:{suffix}"

            result = get_cached(cache_key)
            if result is not None:
                logger.debug("Cache hit: %s", cache_key)
                return result

            result = func(*args, **kwargs)
            if result is not None:
                set_cached(cache_key, result, ttl=ttl)
            return result

        # Allow manual cache bypass
        wrapper.cache_prefix = prefix
        wrapper.uncached = func
        return wrapper

    return decorator
