from __future__ import annotations

import base64
import hashlib
import json
import os
from dataclasses import dataclass
from pathlib import Path
import tempfile
from threading import Lock

import httpx


def _bool_env(name: str, default: bool) -> bool:
    raw = os.getenv(name)
    if raw is None:
        return default
    return raw.strip().lower() in {"1", "true", "yes", "on"}


def _api_base() -> str:
    return (os.getenv("AI_API_BASE", "https://api.openai.com/v1").strip() or "https://api.openai.com/v1").rstrip("/")


def _api_key() -> str:
    return os.getenv("AI_API_KEY", "").strip()


def _timeout_seconds() -> float:
    raw = os.getenv("AI_TIMEOUT_SECONDS", "20").strip()
    try:
        value = float(raw)
    except ValueError:
        value = 20.0
    return max(value, 3.0)


def _openai_tts_model() -> str:
    return os.getenv("AI_VOICE_TTS_MODEL", "gpt-4o-mini-tts").strip() or "gpt-4o-mini-tts"


def _openai_tts_voice() -> str:
    return os.getenv("AI_VOICE_TTS_VOICE", "alloy").strip() or "alloy"


def _openai_tts_speed() -> float:
    raw = os.getenv("AI_VOICE_TTS_OPENAI_SPEED", "1.1").strip()
    try:
        value = float(raw)
    except ValueError:
        value = 1.1
    return max(0.25, min(value, 4.0))


def _yandex_api_base() -> str:
    return (
        os.getenv("AI_VOICE_TTS_YANDEX_API_BASE", "https://tts.api.ml.yandexcloud.kz").strip()
        or "https://tts.api.ml.yandexcloud.kz"
    ).rstrip("/")


def _yandex_api_key() -> str:
    return os.getenv("AI_VOICE_TTS_YANDEX_API_KEY", "").strip()


def _yandex_iam_token() -> str:
    return os.getenv("AI_VOICE_TTS_YANDEX_IAM_TOKEN", "").strip()


def _yandex_folder_id() -> str:
    return os.getenv("AI_VOICE_TTS_YANDEX_FOLDER_ID", "").strip()


def _yandex_default_language() -> str:
    return os.getenv("AI_VOICE_TTS_YANDEX_LANGUAGE", "ru-RU").strip() or "ru-RU"


def _normalize_yandex_language(language: str | None) -> str:
    raw = str(language or "").strip()
    if not raw:
        return _yandex_default_language()
    lowered = raw.lower()
    if lowered == "ru":
        return "ru-RU"
    if lowered in {"kk", "kz"}:
        return "kk-KZ"
    return raw


def _yandex_voice_for_language(language: str | None) -> str:
    normalized = _normalize_yandex_language(language).lower()
    if normalized.startswith("kk"):
        return os.getenv("AI_VOICE_TTS_YANDEX_KK_VOICE", "amira").strip() or "amira"
    return os.getenv("AI_VOICE_TTS_YANDEX_VOICE", "jane").strip() or "jane"


def _yandex_role() -> str:
    explicit = os.getenv("AI_VOICE_TTS_YANDEX_ROLE", "").strip()
    if explicit:
        return explicit
    # Backward compatibility with the old v1-style env.
    return os.getenv("AI_VOICE_TTS_YANDEX_EMOTION", "").strip()


def _yandex_speed() -> str:
    raw = os.getenv("AI_VOICE_TTS_YANDEX_SPEED", "1.1").strip()
    try:
        value = float(raw)
    except ValueError:
        value = 1.1
    return f"{max(0.1, min(value, 3.0)):g}"


def _yandex_sample_rate_hz() -> int:
    raw = os.getenv("AI_VOICE_TTS_YANDEX_SAMPLE_RATE_HZ", "8000").strip()
    try:
        value = int(raw)
    except ValueError:
        value = 8000
    if value < 8000 or value > 48000:
        value = 8000
    return value


def _normalize_voice_language(language: str | None) -> str:
    lowered = str(language or "").strip().lower()
    if lowered in {"kz", "kk", "kk-kz", "kk_kz"}:
        return "kz"
    return "ru"


