"""
Cache subsystem for healed selectors.

Three backends:
  MEMORY  — in-process LRU (cachetools), fastest, ephemeral
  FILE    — JSON on disk, survives restarts, default
  REDIS   — shared across CI workers, requires redis extra

Adaptive TTL:
  Each cache entry tracks a `hit_count` and `miss_count`.  When a selector
  is consistently healed the same way the TTL is extended (stable); when it
  keeps changing the TTL is shortened so stale healings expire quickly.
"""

from __future__ import annotations

import json
import logging
import time
from abc import ABC, abstractmethod
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Dict, Optional

from playwright_healer.config import CacheBackend, CacheConfig

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


@dataclass
class CacheEntry:
    """A single cached healing result."""

    original_selector: str
    healed_selector: str
    description: str
    url_pattern: str           # URL at time of healing (for context)
    stage: str                 # HEURISTIC | DOM_FUZZY | AI
    confidence: float          # 0.0 – 1.0
    created_at: float = field(default_factory=time.time)
    expires_at: float = 0.0    # epoch seconds; 0 = never
    hit_count: int = 0
    miss_count: int = 0        # times healed selector was also broken

    def is_expired(self) -> bool:
        if self.expires_at == 0.0:
            return False
        return time.time() > self.expires_at

    def to_dict(self) -> dict:
        return asdict(self)

    @classmethod
    def from_dict(cls, d: dict) -> "CacheEntry":
        return cls(**{k: v for k, v in d.items() if k in cls.__dataclass_fields__})


class AbstractSelectorCache(ABC):
    """Base class for all cache backends."""

    def __init__(self, cfg: CacheConfig) -> None:
        self._cfg = cfg

    @abstractmethod
    def get(self, key: str) -> Optional[CacheEntry]:
        ...

    @abstractmethod
    def put(self, key: str, entry: CacheEntry) -> None:
        ...

    @abstractmethod
    def invalidate(self, key: str) -> None:
        ...

    @abstractmethod
    def clear(self) -> None:
        ...

    @abstractmethod
    def size(self) -> int:
        ...

    @abstractmethod
    def all_entries(self) -> Dict[str, CacheEntry]:
        ...

    def _cache_key(self, selector: str, description: str) -> str:
        # Normalise whitespace; description is included so the same selector
        # used for different elements heals differently.
        return f"{selector.strip()}|{description.strip().lower()}"

    def record_hit(self, key: str) -> None:
        entry = self.get(key)
        if entry:
            entry.hit_count += 1
            self.put(key, entry)

    def record_miss(self, key: str) -> None:
        """Called when a cached selector was itself broken."""
        entry = self.get(key)
        if entry:
            entry.miss_count += 1
            self.put(key, entry)

    def _ttl_seconds(self, cfg: CacheConfig) -> float:
        return cfg.ttl_hours * 3600.0

    def _adaptive_ttl(self, entry: CacheEntry) -> float:
        """Scale TTL based on hit/miss ratio."""
        cfg = self._cfg
        base = cfg.ttl_hours * 3600.0
        total = entry.hit_count + entry.miss_count
        if total == 0:
            return base
        stability = entry.hit_count / total  # 1.0 = never broken, 0.0 = always broken
        min_ttl = cfg.min_ttl_hours * 3600.0
        max_ttl = cfg.max_ttl_hours * 3600.0
        return min_ttl + stability * (max_ttl - min_ttl)


# ---------------------------------------------------------------------------
# Memory backend
# ---------------------------------------------------------------------------

class MemorySelectorCache(AbstractSelectorCache):
    """In-process LRU cache using cachetools."""

    def __init__(self, cfg: CacheConfig) -> None:
        super().__init__(cfg)
        from cachetools import LRUCache
        self._store: LRUCache = LRUCache(maxsize=cfg.max_size)

    def get(self, key: str) -> Optional[CacheEntry]:
        entry: Optional[CacheEntry] = self._store.get(key)
        if entry and entry.is_expired():
            del self._store[key]
            return None
        return entry

    def put(self, key: str, entry: CacheEntry) -> None:
        self._store[key] = entry

    def invalidate(self, key: str) -> None:
        self._store.pop(key, None)

    def clear(self) -> None:
        self._store.clear()

    def size(self) -> int:
        return len(self._store)

    def all_entries(self) -> Dict[str, CacheEntry]:
        return dict(self._store)


# ---------------------------------------------------------------------------
# File backend
# ---------------------------------------------------------------------------

