"""
Cached TTS Adapter

Wraps any TtsAdapter with a disk-based audio cache keyed by
(provider + model + voiceId + speed + text) SHA-256 hash.
Cached PCM audio persists across process restarts, eliminating
redundant TTS API calls for repeated announcements and broadcasts.

Features:
- Disk-based PCM cache (survives restarts/updates)
- SHA-256 cache key
- Warmup / preload API for pre-generating announcement audio pools
- TTL-based expiration (optional)
- Max cache size enforcement (LRU eviction)

Example::

    from dvgateway.adapters.tts import ElevenLabsAdapter, CachedTtsAdapter

    tts = CachedTtsAdapter(
        ElevenLabsAdapter(api_key="..."),
        provider="elevenlabs",
        cache_dir="./tts-cache",
        ttl_ms=7 * 24 * 60 * 60 * 1000,  # 7 days
    )

    # Pre-warm common announcements (done once, reused after restart)
    await tts.warmup([
        {"text": "잠시만 기다려 주세요."},
        {"text": "상담사에게 연결하겠습니다."},
    ])

    # synthesize() returns cached audio if available — no API call
    async for chunk in tts.synthesize("잠시만 기다려 주세요."):
        ...
"""

from __future__ import annotations

import hashlib
import os
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import AsyncIterable, AsyncIterator, TypedDict

from dvgateway.types import TtsAdapter, TtsOptions

# ─── Types ─────────────────────────────────────────────────────────────────

PCM_CHUNK_SIZE = 640  # 20ms at 16kHz, 16-bit = 640 bytes


class WarmupEntry(TypedDict, total=False):
    text: str
    voice_id: str
    speed: float


@dataclass
class CacheStats:
    hits: int = 0
    misses: int = 0
    evictions: int = 0
    total_entries: int = 0


# ─── Implementation ───────────────────────────────────────────────────────


class CachedTtsAdapter(TtsAdapter):
    """Wraps any TtsAdapter with disk-based PCM audio caching."""

    def __init__(
        self,
        inner: TtsAdapter,
        *,
        provider: str,
        cache_dir: str = "./tts-cache",
        default_voice_id: str = "default",
        default_model: str = "default",
        ttl_ms: int = 0,
        max_entries: int = 0,
    ) -> None:
        self._inner = inner
        self._provider = provider
        self._cache_dir = Path(cache_dir)
        self._default_voice_id = default_voice_id
        self._default_model = default_model
        self._ttl_ms = ttl_ms
        self._max_entries = max_entries
        self._stats = CacheStats()
        self._initialized = False

    async def synthesize(  # type: ignore[override]
        self, text: str, options: TtsOptions | None = None
    ) -> AsyncIterable[bytes]:
        """Synthesize text, returning cached audio if available."""
        self._ensure_dir()

        voice_id = (options.voice_id if options and options.voice_id else None) or self._default_voice_id
        speed = (options.speed if options and options.speed else None) or 1.0
        key = self._build_cache_key(text, voice_id, speed)
        file_path = self._cache_dir / f"{key}.pcm"

        # Try cache hit
        cached = self._read_cache(file_path)
        if cached is not None:
            self._stats.hits += 1

            async def _yield_cached() -> AsyncIterator[bytes]:
                offset = 0
                while offset < len(cached):
                    end = min(offset + PCM_CHUNK_SIZE, len(cached))
                    yield cached[offset:end]
                    offset = end

            return _yield_cached()

        # Cache miss — synthesize via inner adapter
        self._stats.misses += 1

        inner_result = self._inner.synthesize(text, options)

        async def _yield_and_cache() -> AsyncIterator[bytes]:
            chunks: list[bytes] = []
            async for chunk in inner_result:
                chunks.append(chunk)
                yield chunk
            # Store in cache
            full = b"".join(chunks)
            self._write_cache(file_path, full)

        return _yield_and_cache()

    async def warmup(self, entries: list[WarmupEntry]) -> int:
        """
        Pre-generate and cache audio for a list of announcements.
        Skips entries that are already cached.

        Returns the number of entries that were newly synthesized.
        """
        self._ensure_dir()
        synthesized = 0

        for entry in entries:
            text = entry["text"]
            voice_id = entry.get("voice_id", self._default_voice_id)
            speed = entry.get("speed", 1.0)
            key = self._build_cache_key(text, voice_id, speed)
            file_path = self._cache_dir / f"{key}.pcm"

            existing = self._read_cache(file_path)
            if existing is not None:
                continue

            # Synthesize and store
            chunks: list[bytes] = []
            options = TtsOptions(voice_id=voice_id, speed=speed)
            async for chunk in self._inner.synthesize(text, options):
                chunks.append(chunk)
            full = b"".join(chunks)
            self._write_cache(file_path, full)
            synthesized += 1

        return synthesized

    async def is_cached(
        self, text: str, voice_id: str | None = None, speed: float | None = None
    ) -> bool:
        """Check if a specific text is already cached."""
        self._ensure_dir()
        key = self._build_cache_key(text, voice_id or self._default_voice_id, speed or 1.0)
        file_path = self._cache_dir / f"{key}.pcm"
        return self._read_cache(file_path) is not None

    async def clear_cache(self) -> int:
        """Clear all cached audio files."""
        self._ensure_dir()
        removed = 0
        for f in self._cache_dir.glob("*.pcm"):
            try:
                f.unlink()
                removed += 1
            except OSError:
                pass
        self._stats.total_entries = 0
        return removed

    def get_stats(self) -> CacheStats:
        """Get cache statistics."""
        return CacheStats(
            hits=self._stats.hits,
            misses=self._stats.misses,
            evictions=self._stats.evictions,
            total_entries=self._stats.total_entries,
        )

    # ─── Private ───────────────────────────────────────────────────────────

    def _build_cache_key(self, text: str, voice_id: str, speed: float) -> str:
        raw = f"{self._provider}|{self._default_model}|{voice_id}|{speed}|{text}"
        return hashlib.sha256(raw.encode()).hexdigest()

    def _ensure_dir(self) -> None:
        if self._initialized:
            return
        self._cache_dir.mkdir(parents=True, exist_ok=True)
        self._initialized = True

    def _read_cache(self, file_path: Path) -> bytes | None:
        try:
            if self._ttl_ms > 0:
                mtime = file_path.stat().st_mtime
                age_ms = (time.time() - mtime) * 1000
                if age_ms > self._ttl_ms:
                    file_path.unlink(missing_ok=True)
                    return None
            return file_path.read_bytes()
        except (OSError, FileNotFoundError):
            return None

    def _write_cache(self, file_path: Path, data: bytes) -> None:
        if self._max_entries > 0:
            self._evict_if_needed()
        try:
            file_path.write_bytes(data)
            self._stats.total_entries += 1
        except OSError:
            pass

    def _evict_if_needed(self) -> None:
        try:
            pcm_files = list(self._cache_dir.glob("*.pcm"))
            if len(pcm_files) < self._max_entries:
                return

            # Sort by modification time (oldest first) for LRU eviction
            pcm_files.sort(key=lambda p: p.stat().st_mtime)

            to_remove = len(pcm_files) - self._max_entries + 1
            for i in range(to_remove):
                try:
                    pcm_files[i].unlink()
                    self._stats.evictions += 1
                    self._stats.total_entries -= 1
                except OSError:
                    pass
        except OSError:
            pass