def _elevenlabs_api_base() -> str:
    return (os.getenv("AI_VOICE_TTS_ELEVENLABS_API_BASE", "https://api.elevenlabs.io").strip() or "https://api.elevenlabs.io").rstrip("/")


def _elevenlabs_api_key() -> str:
    return os.getenv("AI_VOICE_TTS_ELEVENLABS_API_KEY", "").strip()


def _elevenlabs_model_id() -> str:
    return os.getenv("AI_VOICE_TTS_ELEVENLABS_MODEL_ID", "eleven_v3").strip() or "eleven_v3"


def _elevenlabs_output_format() -> str:
    return os.getenv("AI_VOICE_TTS_ELEVENLABS_OUTPUT_FORMAT", "pcm_16000").strip() or "pcm_16000"


def _elevenlabs_voice_for_language(language: str | None) -> str:
    normalized = _normalize_voice_language(language)
    if normalized == "kz":
        return os.getenv("AI_VOICE_TTS_ELEVENLABS_KK_VOICE_ID", "nPczCjzI2devNBz1zQrb").strip() or "nPczCjzI2devNBz1zQrb"
    return os.getenv("AI_VOICE_TTS_ELEVENLABS_RU_VOICE_ID", "nPczCjzI2devNBz1zQrb").strip() or "nPczCjzI2devNBz1zQrb"


def _elevenlabs_language_code_for_language(language: str | None) -> str:
    normalized = _normalize_voice_language(language)
    if normalized == "kz":
        return os.getenv("AI_VOICE_TTS_ELEVENLABS_KK_LANGUAGE_CODE", "kk").strip() or "kk"
    return os.getenv("AI_VOICE_TTS_ELEVENLABS_RU_LANGUAGE_CODE", "ru").strip() or "ru"


def _elevenlabs_sample_rate_hz(output_format: str | None = None) -> int:
    normalized = str(output_format or _elevenlabs_output_format()).strip().lower()
    for prefix in ("pcm_", "ulaw_"):
        if normalized.startswith(prefix):
            suffix = normalized.split("_", 1)[1]
            try:
                return max(int(suffix), 8000)
            except ValueError:
                break
    return 16000


def _tts_cache_enabled() -> bool:
    return _bool_env("AI_VOICE_TTS_CACHE_ENABLED", True)


def _tts_cache_dir() -> Path:
    explicit = os.getenv("AI_VOICE_TTS_CACHE_DIR", "").strip()
    if explicit:
        return Path(explicit).expanduser()
    data_dir = os.getenv("CC_DATA_DIR", "").strip()
    if data_dir:
        return Path(data_dir).expanduser() / "ai_voice_tts_cache"
    local_data_dir = Path(".data_local")
    if local_data_dir.exists():
        return local_data_dir / "ai_voice_tts_cache"
    return Path(".data") / "ai_voice_tts_cache"


@dataclass(slots=True)
class TTSSynthesis:
    text: str
    audio_bytes: bytes = b""
    sample_rate_hz: int = 24000


class TTSProvider:
    name = "stub"

    def cache_fingerprint(self, language: str | None, *, style_hints: dict[str, object] | None = None) -> str:
        """Identifies the exact voice/model config that would render `language`.

        Callers that cache synthesized audio keyed only on (provider, language,
        text) — like the prebaked ack bank — must fold this in too, or a voice
        change silently keeps serving audio recorded with the old voice.
        """
        del language, style_hints
        return ""

    def synthesize(
        self,
        text: str,
        *,
        language: str | None = None,
        style_hints: dict[str, object] | None = None,
    ) -> TTSSynthesis:
        del language, style_hints
        return TTSSynthesis(text=text, audio_bytes=b"", sample_rate_hz=8000)

    def synthesize_chunks(
        self,
        text: str,
        *,
        language: str | None = None,
        style_hints: dict[str, object] | None = None,
    ):
        synthesis = self.synthesize(text, language=language, style_hints=style_hints)
        if synthesis.audio_bytes:
            yield synthesis