class FileSelectorCache(AbstractSelectorCache):
    """Persistent JSON file cache with TTL and adaptive TTL support."""

    def __init__(self, cfg: CacheConfig) -> None:
        super().__init__(cfg)
        self._path = Path(cfg.file_path)
        self._store: Dict[str, CacheEntry] = {}
        self._load()

    def _load(self) -> None:
        if self._path.exists():
            try:
                raw: dict = json.loads(self._path.read_text(encoding="utf-8"))
                for k, v in raw.items():
                    self._store[k] = CacheEntry.from_dict(v)
                logger.debug("Loaded %d cache entries from %s", len(self._store), self._path)
            except Exception as exc:
                logger.warning("Could not load cache file %s: %s", self._path, exc)

    def _save(self) -> None:
        try:
            self._path.parent.mkdir(parents=True, exist_ok=True)
            data = {k: v.to_dict() for k, v in self._store.items()}
            self._path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
        except Exception as exc:
            logger.warning("Could not save cache file %s: %s", self._path, exc)

    def get(self, key: str) -> Optional[CacheEntry]:
        entry = self._store.get(key)
        if entry and entry.is_expired():
            del self._store[key]
            self._save()
            return None
        return entry

    def put(self, key: str, entry: CacheEntry) -> None:
        # Enforce max_size with simple FIFO eviction
        if len(self._store) >= self._cfg.max_size and key not in self._store:
            oldest = next(iter(self._store))
            del self._store[oldest]
        self._store[key] = entry
        self._save()

    def invalidate(self, key: str) -> None:
        self._store.pop(key, None)
        self._save()

    def clear(self) -> None:
        self._store.clear()
        self._save()

    def size(self) -> int:
        return len(self._store)

    def all_entries(self) -> Dict[str, CacheEntry]:
        return dict(self._store)


# ---------------------------------------------------------------------------
# Redis backend
# ---------------------------------------------------------------------------

class RedisSelectorCache(AbstractSelectorCache):
    """Shared Redis cache — requires `pip install playwright-healer[redis]`."""

    def __init__(self, cfg: CacheConfig) -> None:
        super().__init__(cfg)
        try:
            import redis  # type: ignore[import]
        except ImportError as exc:
            raise ImportError(
                "Redis cache requires: pip install playwright-healer[redis]"
            ) from exc
        self._client = redis.Redis(
            host=cfg.redis_host,
            port=cfg.redis_port,
            password=cfg.redis_password,
            db=cfg.redis_db,
            decode_responses=True,
        )
        self._prefix = cfg.redis_key_prefix

    def _rkey(self, key: str) -> str:
        return f"{self._prefix}{key}"

    def get(self, key: str) -> Optional[CacheEntry]:
        raw = self._client.get(self._rkey(key))
        if raw is None:
            return None
        try:
            return CacheEntry.from_dict(json.loads(raw))
        except Exception:
            return None

    def put(self, key: str, entry: CacheEntry) -> None:
        ttl = int(self._adaptive_ttl(entry)) if self._cfg.max_ttl_hours else None
        value = json.dumps(entry.to_dict(), ensure_ascii=False)
        if ttl:
            self._client.setex(self._rkey(key), ttl, value)
        else:
            self._client.set(self._rkey(key), value)

    def invalidate(self, key: str) -> None:
        self._client.delete(self._rkey(key))

    def clear(self) -> None:
        keys = self._client.keys(f"{self._prefix}*")
        if keys:
            self._client.delete(*keys)

    def size(self) -> int:
        return len(self._client.keys(f"{self._prefix}*"))

    def all_entries(self) -> Dict[str, CacheEntry]:
        result: Dict[str, CacheEntry] = {}
        for k in self._client.keys(f"{self._prefix}*"):
            raw = self._client.get(k)
            if raw:
                try:
                    entry = CacheEntry.from_dict(json.loads(raw))
                    result[k[len(self._prefix):]] = entry
                except Exception:
                    pass
        return result


# ---------------------------------------------------------------------------
# Factory
# ---------------------------------------------------------------------------

def build_cache(cfg: CacheConfig) -> AbstractSelectorCache:
    """Create the appropriate cache backend from configuration."""
    if cfg.backend == CacheBackend.MEMORY:
        return MemorySelectorCache(cfg)
    if cfg.backend == CacheBackend.REDIS:
        return RedisSelectorCache(cfg)
    return FileSelectorCache(cfg)
