"""Redis caching layer for GreyMatter.

Provides a ``PatternCache`` that wraps frequently accessed pattern queries
with a Redis-backed TTL cache.  Falls back gracefully to a no-op cache
when Redis is unavailable, so the system never hard-depends on Redis.

Usage::

    from greymatter_core.cache import RedisCache, NullCache

    cache = RedisCache.connect(redis_url)  # returns NullCache on failure
    value = cache.get("key")
    cache.set("key", value, ttl=300)
    cache.invalidate_pattern("pat-abc123")
"""

from __future__ import annotations

import hashlib
import json
import logging
import time
from typing import Any, Protocol, runtime_checkable

logger = logging.getLogger("greymatter.cache")

# Default TTLs (seconds)
DEFAULT_TTL = 900          # 15 minutes for individual patterns
SESSION_LOAD_TTL = 300     # 5 minutes for session-load results
SEARCH_TTL = 300           # 5 minutes for search/list results
COUNT_TTL = 300            # 5 minutes for count results

# Key prefix — all pattern cache keys live under this namespace
KEY_PREFIX = "gm:pat:"


def _make_key(*parts: str) -> str:
    """Build a namespaced cache key from parts."""
    return KEY_PREFIX + ":".join(str(p) for p in parts)


def _hash_params(**kwargs: Any) -> str:
    """Create a short, deterministic hash of query parameters."""
    raw = json.dumps(kwargs, sort_keys=True, default=str)
    return hashlib.sha256(raw.encode()).hexdigest()[:12]


# ── Protocol ──────────────────────────────────────────────────────────


@runtime_checkable
class Cache(Protocol):
    """Minimal cache protocol.  Both RedisCache and NullCache satisfy it."""

    def get(self, key: str) -> Any | None: ...
    def set(self, key: str, value: Any, ttl: int = DEFAULT_TTL) -> None: ...
    def delete(self, key: str) -> None: ...
    def invalidate_patterns(self) -> None: ...
    def close(self) -> None: ...

    @property
    def enabled(self) -> bool: ...
    @property
    def stats(self) -> dict[str, int]: ...


# ── Null (no-op) implementation ──────────────────────────────────────


class NullCache:
    """No-op cache used when Redis is unavailable.

    Every ``get()`` returns None; every ``set()`` is a no-op.
    This ensures the rest of the codebase never needs to check
    whether caching is enabled — it just uses the cache interface.
    """

    def __init__(self) -> None:
        self._hits = 0
        self._misses = 0

    @property
    def enabled(self) -> bool:
        return False

    @property
    def stats(self) -> dict[str, int]:
        return {"hits": self._hits, "misses": self._misses, "enabled": 0}

    def get(self, key: str) -> Any | None:
        self._misses += 1
        return None

    def set(self, key: str, value: Any, ttl: int = DEFAULT_TTL) -> None:
        pass

    def delete(self, key: str) -> None:
        pass

    def invalidate_patterns(self) -> None:
        pass

    def close(self) -> None:
        pass


# ── Redis implementation ─────────────────────────────────────────────


class RedisCache:
    """Redis-backed cache with JSON serialization and automatic invalidation.

    All values are stored as JSON strings.  Complex types (dicts, lists)
    are serialized/deserialized transparently.

    Pattern invalidation uses ``SCAN`` to find and delete all keys under
    the ``gm:pat:`` prefix.  This is safe because pattern mutations are
    infrequent relative to reads.
    """

    def __init__(self, client: Any) -> None:
        self._client = client
        self._hits = 0
        self._misses = 0

    @classmethod
    def connect(cls, redis_url: str) -> "RedisCache | NullCache":
        """Try to connect to Redis.  Returns NullCache on failure.

        Args:
            redis_url: Redis connection URL (e.g. ``redis://localhost:6379/0``).

        Returns:
            A RedisCache if connection succeeds, NullCache otherwise.
        """
        if not redis_url:
            logger.info("No Redis URL configured; pattern caching disabled")
            return NullCache()

        try:
            import redis

            client = redis.Redis.from_url(
                redis_url,
                decode_responses=True,
                socket_connect_timeout=3,
                socket_timeout=2,
                retry_on_timeout=True,
            )
            # Verify connectivity
            client.ping()
            logger.info("Redis cache connected: %s", redis_url.split("@")[-1])
            return cls(client)
        except ImportError:
            logger.warning("redis package not installed; pattern caching disabled")
            return NullCache()
        except Exception as e:
            logger.warning("Redis connection failed (%s); pattern caching disabled", e)
            return NullCache()

    @property
    def enabled(self) -> bool:
        return True

    @property
    def stats(self) -> dict[str, int]:
        total = self._hits + self._misses
        hit_rate = round(self._hits / total * 100) if total > 0 else 0
        return {
            "hits": self._hits,
            "misses": self._misses,
            "hit_rate_pct": hit_rate,
            "enabled": 1,
        }

    def get(self, key: str) -> Any | None:
        """Retrieve a value from cache.  Returns None on miss or error."""
        try:
            raw = self._client.get(key)
            if raw is None:
                self._misses += 1
                return None
            self._hits += 1
            return json.loads(raw)
        except Exception as e:
            logger.debug("Cache get failed for %s: %s", key, e)
            self._misses += 1
            return None

    def set(self, key: str, value: Any, ttl: int = DEFAULT_TTL) -> None:
        """Store a value in cache with a TTL (seconds)."""
        try:
            raw = json.dumps(value, default=str)
            self._client.setex(key, ttl, raw)
        except Exception as e:
            logger.debug("Cache set failed for %s: %s", key, e)

    def delete(self, key: str) -> None:
        """Delete a single key."""
        try:
            self._client.delete(key)
        except Exception as e:
            logger.debug("Cache delete failed for %s: %s", key, e)

    def invalidate_patterns(self) -> None:
        """Delete all pattern-related cache keys.

        Uses SCAN (non-blocking) rather than KEYS to avoid performance
        issues on large Redis instances.
        """
        try:
            cursor = 0
            deleted = 0
            while True:
                cursor, keys = self._client.scan(
                    cursor=cursor, match=f"{KEY_PREFIX}*", count=100
                )
                if keys:
                    self._client.delete(*keys)
                    deleted += len(keys)
                if cursor == 0:
                    break
            if deleted:
                logger.debug("Invalidated %d pattern cache keys", deleted)
        except Exception as e:
            logger.debug("Cache invalidation failed: %s", e)

    def close(self) -> None:
        """Close the Redis connection."""
        try:
            self._client.close()
        except Exception:
            pass


