"""TTS provider abstraction — free providers only (no paid).

Provider order (first available wins):
  1. edge-tts    — online, free, Microsoft Edge browser endpoint, ~25 voices
  2. piper-tts   — local, free, ~80MB models per voice, decent quality
  3. pyttsx3     — system voices (SAPI / NSSpeech / espeak), robotic but always available

All heavy imports are lazy; no-deps installation never crashes. Missing
providers are skipped silently and logged at INFO level.
"""

from __future__ import annotations

import asyncio
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Any

log = logging.getLogger(__name__)


@dataclass
class TTSResult:
    """One scene's audio output."""

    path: Path
    duration_seconds: float
    provider_used: str    # "edge-tts" / "piper-tts" / "pyttsx3" / "empty"


class TTSProviderUnavailable(RuntimeError):
    """All configured providers failed."""


# ── Public API ──────────────────────────────────────────────────

async def synthesize_scene(
    scene_id: str,
    text: str,
    *,
    voice: str,
    rate: str = "-5%",
    output_dir: Path,
    skill_loader: Any = None,
) -> TTSResult:
    """Generate a single scene's TTS clip.

    Walks the provider chain from styles.yaml (edge-tts → piper-tts →
    pyttsx3) and returns the first successful result. Returns an empty-
    path TTSResult (provider_used="empty") if text is empty after
    sanitization. Raises TTSProviderUnavailable only if all providers
    fail.
    """

    output_dir.mkdir(parents=True, exist_ok=True)
    clean = sanitize_for_tts(text)
    if not clean.strip():
        return TTSResult(path=Path(), duration_seconds=0.0, provider_used="empty")

    providers = _resolve_provider_chain(skill_loader)
    last_err: Exception | None = None

    for provider_cfg in providers:
        pid = provider_cfg.get("id", "")
        if not provider_cfg.get("enabled", True):
            continue

        voice_map = provider_cfg.get("voices", {}) or {}
        try:
            result: TTSResult | None = None
            if pid == "edge-tts":
                resolved = voice_map.get(voice) or "en-CA-LiamNeural"
                result = await _try_edge_tts(scene_id, clean,
                                             voice=resolved, rate=rate, output_dir=output_dir)
            elif pid == "piper-tts":
                resolved = voice_map.get(voice) or "en_US-ryan-high"
                result = await _try_piper(scene_id, clean,
                                          voice=resolved, output_dir=output_dir)
            elif pid == "pyttsx3":
                result = await _try_pyttsx3(scene_id, clean, output_dir=output_dir)
            else:
                log.debug("Unknown TTS provider %r; skipping", pid)
                continue

            if result is not None:
                log.info("TTS %s synthesized via %s (%.1fs)",
                         scene_id, result.provider_used, result.duration_seconds)
                return result
        except Exception as exc:  # noqa: BLE001 — best-effort chain
            last_err = exc
            log.warning("TTS provider %s failed for %s: %s", pid, scene_id, exc)
            continue

    raise TTSProviderUnavailable(
        f"All TTS providers failed for scene {scene_id!r}; last error: {last_err}"
    )


def sanitize_for_tts(text: str) -> str:
    """Strip TTS-hostile characters per styles.yaml validation rules.

    Pure function — em-dash → comma, ellipsis → period, AGNT → Agent.
    Safe to call before queuing TTS regardless of provider.
    """

    if not text:
        return ""
    out = text
    out = out.replace("—", ", ").replace("–", ", ").replace("…", ".")
    out = out.replace("’", "'").replace("‘", "'")
    out = out.replace("“", '"').replace("”", '"')
    out = out.replace("AgntSpace", "Agent Space").replace("AGNTSPACE", "Agent Space")
    out = out.replace("AGNT", "Agent")
    out = out.replace(" AI ", " A.I. ").replace(" AI.", " A.I.")
    return out


# ── Provider chain resolution ───────────────────────────────────

_FALLBACK_CHAIN = [
    {"id": "edge-tts", "enabled": True,
     "voices": {"liam": "en-CA-LiamNeural"}},
    {"id": "piper-tts", "enabled": True,
     "voices": {"liam": "en_US-ryan-high"}},
    {"id": "pyttsx3", "enabled": True, "voices": {}},
]


def _resolve_provider_chain(skill_loader: Any) -> list[dict[str, Any]]:
    """Load provider chain from styles.yaml, fall back to hardcoded defaults."""

    if skill_loader is None:
        return _FALLBACK_CHAIN
    try:
        data = skill_loader.load_skill_config_file("video-producer", "styles.yaml")
        if isinstance(data, dict):
            providers = (data.get("tts") or {}).get("providers") or []
            if providers:
                return providers
    except Exception as exc:  # noqa: BLE001
        log.debug("styles.yaml provider chain load failed: %s", exc)
    return _FALLBACK_CHAIN


# ── Provider implementations ────────────────────────────────────

