"""Job result caching for Oclawma.

This module provides result caching for job execution to avoid reprocessing
identical jobs. It uses SQLite for persistence and supports TTL-based expiration.
"""

from __future__ import annotations

import hashlib
import json
import logging
import sqlite3
import threading
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Callable, TypeVar

logger = logging.getLogger(__name__)

T = TypeVar("T")


@dataclass(frozen=True)
class CacheConfig:
    """Configuration for the result cache.

    Attributes:
        default_ttl: Default time-to-live for cached entries in seconds
        max_size: Maximum number of entries in cache (LRU eviction)
        key_fields: Fields to include in cache key generation (None = all)
        db_path: Path to SQLite database (None = in-memory)
    """

    default_ttl: int = 3600  # 1 hour default
    max_size: int = 10000  # Max entries before LRU eviction
    key_fields: set[str] | None = None  # None = use all fields
    db_path: str | Path | None = None  # None = in-memory


@dataclass(frozen=True)
class CacheKey:
    """A cache key for job results.

    The key is derived from job type and normalized payload to ensure
    that identical jobs produce the same key.
    """

    key_hash: str
    job_type: str

    def __str__(self) -> str:
        return f"{self.job_type}:{self.key_hash}"

    @classmethod
    def from_job(
        cls,
        job_type: str,
        payload: dict[str, Any],
        key_fields: set[str] | None = None,
    ) -> CacheKey:
        """Generate a cache key from job type and payload.

        Args:
            job_type: Type of job
            payload: Job payload data
            key_fields: Specific fields to include in key (None = all)

        Returns:
            CacheKey instance
        """
        # Normalize payload for consistent hashing
        if key_fields is not None:
            # Only include specified fields
            normalized_payload = {k: payload.get(k) for k in key_fields if k in payload}
        else:
            normalized_payload = payload

        # Include job_type in the data to hash
        data_to_hash = {
            "job_type": job_type,
            "payload": normalized_payload,
        }

        # Sort keys for consistent ordering
        normalized = json.dumps(data_to_hash, sort_keys=True, separators=(",", ":"))

        # Create hash
        key_hash = hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:32]

        return cls(key_hash=key_hash, job_type=job_type)


@dataclass
class CacheEntry:
    """A cached result entry.

    Attributes:
        key: Cache key
        result: The cached result
        created_at: When the entry was created
        expires_at: When the entry expires
        access_count: Number of times accessed
        last_accessed: Last access timestamp
    """

    key: CacheKey
    result: Any
    created_at: datetime = field(default_factory=datetime.utcnow)
    expires_at: datetime | None = None
    access_count: int = 0
    last_accessed: datetime | None = None

    def is_expired(self) -> bool:
        """Check if the cache entry has expired."""
        if self.expires_at is None:
            return False
        return datetime.utcnow() > self.expires_at

    def touch(self) -> None:
        """Update access statistics."""
        self.access_count += 1
        self.last_accessed = datetime.utcnow()


@dataclass
class CacheStats:
    """Statistics about the cache.

    Attributes:
        total_entries: Total number of entries in cache
        expired_entries: Number of expired entries
        hits: Cache hits
        misses: Cache misses
        hit_rate: Cache hit rate (0.0 to 1.0)
        size_bytes: Estimated size in bytes
    """

    total_entries: int = 0
    expired_entries: int = 0
    hits: int = 0
    misses: int = 0
    size_bytes: int = 0

    @property
    def hit_rate(self) -> float:
        """Calculate hit rate."""
        total = self.hits + self.misses
        if total == 0:
            return 0.0
        return self.hits / total


class CacheError(Exception):
    """Raised when there's an error with the cache."""

    pass