class OpenAITTSProvider(TTSProvider):
    name = "openai"

    def __init__(
        self,
        *,
        api_base: str | None = None,
        api_key: str | None = None,
        timeout_seconds: float | None = None,
        ru_model: str | None = None,
        kz_model: str | None = None,
        ru_voice: str | None = None,
        kz_voice: str | None = None,
        speed: float | None = None,
        cache_enabled: bool | None = None,
        cache_dir: Path | None = None,
    ) -> None:
        self._api_base = str(api_base or _api_base()).strip().rstrip("/")
        self._api_key = str(api_key if api_key is not None else _api_key()).strip()
        self._timeout_seconds = max(float(timeout_seconds if timeout_seconds is not None else _timeout_seconds()), 3.0)
        self._ru_model = str(ru_model or _openai_tts_model()).strip() or _openai_tts_model()
        self._kz_model = str(kz_model or self._ru_model).strip() or self._ru_model
        self._ru_voice = str(ru_voice or _openai_tts_voice()).strip() or _openai_tts_voice()
        self._kz_voice = str(kz_voice or self._ru_voice).strip() or self._ru_voice
        configured_speed = float(speed if speed is not None else _openai_tts_speed())
        self._speed = max(0.25, min(configured_speed, 4.0))
        self._cache_enabled = _tts_cache_enabled() if cache_enabled is None else bool(cache_enabled)
        self._cache_dir = cache_dir or _tts_cache_dir()
        self._cache_lock = Lock()

    def _model(self, language: str | None) -> str:
        return self._kz_model if _normalize_voice_language(language) == "kz" else self._ru_model

    def _voice(self, language: str | None) -> str:
        return self._kz_voice if _normalize_voice_language(language) == "kz" else self._ru_voice

    def cache_fingerprint(self, language: str | None, *, style_hints: dict[str, object] | None = None) -> str:
        del style_hints
        return f"{self._voice(language)}:{self._model(language)}:{self._speed}"

    def _cache_key(self, text: str, *, language: str | None) -> str:
        payload = {
            "provider": self.name,
            "model": self._model(language),
            "voice": self._voice(language),
            "speed": self._speed,
            "language": str(language or "").strip() or None,
            "text": text,
        }
        encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
        return hashlib.sha256(encoded).hexdigest()

    def _cache_paths(self, cache_key: str) -> tuple[Path, Path]:
        prefix = self._cache_dir / cache_key[:2] / cache_key[2:4]
        return prefix / f"{cache_key}.pcm", prefix / f"{cache_key}.json"

    def _load_cached_synthesis(self, text: str, *, language: str | None) -> TTSSynthesis | None:
        if not self._cache_enabled:
            return None
        pcm_path, meta_path = self._cache_paths(self._cache_key(text, language=language))
        if not pcm_path.exists():
            return None

        try:
            audio_bytes = pcm_path.read_bytes()
            sample_rate_hz = 24000
            if meta_path.exists():
                metadata = json.loads(meta_path.read_text(encoding="utf-8"))
                sample_rate_hz = max(int(metadata.get("sample_rate_hz") or 24000), 1)
        except (OSError, ValueError, TypeError, json.JSONDecodeError):
            return None

        return TTSSynthesis(text=text, audio_bytes=audio_bytes, sample_rate_hz=sample_rate_hz)

    def _write_cached_synthesis(self, synthesis: TTSSynthesis, *, language: str | None) -> None:
        if not self._cache_enabled or not synthesis.audio_bytes:
            return

        pcm_path, meta_path = self._cache_paths(self._cache_key(synthesis.text, language=language))
        pcm_path.parent.mkdir(parents=True, exist_ok=True)
        metadata = {
            "provider": self.name,
            "model": self._model(language),
            "voice": self._voice(language),
            "speed": self._speed,
            "language": str(language or "").strip() or None,
            "sample_rate_hz": synthesis.sample_rate_hz,
            "text": synthesis.text,
        }

        pcm_tmp: str | None = None
        meta_tmp: str | None = None
        try:
            with tempfile.NamedTemporaryFile(dir=pcm_path.parent, delete=False, suffix=".pcm.tmp") as handle:
                handle.write(synthesis.audio_bytes)
                pcm_tmp = handle.name
            with tempfile.NamedTemporaryFile(dir=meta_path.parent, delete=False, suffix=".json.tmp", mode="w", encoding="utf-8") as handle:
                json.dump(metadata, handle, ensure_ascii=False, sort_keys=True)
                meta_tmp = handle.name
            os.replace(pcm_tmp, pcm_path)
            os.replace(meta_tmp, meta_path)
        finally:
            for temp_path in (pcm_tmp, meta_tmp):
                if not temp_path:
                    continue
                try:
                    if os.path.exists(temp_path):
                        os.remove(temp_path)
                except OSError:
                    pass

    def synthesize(
        self,
        text: str,
        *,
        language: str | None = None,
        style_hints: dict[str, object] | None = None,
    ) -> TTSSynthesis:
        del style_hints
        if not text:
            return TTSSynthesis(text=text, audio_bytes=b"", sample_rate_hz=24000)
        cached = self._load_cached_synthesis(text, language=language)
        if cached is not None:
            return cached
        if not self._api_key:
            raise RuntimeError("AI_API_KEY is required for OpenAI TTS")

        with httpx.Client(timeout=self._timeout_seconds) as client:
            response = client.post(
                f"{self._api_base}/audio/speech",
                headers={
                    "Authorization": f"Bearer {self._api_key}",
                    "Accept": "application/octet-stream",
                },
                json={
                    "model": self._model(language),
                    "voice": self._voice(language),
                    "speed": self._speed,
                    "input": text,
                    "response_format": "pcm",
                },
            )
        response.raise_for_status()
        synthesis = TTSSynthesis(text=text, audio_bytes=response.content, sample_rate_hz=24000)
        with self._cache_lock:
            cached = self._load_cached_synthesis(text, language=language)
            if cached is not None:
                return cached
            self._write_cached_synthesis(synthesis, language=language)
        return synthesis


