"""Request deduplication and smart caching for LLM providers.

Extends the TTLCache with semantic hashing of prompts for near-duplicate
detection, request coalescing for concurrent identical requests, and
multi-tier cache backend abstraction. Stdlib-only (ADR-021).
"""

from __future__ import annotations

import hashlib
import json
import threading
from dataclasses import dataclass
from typing import Any

from orxaq.kernel.cache import _SENTINEL, TTLCache, get_registry


def _normalize_prompt(prompt: str) -> str:
    """Normalize a prompt for semantic comparison."""
    return " ".join(prompt.lower().split())


def _semantic_hash(prompt: str, model: str | None = None) -> str:
    """Compute a semantic hash of a prompt."""
    normalized = _normalize_prompt(prompt)
    key_data = json.dumps(
        {"prompt": normalized, "model": model or "default"},
        sort_keys=True,
    )
    return hashlib.sha256(key_data.encode()).hexdigest()[:16]


def _exact_hash(
    prompt: str,
    model: str | None,
    max_tokens: int,
    temperature: float,
) -> str:
    """Compute an exact hash including all generation parameters."""
    key_data = json.dumps(
        {
            "prompt": prompt,
            "model": model or "default",
            "max_tokens": max_tokens,
            "temperature": temperature,
        },
        sort_keys=True,
    )
    return hashlib.sha256(key_data.encode()).hexdigest()[:16]


@dataclass
class DeduplicationStats:
    """Statistics for the deduplication layer."""

    exact_hits: int = 0
    semantic_hits: int = 0
    misses: int = 0
    coalesced: int = 0
    total_requests: int = 0
    cache_entries: int = 0

    @property
    def hit_rate(self) -> float:
        if self.total_requests == 0:
            return 0.0
        return (self.exact_hits + self.semantic_hits) / self.total_requests

    @property
    def savings_pct(self) -> float:
        if self.total_requests == 0:
            return 0.0
        avoided = self.exact_hits + self.semantic_hits + self.coalesced
        return (avoided / self.total_requests) * 100

    def to_dict(self) -> dict:
        return {
            "exact_hits": self.exact_hits,
            "semantic_hits": self.semantic_hits,
            "misses": self.misses,
            "coalesced": self.coalesced,
            "total_requests": self.total_requests,
            "cache_entries": self.cache_entries,
            "hit_rate": round(self.hit_rate, 4),
            "savings_pct": round(self.savings_pct, 2),
        }


@dataclass
class CacheConfig:
    """Configuration for the deduplication cache."""

    exact_maxsize: int = 512
    exact_ttl_s: float = 300.0
    semantic_maxsize: int = 256
    semantic_ttl_s: float = 600.0
    enable_semantic: bool = True
    enable_coalescing: bool = True

    def to_dict(self) -> dict:
        return {
            "exact_maxsize": self.exact_maxsize,
            "exact_ttl_s": self.exact_ttl_s,
            "semantic_maxsize": self.semantic_maxsize,
            "semantic_ttl_s": self.semantic_ttl_s,
            "enable_semantic": self.enable_semantic,
            "enable_coalescing": self.enable_coalescing,
        }