async def _try_edge_tts(
    scene_id: str,
    text: str,
    *,
    voice: str,
    rate: str,
    output_dir: Path,
) -> TTSResult | None:
    """Online — Microsoft Edge browser endpoint. Free, no key. Lazy import.

    Uses edge_tts.Communicate + mutagen for duration probe. Returns None
    only if edge_tts imports cleanly but produces no output (rare).
    Raises ImportError chain upward when edge_tts isn't installed so
    caller can fall through to piper-tts.
    """

    try:
        import edge_tts  # noqa: PLC0415
        from mutagen.mp3 import MP3  # noqa: PLC0415
    except ImportError as exc:
        raise ImportError(f"edge-tts not available: {exc}") from exc

    path = output_dir / f"{scene_id}.mp3"
    communicate = edge_tts.Communicate(text, voice, rate=rate, volume="+0%", pitch="+0Hz")
    await communicate.save(str(path))

    if not path.exists() or path.stat().st_size == 0:
        return None

    audio = MP3(str(path))
    return TTSResult(
        path=path,
        duration_seconds=round(float(audio.info.length), 2),
        provider_used="edge-tts",
    )


async def _try_piper(
    scene_id: str,
    text: str,
    *,
    voice: str,
    output_dir: Path,
) -> TTSResult | None:
    """Local — piper-tts. Auto-downloads ~80MB voice model on first use.

    piper-tts is invoked via subprocess (piper CLI) rather than Python
    bindings because the PyPI package has unstable native bindings
    across platforms. Output is wav; we convert to mp3 via ffmpeg for
    parity with edge-tts.
    """

    import shutil  # noqa: PLC0415

    piper_bin = shutil.which("piper")
    ffmpeg_bin = shutil.which("ffmpeg")
    if not piper_bin:
        raise ImportError("piper CLI not on PATH; install piper-tts")
    if not ffmpeg_bin:
        raise ImportError("ffmpeg not on PATH; needed for piper→mp3 conversion")

    wav_path = output_dir / f"{scene_id}.wav"
    mp3_path = output_dir / f"{scene_id}.mp3"

    proc = await asyncio.create_subprocess_exec(
        piper_bin, "--model", voice, "--output_file", str(wav_path),
        stdin=asyncio.subprocess.PIPE,
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
    )
    stdout, stderr = await proc.communicate(input=text.encode("utf-8"))
    if proc.returncode != 0:
        raise RuntimeError(f"piper exited {proc.returncode}: {stderr.decode('utf-8', 'replace')}")
    if not wav_path.exists() or wav_path.stat().st_size == 0:
        return None

    # Convert wav → mp3 via ffmpeg
    ff = await asyncio.create_subprocess_exec(
        ffmpeg_bin, "-y", "-i", str(wav_path),
        "-codec:a", "libmp3lame", "-qscale:a", "4", str(mp3_path),
        stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE,
    )
    _, ff_err = await ff.communicate()
    wav_path.unlink(missing_ok=True)
    if ff.returncode != 0:
        raise RuntimeError(f"ffmpeg piper→mp3 failed: {ff_err.decode('utf-8', 'replace')}")

    from mutagen.mp3 import MP3  # noqa: PLC0415
    audio = MP3(str(mp3_path))
    return TTSResult(
        path=mp3_path,
        duration_seconds=round(float(audio.info.length), 2),
        provider_used="piper-tts",
    )


async def _try_pyttsx3(
    scene_id: str,
    text: str,
    *,
    output_dir: Path,
) -> TTSResult | None:
    """System voices — last resort. SAPI / NSSpeech / espeak. Lazy import.

    pyttsx3 is sync — we run it in a thread pool to avoid blocking the
    event loop. Saves wav, then converts to mp3 via ffmpeg (same as piper).
    """

    import shutil  # noqa: PLC0415
    ffmpeg_bin = shutil.which("ffmpeg")
    if not ffmpeg_bin:
        raise ImportError("ffmpeg not on PATH; needed for pyttsx3→mp3 conversion")

    try:
        import pyttsx3  # noqa: PLC0415
    except ImportError as exc:
        raise ImportError(f"pyttsx3 not available: {exc}") from exc

    wav_path = output_dir / f"{scene_id}.wav"
    mp3_path = output_dir / f"{scene_id}.mp3"

    def _run_sync() -> None:
        engine = pyttsx3.init()
        engine.save_to_file(text, str(wav_path))
        engine.runAndWait()
        engine.stop()

    await asyncio.to_thread(_run_sync)
    if not wav_path.exists() or wav_path.stat().st_size == 0:
        return None

    ff = await asyncio.create_subprocess_exec(
        ffmpeg_bin, "-y", "-i", str(wav_path),
        "-codec:a", "libmp3lame", "-qscale:a", "4", str(mp3_path),
        stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE,
    )
    _, ff_err = await ff.communicate()
    wav_path.unlink(missing_ok=True)
    if ff.returncode != 0:
        raise RuntimeError(f"ffmpeg pyttsx3→mp3 failed: {ff_err.decode('utf-8', 'replace')}")

    from mutagen.mp3 import MP3  # noqa: PLC0415
    audio = MP3(str(mp3_path))
    return TTSResult(
        path=mp3_path,
        duration_seconds=round(float(audio.info.length), 2),
        provider_used="pyttsx3",
    )