class YandexTTSProvider(TTSProvider):
    name = "yandex"

    def __init__(
        self,
        *,
        api_base: str | None = None,
        api_key: str | None = None,
        iam_token: str | None = None,
        folder_id: str | None = None,
        timeout_seconds: float | None = None,
        ru_voice: str | None = None,
        kz_voice: str | None = None,
        speed: str | float | None = None,
        role: str | None = None,
        sample_rate_hz: int | None = None,
        cache_enabled: bool | None = None,
        cache_dir: Path | None = None,
    ) -> None:
        self._api_base = str(api_base or _yandex_api_base()).strip().rstrip("/")
        self._api_key = str(api_key if api_key is not None else _yandex_api_key()).strip()
        self._iam_token = str(iam_token if iam_token is not None else _yandex_iam_token()).strip()
        self._folder_id = str(folder_id if folder_id is not None else _yandex_folder_id()).strip()
        self._timeout_seconds = max(float(timeout_seconds if timeout_seconds is not None else _timeout_seconds()), 3.0)
        if speed is None:
            self._speed = _yandex_speed()
        else:
            try:
                speed_value = float(speed)
            except (TypeError, ValueError):
                speed_value = float(_yandex_speed())
            self._speed = f"{max(0.1, min(speed_value, 3.0)):g}"
        self._role = str(role if role is not None else _yandex_role()).strip()
        resolved_sample_rate = int(sample_rate_hz if sample_rate_hz is not None else _yandex_sample_rate_hz())
        self._sample_rate_hz = max(min(resolved_sample_rate, 48000), 8000)
        self._cache_enabled = _tts_cache_enabled() if cache_enabled is None else bool(cache_enabled)
        self._cache_dir = cache_dir or _tts_cache_dir()
        self._ru_voice = str(ru_voice or _yandex_voice_for_language("ru")).strip() or _yandex_voice_for_language("ru")
        self._kz_voice = str(kz_voice or _yandex_voice_for_language("kz")).strip() or _yandex_voice_for_language("kz")
        self._cache_lock = Lock()

    def _voice(self, language: str | None) -> str:
        return self._kz_voice if _normalize_voice_language(language) == "kz" else self._ru_voice

    def _lang(self, language: str | None) -> str:
        return _normalize_yandex_language(language)

    @staticmethod
    def _normalize_style_hints(style_hints: dict[str, object] | None) -> dict[str, object]:
        if not isinstance(style_hints, dict):
            return {}
        normalized: dict[str, object] = {}
        role = str(style_hints.get("role") or "").strip()
        if role:
            normalized["role"] = role
        return normalized

    def _effective_role(self, *, style_hints: dict[str, object] | None = None) -> str:
        normalized_hints = self._normalize_style_hints(style_hints)
        explicit_role = str(normalized_hints.get("role") or "").strip()
        if explicit_role:
            return explicit_role
        return self._role

    def cache_fingerprint(self, language: str | None, *, style_hints: dict[str, object] | None = None) -> str:
        return (
            f"{self._voice(language)}:{self._effective_role(style_hints=style_hints)}:"
            f"{self._speed}:{self._sample_rate_hz}"
        )

    def _cache_key(self, text: str, *, language: str | None, style_hints: dict[str, object] | None = None) -> str:
        payload = {
            "provider": self.name,
            "voice": self._voice(language),
            "language": self._lang(language),
            "role": self._effective_role(style_hints=style_hints) or None,
            "speed": self._speed,
            "sample_rate_hz": self._sample_rate_hz,
            "text": text,
        }
        encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
        return hashlib.sha256(encoded).hexdigest()

    def _cache_paths(self, cache_key: str) -> tuple[Path, Path]:
        prefix = self._cache_dir / cache_key[:2] / cache_key[2:4]
        return prefix / f"{cache_key}.pcm", prefix / f"{cache_key}.json"

    def _load_cached_synthesis(
        self,
        text: str,
        *,
        language: str | None,
        style_hints: dict[str, object] | None = None,
    ) -> TTSSynthesis | None:
        if not self._cache_enabled:
            return None
        pcm_path, meta_path = self._cache_paths(self._cache_key(text, language=language, style_hints=style_hints))
        if not pcm_path.exists():
            return None

        try:
            audio_bytes = pcm_path.read_bytes()
            sample_rate_hz = self._sample_rate_hz
            if meta_path.exists():
                metadata = json.loads(meta_path.read_text(encoding="utf-8"))
                sample_rate_hz = max(int(metadata.get("sample_rate_hz") or self._sample_rate_hz), 1)
        except (OSError, ValueError, TypeError, json.JSONDecodeError):
            return None

        return TTSSynthesis(text=text, audio_bytes=audio_bytes, sample_rate_hz=sample_rate_hz)

    def _write_cached_synthesis(
        self,
        synthesis: TTSSynthesis,
        *,
        language: str | None,
        style_hints: dict[str, object] | None = None,
    ) -> None:
        if not self._cache_enabled or not synthesis.audio_bytes:
            return

        pcm_path, meta_path = self._cache_paths(
            self._cache_key(synthesis.text, language=language, style_hints=style_hints)
        )
        pcm_path.parent.mkdir(parents=True, exist_ok=True)
        metadata = {
            "provider": self.name,
            "voice": self._voice(language),
            "language": self._lang(language),
            "role": self._effective_role(style_hints=style_hints) or None,
            "speed": self._speed,
            "sample_rate_hz": synthesis.sample_rate_hz,
            "text": synthesis.text,
        }

        pcm_tmp: str | None = None
        meta_tmp: str | None = None
        try:
            with tempfile.NamedTemporaryFile(dir=pcm_path.parent, delete=False, suffix=".pcm.tmp") as handle:
                handle.write(synthesis.audio_bytes)
                pcm_tmp = handle.name
            with tempfile.NamedTemporaryFile(dir=meta_path.parent, delete=False, suffix=".json.tmp", mode="w", encoding="utf-8") as handle:
                json.dump(metadata, handle, ensure_ascii=False, sort_keys=True)
                meta_tmp = handle.name
            os.replace(pcm_tmp, pcm_path)
            os.replace(meta_tmp, meta_path)
        finally:
            for temp_path in (pcm_tmp, meta_tmp):
                if not temp_path:
                    continue
                try:
                    if os.path.exists(temp_path):
                        os.remove(temp_path)
                except OSError:
                    pass

    def synthesize(
        self,
        text: str,
        *,
        language: str | None = None,
        style_hints: dict[str, object] | None = None,
    ) -> TTSSynthesis:
        if not text:
            return TTSSynthesis(text=text, audio_bytes=b"", sample_rate_hz=self._sample_rate_hz)
        cached = self._load_cached_synthesis(text, language=language, style_hints=style_hints)
        if cached is not None:
            return cached
        if not self._api_key and not self._iam_token:
            raise RuntimeError(
                "AI_VOICE_TTS_YANDEX_API_KEY or AI_VOICE_TTS_YANDEX_IAM_TOKEN is required for Yandex TTS"
            )

        headers = {"Accept": "application/json"}
        if self._api_key:
            headers["Authorization"] = f"Api-Key {self._api_key}"
        else:
            headers["Authorization"] = f"Bearer {self._iam_token}"
        if self._folder_id:
            headers["x-folder-id"] = self._folder_id

        hints: list[dict[str, object]] = [
            {"voice": self._voice(language)},
            {"speed": float(self._speed)},
        ]
        effective_role = self._effective_role(style_hints=style_hints)
        if effective_role:
            hints.append({"role": effective_role})

        payload = {
            "text": text,
            "hints": hints,
            "outputAudioSpec": {
                "rawAudio": {
                    "audioEncoding": "LINEAR16_PCM",
                    "sampleRateHertz": self._sample_rate_hz,
                }
            },
            "loudnessNormalizationType": "LUFS",
        }

        with httpx.Client(timeout=self._timeout_seconds) as client:
            response = client.post(
                f"{self._api_base}/tts/v3/utteranceSynthesis",
                headers=headers,
                json=payload,
            )
        response.raise_for_status()
        response_payload = response.json()
        result = response_payload.get("result")
        if isinstance(result, dict):
            response_payload = result
        audio_chunk = response_payload.get("audioChunk")
        if not isinstance(audio_chunk, dict):
            raise RuntimeError("Yandex TTS response is missing audioChunk")
        audio_data = audio_chunk.get("data")
        if not isinstance(audio_data, str) or not audio_data:
            raise RuntimeError("Yandex TTS response is missing audioChunk.data")
        try:
            audio_bytes = base64.b64decode(audio_data)
        except (ValueError, TypeError) as exc:
            raise RuntimeError("Yandex TTS returned invalid base64 audio data") from exc
        synthesis = TTSSynthesis(text=text, audio_bytes=audio_bytes, sample_rate_hz=self._sample_rate_hz)
        with self._cache_lock:
            cached = self._load_cached_synthesis(text, language=language, style_hints=style_hints)
            if cached is not None:
                return cached
            self._write_cached_synthesis(synthesis, language=language, style_hints=style_hints)
        return synthesis