class ResultCache:
    """SQLite-backed result cache with TTL support.

    This cache stores job results to avoid reprocessing identical jobs.
    It supports TTL-based expiration and LRU eviction when max size is reached.

    Example:
        >>> cache = ResultCache(CacheConfig(default_ttl=3600))
        >>> key = CacheKey.from_job("summarize", {"text": "hello world"})
        >>>
        >>> # Store result
        >>> cache.set(key, {"summary": "hello"})
        >>>
        >>> # Retrieve result
        >>> result = cache.get(key)
        >>> if result is not None:
        ...     print(f"Cached: {result}")
        >>>
        >>> # Or use get_or_execute pattern
        >>> result = cache.get_or_execute(key, lambda: expensive_operation())
    """

    def __init__(self, config: CacheConfig | None = None) -> None:
        """Initialize the result cache.

        Args:
            config: Cache configuration (uses defaults if None)
        """
        self.config = config or CacheConfig()
        self._lock = threading.RLock()
        self._hits = 0
        self._misses = 0

        # Determine database path
        db_path = self.config.db_path or ":memory:"

        # Initialize database
        self._conn = sqlite3.connect(db_path, check_same_thread=False)
        self._conn.row_factory = sqlite3.Row
        self._init_db()

    def _init_db(self) -> None:
        """Initialize the database schema."""
        with self._conn:
            self._conn.execute(
                """
                CREATE TABLE IF NOT EXISTS cache_entries (
                    key_hash TEXT PRIMARY KEY,
                    job_type TEXT NOT NULL,
                    result TEXT NOT NULL,
                    created_at TEXT NOT NULL,
                    expires_at TEXT,
                    access_count INTEGER DEFAULT 0,
                    last_accessed TEXT
                )
            """
            )

            # Index for efficient cleanup
            self._conn.execute(
                """
                CREATE INDEX IF NOT EXISTS idx_expires_at
                ON cache_entries(expires_at)
            """
            )

            # Index for job type queries
            self._conn.execute(
                """
                CREATE INDEX IF NOT EXISTS idx_job_type
                ON cache_entries(job_type)
            """
            )

    def _make_key_string(self, key: CacheKey) -> str:
        """Convert CacheKey to string for database storage."""
        return str(key)

    def get(self, key: CacheKey) -> Any | None:
        """Get a cached result by key.

        Args:
            key: Cache key

        Returns:
            Cached result or None if not found/expired
        """
        with self._lock:
            cursor = self._conn.execute(
                "SELECT * FROM cache_entries WHERE key_hash = ?",
                (key.key_hash,),
            )
            row = cursor.fetchone()

            if row is None:
                self._misses += 1
                return None

            # Check expiration
            expires_at = row["expires_at"]
            if expires_at:
                expires_dt = datetime.fromisoformat(expires_at)
                if datetime.utcnow() > expires_dt:
                    # Expired - delete it
                    self._conn.execute(
                        "DELETE FROM cache_entries WHERE key_hash = ?",
                        (key.key_hash,),
                    )
                    self._conn.commit()
                    self._misses += 1
                    return None

            # Update access stats
            self._conn.execute(
                """UPDATE cache_entries
                   SET access_count = access_count + 1,
                       last_accessed = ?
                   WHERE key_hash = ?""",
                (datetime.utcnow().isoformat(), key.key_hash),
            )
            self._conn.commit()

            self._hits += 1

            # Parse result
            try:
                return json.loads(row["result"])
            except json.JSONDecodeError as e:
                logger.warning(f"Failed to decode cached result: {e}")
                return None

    def set(
        self,
        key: CacheKey,
        result: Any,
        ttl: int | None = None,
    ) -> None:
        """Store a result in the cache.

        Args:
            key: Cache key
            result: Result to cache
            ttl: Time-to-live in seconds (None = use config default)
        """
        with self._lock:
            # Check if we need to evict entries
            self._maybe_evict()

            # Calculate expiration
            effective_ttl = ttl if ttl is not None else self.config.default_ttl
            expires_at = datetime.utcnow() + timedelta(seconds=effective_ttl)

            # Serialize result
            try:
                result_json = json.dumps(result)
            except (TypeError, ValueError) as e:
                raise CacheError(f"Cannot serialize result: {e}") from e

            # Insert or replace
            self._conn.execute(
                """INSERT OR REPLACE INTO cache_entries
                   (key_hash, job_type, result, created_at, expires_at, access_count, last_accessed)
                   VALUES (?, ?, ?, ?, ?, 0, ?)""",
                (
                    key.key_hash,
                    key.job_type,
                    result_json,
                    datetime.utcnow().isoformat(),
                    expires_at.isoformat(),
                    datetime.utcnow().isoformat(),
                ),
            )
            self._conn.commit()

    def _maybe_evict(self) -> None:
        """Evict entries if cache is at max size."""
        cursor = self._conn.execute("SELECT COUNT(*) as count FROM cache_entries")
        count = cursor.fetchone()["count"]

        if count >= self.config.max_size:
            # Evict oldest entries (by last_accessed, then by created_at)
            to_evict = count - self.config.max_size + 1  # Make room for 1 new
            self._conn.execute(
                """DELETE FROM cache_entries
                   WHERE key_hash IN (
                       SELECT key_hash FROM cache_entries
                       ORDER BY COALESCE(last_accessed, created_at) ASC
                       LIMIT ?
                   )""",
                (to_evict,),
            )
            self._conn.commit()
            logger.debug(f"Evicted {to_evict} cache entries")

    def get_or_execute(
        self,
        key: CacheKey,
        executor: Callable[[], T],
        ttl: int | None = None,
    ) -> T:
        """Get cached result or execute and cache the result.

        This is the primary pattern for using the cache. If a cached result
        exists and hasn't expired, it's returned. Otherwise, the executor
        function is called, its result is cached, and then returned.

        Args:
            key: Cache key
            executor: Function to execute if cache miss
            ttl: Optional TTL override

        Returns:
            Cached or freshly computed result

        Example:
            >>> def expensive_operation():
            ...     return sum(range(1000000))
            >>>
            >>> key = CacheKey.from_job("sum", {"n": 1000000})
            >>> result = cache.get_or_execute(key, expensive_operation)
        """
        # First check without lock (fast path)
        cached = self.get(key)
        if cached is not None:
            logger.debug(f"Cache hit for key {key}")
            return cached

        # Cache miss - need to execute, but check again with lock
        with self._lock:
            # Double-check pattern - another thread might have cached it
            cached = self.get(key)
            if cached is not None:
                logger.debug(f"Cache hit for key {key} (after lock)")
                return cached

            logger.debug(f"Cache miss for key {key}, executing...")
            # Execute while holding lock to prevent duplicate execution
            result = executor()

            # Cache the result
            try:
                self.set(key, result, ttl=ttl)
            except CacheError as e:
                logger.warning(f"Failed to cache result: {e}")

            return result

    def invalidate(self, key: CacheKey) -> bool:
        """Invalidate a specific cache entry.

        Args:
            key: Cache key to invalidate

        Returns:
            True if entry was found and removed, False otherwise
        """
        with self._lock:
            cursor = self._conn.execute(
                "DELETE FROM cache_entries WHERE key_hash = ?",
                (key.key_hash,),
            )
            self._conn.commit()
            return cursor.rowcount > 0

    def invalidate_pattern(self, pattern: str) -> int:
        """Invalidate cache entries matching a pattern.

        Args:
            pattern: SQL LIKE pattern to match against key_hash

        Returns:
            Number of entries invalidated

        Example:
            >>> # Invalidate all entries for job type "summarize"
            >>> cache.invalidate_pattern("%:summarize_%")
            >>>
            >>> # Invalidate all entries
            >>> cache.invalidate_pattern("%")
        """
        with self._lock:
            cursor = self._conn.execute(
                "DELETE FROM cache_entries WHERE key_hash LIKE ?",
                (pattern,),
            )
            self._conn.commit()
            return cursor.rowcount

    def invalidate_by_job_type(self, job_type: str) -> int:
        """Invalidate all entries for a specific job type.

        Args:
            job_type: Job type to invalidate

        Returns:
            Number of entries invalidated
        """
        with self._lock:
            cursor = self._conn.execute(
                "DELETE FROM cache_entries WHERE job_type = ?",
                (job_type,),
            )
            self._conn.commit()
            return cursor.rowcount

    def clear(self) -> int:
        """Clear all cache entries.

        Returns:
            Number of entries cleared
        """
        with self._lock:
            cursor = self._conn.execute("DELETE FROM cache_entries")
            self._conn.commit()
            self._hits = 0
            self._misses = 0
            return cursor.rowcount

    def cleanup_expired(self) -> int:
        """Remove all expired entries.

        Returns:
            Number of entries removed
        """
        with self._lock:
            now = datetime.utcnow().isoformat()
            cursor = self._conn.execute(
                "DELETE FROM cache_entries WHERE expires_at < ?",
                (now,),
            )
            self._conn.commit()
            return cursor.rowcount

    def get_stats(self) -> CacheStats:
        """Get cache statistics.

        Returns:
            CacheStats with current metrics
        """
        with self._lock:
            # Count total entries
            cursor = self._conn.execute("SELECT COUNT(*) as count FROM cache_entries")
            total = cursor.fetchone()["count"]

            # Count expired entries
            now = datetime.utcnow().isoformat()
            cursor = self._conn.execute(
                "SELECT COUNT(*) as count FROM cache_entries WHERE expires_at < ?",
                (now,),
            )
            expired = cursor.fetchone()["count"]

            # Estimate size
            cursor = self._conn.execute("SELECT SUM(LENGTH(result)) as total FROM cache_entries")
            row = cursor.fetchone()
            size_bytes = row["total"] or 0

            return CacheStats(
                total_entries=total,
                expired_entries=expired,
                hits=self._hits,
                misses=self._misses,
                size_bytes=size_bytes,
            )

    def get_entry_info(self, key: CacheKey) -> CacheEntry | None:
        """Get detailed information about a cache entry.

        Args:
            key: Cache key

        Returns:
            CacheEntry info or None if not found
        """
        with self._lock:
            cursor = self._conn.execute(
                "SELECT * FROM cache_entries WHERE key_hash = ?",
                (key.key_hash,),
            )
            row = cursor.fetchone()

            if row is None:
                return None

            return CacheEntry(
                key=CacheKey(key_hash=row["key_hash"], job_type=row["job_type"]),
                result=json.loads(row["result"]),
                created_at=datetime.fromisoformat(row["created_at"]),
                expires_at=datetime.fromisoformat(row["expires_at"]) if row["expires_at"] else None,
                access_count=row["access_count"],
                last_accessed=(
                    datetime.fromisoformat(row["last_accessed"]) if row["last_accessed"] else None
                ),
            )

    def list_entries(
        self,
        job_type: str | None = None,
        limit: int = 100,
        include_expired: bool = False,
    ) -> list[CacheEntry]:
        """List cache entries.

        Args:
            job_type: Filter by job type (None = all)
            limit: Maximum entries to return
            include_expired: Whether to include expired entries

        Returns:
            List of cache entries
        """
        with self._lock:
            query = "SELECT * FROM cache_entries WHERE 1=1"
            params: list[Any] = []

            if job_type:
                query += " AND job_type = ?"
                params.append(job_type)

            if not include_expired:
                now = datetime.utcnow().isoformat()
                query += " AND (expires_at IS NULL OR expires_at > ?)"
                params.append(now)

            query += " ORDER BY created_at DESC LIMIT ?"
            params.append(limit)

            cursor = self._conn.execute(query, params)
            rows = cursor.fetchall()

            entries = []
            for row in rows:
                entries.append(
                    CacheEntry(
                        key=CacheKey(key_hash=row["key_hash"], job_type=row["job_type"]),
                        result=json.loads(row["result"]),
                        created_at=datetime.fromisoformat(row["created_at"]),
                        expires_at=(
                            datetime.fromisoformat(row["expires_at"]) if row["expires_at"] else None
                        ),
                        access_count=row["access_count"],
                        last_accessed=(
                            datetime.fromisoformat(row["last_accessed"])
                            if row["last_accessed"]
                            else None
                        ),
                    )
                )

            return entries

    def close(self) -> None:
        """Close the cache and release resources."""
        with self._lock:
            self._conn.close()

    def __enter__(self) -> ResultCache:
        """Context manager entry."""
        return self

    def __exit__(self, *args: Any) -> None:
        """Context manager exit."""
        self.close()

    def __len__(self) -> int:
        """Return number of entries in cache."""
        with self._lock:
            cursor = self._conn.execute("SELECT COUNT(*) as count FROM cache_entries")
            return cursor.fetchone()["count"]