# ── PatternCache: high-level API for caching pattern queries ─────────


class PatternCache:
    """High-level caching API for PatternManager operations.

    Wraps a ``Cache`` instance with pattern-specific key construction
    and TTL policies.  Mutation methods call ``invalidate()`` to ensure
    stale data is never served.

    Example::

        pc = PatternCache(cache)
        result = pc.get_pattern("pat-abc")
        if result is None:
            result = db_fetch(...)
            pc.set_pattern("pat-abc", result)
    """

    def __init__(self, cache: Cache) -> None:
        self._cache = cache

    @property
    def enabled(self) -> bool:
        return self._cache.enabled

    @property
    def stats(self) -> dict[str, int]:
        return self._cache.stats

    # ── Single pattern ───────────────────────────────────────────────

    def get_pattern(self, pattern_id: str) -> dict | None:
        """Retrieve a cached pattern by ID."""
        return self._cache.get(_make_key("id", pattern_id))

    def set_pattern(self, pattern_id: str, data: dict) -> None:
        """Cache a single pattern."""
        self._cache.set(_make_key("id", pattern_id), data, ttl=DEFAULT_TTL)

    # ── Session load ─────────────────────────────────────────────────

    def get_session_load(self, max_count: int) -> list[dict] | None:
        """Retrieve cached session-load results."""
        return self._cache.get(_make_key("session_load", str(max_count)))

    def set_session_load(self, max_count: int, data: list[dict]) -> None:
        """Cache session-load results."""
        self._cache.set(
            _make_key("session_load", str(max_count)), data, ttl=SESSION_LOAD_TTL
        )

    # ── Search ───────────────────────────────────────────────────────

    def get_search(self, **kwargs: Any) -> list[dict] | None:
        """Retrieve cached search results."""
        h = _hash_params(**kwargs)
        return self._cache.get(_make_key("search", h))

    def set_search(self, data: list[dict], **kwargs: Any) -> None:
        """Cache search results."""
        h = _hash_params(**kwargs)
        self._cache.set(_make_key("search", h), data, ttl=SEARCH_TTL)

    # ── List summary ─────────────────────────────────────────────────

    def get_list_summary(self, limit: int, confidence: str | None) -> dict | None:
        """Retrieve cached list-summary results."""
        h = _hash_params(limit=limit, confidence=confidence)
        return self._cache.get(_make_key("list_summary", h))

    def set_list_summary(
        self, limit: int, confidence: str | None, data: dict
    ) -> None:
        """Cache list-summary results."""
        h = _hash_params(limit=limit, confidence=confidence)
        self._cache.set(_make_key("list_summary", h), data, ttl=SEARCH_TTL)

    # ── Count ────────────────────────────────────────────────────────

    def get_count(self, confidence: str | None) -> int | None:
        """Retrieve cached count."""
        return self._cache.get(_make_key("count", str(confidence)))

    def set_count(self, confidence: str | None, count: int) -> None:
        """Cache a count result."""
        self._cache.set(
            _make_key("count", str(confidence)), count, ttl=COUNT_TTL
        )

    # ── Invalidation ─────────────────────────────────────────────────

    def invalidate(self, pattern_id: str | None = None) -> None:
        """Invalidate pattern cache entries after a mutation.

        Deletes the specific pattern key (if given) and all aggregate
        cache keys (session_load, search, list, count) since any write
        could affect them.
        """
        if pattern_id:
            self._cache.delete(_make_key("id", pattern_id))
        # Wipe all aggregate/collection caches
        self._cache.invalidate_patterns()

    def close(self) -> None:
        """Close the underlying cache connection."""
        self._cache.close()