class ElevenLabsTTSProvider(TTSProvider):
    name = "elevenlabs"

    def __init__(
        self,
        *,
        api_base: str | None = None,
        api_key: str | None = None,
        timeout_seconds: float | None = None,
        ru_voice: str | None = None,
        kz_voice: str | None = None,
        ru_model: str | None = None,
        kz_model: str | None = None,
        ru_language_code: str | None = None,
        kz_language_code: str | None = None,
        output_format: str | None = None,
        cache_enabled: bool | None = None,
        cache_dir: Path | None = None,
    ) -> None:
        self._api_base = str(api_base or _elevenlabs_api_base()).strip().rstrip("/")
        self._api_key = str(api_key if api_key is not None else _elevenlabs_api_key()).strip()
        self._timeout_seconds = max(float(timeout_seconds if timeout_seconds is not None else _timeout_seconds()), 3.0)
        self._ru_voice = str(ru_voice or _elevenlabs_voice_for_language("ru")).strip() or _elevenlabs_voice_for_language("ru")
        self._kz_voice = str(kz_voice or _elevenlabs_voice_for_language("kz")).strip() or _elevenlabs_voice_for_language("kz")
        self._ru_model = str(ru_model or _elevenlabs_model_id()).strip() or _elevenlabs_model_id()
        self._kz_model = str(kz_model or self._ru_model).strip() or self._ru_model
        self._ru_language_code = (
            str(ru_language_code or _elevenlabs_language_code_for_language("ru")).strip()
            or _elevenlabs_language_code_for_language("ru")
        )
        self._kz_language_code = (
            str(kz_language_code or _elevenlabs_language_code_for_language("kz")).strip()
            or _elevenlabs_language_code_for_language("kz")
        )
        self._output_format = str(output_format or _elevenlabs_output_format()).strip() or _elevenlabs_output_format()
        self._sample_rate_hz = _elevenlabs_sample_rate_hz(self._output_format)
        self._cache_enabled = _tts_cache_enabled() if cache_enabled is None else bool(cache_enabled)
        self._cache_dir = cache_dir or _tts_cache_dir()
        self._cache_lock = Lock()

    def _voice(self, language: str | None) -> str:
        return self._kz_voice if _normalize_voice_language(language) == "kz" else self._ru_voice

    def _model(self, language: str | None) -> str:
        return self._kz_model if _normalize_voice_language(language) == "kz" else self._ru_model

    def _language_code(self, language: str | None) -> str:
        return self._kz_language_code if _normalize_voice_language(language) == "kz" else self._ru_language_code

    def cache_fingerprint(self, language: str | None, *, style_hints: dict[str, object] | None = None) -> str:
        del style_hints
        return (
            f"{self._voice(language)}:{self._model(language)}:"
            f"{self._language_code(language)}:{self._output_format}"
        )

    def _cache_key(
        self,
        text: str,
        *,
        language: str | None,
        style_hints: dict[str, object] | None = None,
    ) -> str:
        del style_hints
        payload = {
            "provider": self.name,
            "voice": self._voice(language),
            "model": self._model(language),
            "language_code": self._language_code(language),
            "output_format": self._output_format,
            "text": text,
        }
        encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
        return hashlib.sha256(encoded).hexdigest()

    def _cache_paths(self, cache_key: str) -> tuple[Path, Path]:
        prefix = self._cache_dir / cache_key[:2] / cache_key[2:4]
        return prefix / f"{cache_key}.pcm", prefix / f"{cache_key}.json"

    def _load_cached_synthesis(
        self,
        text: str,
        *,
        language: str | None,
        style_hints: dict[str, object] | None = None,
    ) -> TTSSynthesis | None:
        if not self._cache_enabled:
            return None
        pcm_path, meta_path = self._cache_paths(self._cache_key(text, language=language, style_hints=style_hints))
        if not pcm_path.exists():
            return None
        try:
            audio_bytes = pcm_path.read_bytes()
            sample_rate_hz = self._sample_rate_hz
            if meta_path.exists():
                metadata = json.loads(meta_path.read_text(encoding="utf-8"))
                sample_rate_hz = max(int(metadata.get("sample_rate_hz") or self._sample_rate_hz), 1)
        except (OSError, ValueError, TypeError, json.JSONDecodeError):
            return None
        return TTSSynthesis(text=text, audio_bytes=audio_bytes, sample_rate_hz=sample_rate_hz)

    def _write_cached_synthesis(
        self,
        synthesis: TTSSynthesis,
        *,
        language: str | None,
        style_hints: dict[str, object] | None = None,
    ) -> None:
        if not self._cache_enabled or not synthesis.audio_bytes:
            return
        pcm_path, meta_path = self._cache_paths(
            self._cache_key(synthesis.text, language=language, style_hints=style_hints)
        )
        pcm_path.parent.mkdir(parents=True, exist_ok=True)
        metadata = {
            "provider": self.name,
            "voice": self._voice(language),
            "model": self._model(language),
            "language_code": self._language_code(language),
            "output_format": self._output_format,
            "sample_rate_hz": synthesis.sample_rate_hz,
            "text": synthesis.text,
        }
        pcm_tmp: str | None = None
        meta_tmp: str | None = None
        try:
            with tempfile.NamedTemporaryFile(dir=pcm_path.parent, delete=False, suffix=".pcm.tmp") as handle:
                handle.write(synthesis.audio_bytes)
                pcm_tmp = handle.name
            with tempfile.NamedTemporaryFile(
                dir=meta_path.parent,
                delete=False,
                suffix=".json.tmp",
                mode="w",
                encoding="utf-8",
            ) as handle:
                json.dump(metadata, handle, ensure_ascii=False, sort_keys=True)
                meta_tmp = handle.name
            os.replace(pcm_tmp, pcm_path)
            os.replace(meta_tmp, meta_path)
        finally:
            for temp_path in (pcm_tmp, meta_tmp):
                if not temp_path:
                    continue
                try:
                    if os.path.exists(temp_path):
                        os.remove(temp_path)
                except OSError:
                    pass

    def synthesize(
        self,
        text: str,
        *,
        language: str | None = None,
        style_hints: dict[str, object] | None = None,
    ) -> TTSSynthesis:
        del style_hints
        if not text:
            return TTSSynthesis(text=text, audio_bytes=b"", sample_rate_hz=self._sample_rate_hz)
        cached = self._load_cached_synthesis(text, language=language)
        if cached is not None:
            return cached
        if not self._api_key:
            raise RuntimeError("AI_VOICE_TTS_ELEVENLABS_API_KEY is required for ElevenLabs TTS")

        with httpx.Client(timeout=self._timeout_seconds) as client:
            response = client.post(
                f"{self._api_base}/v1/text-to-speech/{self._voice(language)}",
                headers={
                    "xi-api-key": self._api_key,
                    "Accept": "application/octet-stream",
                    "Content-Type": "application/json",
                },
                params={"output_format": self._output_format},
                json={
                    "text": text,
                    "model_id": self._model(language),
                    "language_code": self._language_code(language),
                },
            )
        response.raise_for_status()
        synthesis = TTSSynthesis(text=text, audio_bytes=response.content, sample_rate_hz=self._sample_rate_hz)
        with self._cache_lock:
            cached = self._load_cached_synthesis(text, language=language)
            if cached is not None:
                return cached
            self._write_cached_synthesis(synthesis, language=language)
        return synthesis

    def synthesize_chunks(
        self,
        text: str,
        *,
        language: str | None = None,
        style_hints: dict[str, object] | None = None,
    ):
        del style_hints
        if not text:
            return
        cached = self._load_cached_synthesis(text, language=language)
        if cached is not None:
            if cached.audio_bytes:
                yield cached
            return
        if not self._api_key:
            raise RuntimeError("AI_VOICE_TTS_ELEVENLABS_API_KEY is required for ElevenLabs TTS")

        # Raw PCM16 samples must stay 2-byte aligned across network chunk
        # boundaries, or a split sample corrupts playback at that boundary.
        collected = bytearray()
        leftover = b""
        with httpx.Client(timeout=self._timeout_seconds) as client:
            with client.stream(
                "POST",
                f"{self._api_base}/v1/text-to-speech/{self._voice(language)}/stream",
                headers={
                    "xi-api-key": self._api_key,
                    "Accept": "application/octet-stream",
                    "Content-Type": "application/json",
                },
                params={"output_format": self._output_format},
                json={
                    "text": text,
                    "model_id": self._model(language),
                    "language_code": self._language_code(language),
                },
            ) as response:
                response.raise_for_status()
                for raw_chunk in response.iter_bytes():
                    if not raw_chunk:
                        continue
                    data = leftover + raw_chunk
                    if len(data) % 2:
                        leftover = data[-1:]
                        data = data[:-1]
                    else:
                        leftover = b""
                    if not data:
                        continue
                    collected.extend(data)
                    yield TTSSynthesis(text=text, audio_bytes=data, sample_rate_hz=self._sample_rate_hz)
        if leftover:
            collected.extend(leftover)

        if collected:
            full_synthesis = TTSSynthesis(text=text, audio_bytes=bytes(collected), sample_rate_hz=self._sample_rate_hz)
            with self._cache_lock:
                if self._load_cached_synthesis(text, language=language) is None:
                    self._write_cached_synthesis(full_synthesis, language=language)


def build_tts_provider(name: str) -> TTSProvider:
    normalized = str(name or "stub").strip().lower()
    if normalized == "openai":
        return OpenAITTSProvider()
    if normalized in {"yandex", "yandex_speechkit", "speechkit"}:
        return YandexTTSProvider()
    if normalized in {"elevenlabs", "11labs"}:
        return ElevenLabsTTSProvider()
    return TTSProvider()