class RequestDeduplicator:
    """Smart request deduplication with multi-tier caching.

    Tier 1: Exact match — identical prompt + parameters.
    Tier 2: Semantic match — normalized prompt (whitespace/case insensitive).
    Tier 3: Request coalescing — concurrent identical requests share one call.

    Usage::

        dedup = RequestDeduplicator()
        cached = dedup.get(prompt, model, max_tokens, temperature)
        if cached is not None:
            return cached
        dedup.put(prompt, model, max_tokens, temperature, response)
    """

    def __init__(self, config: CacheConfig | None = None) -> None:
        self._config = config or CacheConfig()
        self._exact_cache = TTLCache(
            maxsize=self._config.exact_maxsize,
            ttl_seconds=self._config.exact_ttl_s,
        )
        self._semantic_cache = TTLCache(
            maxsize=self._config.semantic_maxsize,
            ttl_seconds=self._config.semantic_ttl_s,
        )
        self._stats = DeduplicationStats()
        self._lock = threading.Lock()
        self._inflight: dict[str, threading.Event] = {}
        self._inflight_results: dict[str, Any] = {}

        registry = get_registry()
        registry.register("dedup_exact", self._exact_cache)
        if self._config.enable_semantic:
            registry.register("dedup_semantic", self._semantic_cache)

    def get(
        self,
        prompt: str,
        model: str | None = None,
        max_tokens: int = 1024,
        temperature: float = 0.7,
    ) -> Any | None:
        """Look up a cached response. Returns None on miss."""
        with self._lock:
            self._stats.total_requests += 1

        exact_key = _exact_hash(prompt, model, max_tokens, temperature)
        result = self._exact_cache.get(exact_key)
        if result is not _SENTINEL:
            with self._lock:
                self._stats.exact_hits += 1
            return result

        if self._config.enable_semantic:
            sem_key = _semantic_hash(prompt, model)
            result = self._semantic_cache.get(sem_key)
            if result is not _SENTINEL:
                with self._lock:
                    self._stats.semantic_hits += 1
                return result

        with self._lock:
            self._stats.misses += 1
        return None

    def put(
        self,
        prompt: str,
        model: str | None,
        max_tokens: int,
        temperature: float,
        response: Any,
    ) -> None:
        """Store a response in both cache tiers."""
        exact_key = _exact_hash(prompt, model, max_tokens, temperature)
        self._exact_cache.put(exact_key, response)

        if self._config.enable_semantic:
            sem_key = _semantic_hash(prompt, model)
            self._semantic_cache.put(sem_key, response)

    def try_coalesce(
        self,
        prompt: str,
        model: str | None = None,
        max_tokens: int = 1024,
        temperature: float = 0.7,
        timeout_s: float = 30.0,
    ) -> tuple[bool, Any | None]:
        """Try to coalesce with an in-flight request."""
        if not self._config.enable_coalescing:
            return False, None

        key = _exact_hash(prompt, model, max_tokens, temperature)

        with self._lock:
            if key in self._inflight:
                event = self._inflight[key]
            else:
                self._inflight[key] = threading.Event()
                return False, None

        completed = event.wait(timeout=timeout_s)
        if completed:
            with self._lock:
                self._stats.coalesced += 1
                return True, self._inflight_results.get(key)
        return False, None

    def complete_inflight(
        self,
        prompt: str,
        model: str | None,
        max_tokens: int,
        temperature: float,
        response: Any,
    ) -> None:
        """Mark an in-flight request as complete and notify waiters."""
        key = _exact_hash(prompt, model, max_tokens, temperature)
        with self._lock:
            event = self._inflight.pop(key, None)
            self._inflight_results[key] = response
        if event is not None:
            event.set()

    @property
    def stats(self) -> DeduplicationStats:
        with self._lock:
            self._stats.cache_entries = len(self._exact_cache) + len(self._semantic_cache)
            return DeduplicationStats(
                exact_hits=self._stats.exact_hits,
                semantic_hits=self._stats.semantic_hits,
                misses=self._stats.misses,
                coalesced=self._stats.coalesced,
                total_requests=self._stats.total_requests,
                cache_entries=self._stats.cache_entries,
            )

    def clear(self) -> None:
        """Clear all caches and reset stats."""
        self._exact_cache.clear()
        self._semantic_cache.clear()
        with self._lock:
            self._stats = DeduplicationStats()
            self._inflight.clear()
            self._inflight_results.clear()

    def summary(self) -> dict:
        """Summary for monitoring dashboards."""
        s = self.stats
        return {
            "config": self._config.to_dict(),
            "stats": s.to_dict(),
            "exact_cache": {
                "entries": len(self._exact_cache),
                "hits": self._exact_cache.hits,
                "misses": self._exact_cache.misses,
            },
            "semantic_cache": {
                "entries": len(self._semantic_cache),
                "hits": self._semantic_cache.hits,
                "misses": self._semantic_cache.misses,
            },
        }


__all__ = [
    "CacheConfig",
    "DeduplicationStats",
    "RequestDeduplicator",
]
