"""Text-to-speech engine using mlx-audio (Kokoro).

Runs the Kokoro TTS model locally on Apple Silicon via the MLX framework.
Models are downloaded from HuggingFace on first use and cached automatically.
"""

import asyncio
import logging
import os
import threading

import numpy as np

from cadecoder.core.constants import DEFAULT_TTS_MODEL, DEFAULT_TTS_SPEED, DEFAULT_TTS_VOICE
from cadecoder.core.errors import VoiceError
from cadecoder.core.logging import log
from cadecoder.voice.audio import PLAYBACK_SAMPLE_RATE, AudioPlayer
from cadecoder.voice.cleanup import strip_for_speech


def _suppress_hf_logging() -> None:
    """Suppress noisy HuggingFace Hub download/progress logging.

    Models are cached locally after first download, but HF Hub still logs
    status messages on every load. This silences those for cleaner output.
    """
    # Disable HF Hub progress bars and info logging
    os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")
    os.environ.setdefault("HF_HUB_DISABLE_SYMLINKS_WARNING", "1")

    # Set logging levels for noisy libraries
    for logger_name in (
        "huggingface_hub",
        "huggingface_hub.file_download",
        "filelock",
        "urllib3",
        "transformers",
        "mlx_audio",
        "spacy",
    ):
        logging.getLogger(logger_name).setLevel(logging.WARNING)


class _SuppressStdout:
    """Context manager to suppress stdout during noisy library initialization.

    Some libraries (e.g., spaCy via misaki) print directly to stdout during
    model downloads, which interferes with CLI output. This temporarily
    redirects stdout to devnull.
    """

    def __init__(self) -> None:
        self._original_stdout = None
        self._devnull = None

    def __enter__(self) -> "_SuppressStdout":
        import sys

        self._original_stdout = sys.stdout
        self._devnull = open(os.devnull, "w")
        sys.stdout = self._devnull
        return self

    def __exit__(self, *args: object) -> None:
        import sys

        if self._original_stdout is not None:
            sys.stdout = self._original_stdout
        if self._devnull is not None:
            self._devnull.close()


class KokoroTTS:
    """Local text-to-speech using mlx-audio's Kokoro model on Apple Silicon.

    Generates speech audio from text and plays it through the default
    output device.  Supports interruption — calling :meth:`stop` will
    silence playback immediately.

    Args:
        model: HuggingFace repo ID for the TTS model.
        voice: Voice preset name (e.g. ``af_heart``, ``af_sky``, ``am_adam``).
        speed: Playback speed multiplier (1.0 = normal).
    """

    def __init__(
        self,
        model: str = DEFAULT_TTS_MODEL,
        voice: str = DEFAULT_TTS_VOICE,
        speed: float = DEFAULT_TTS_SPEED,
        device: str | int | None = None,
    ) -> None:
        self.model_name = model
        self.voice = voice
        self.speed = speed
        self._model: object | None = None
        self._player = AudioPlayer(sample_rate=PLAYBACK_SAMPLE_RATE, device=device)
        self._lock = threading.Lock()

    def _ensure_loaded(self) -> None:
        """Lazy-load the TTS model on first use."""
        if self._model is not None:
            return

        with self._lock:
            # Double-check after acquiring lock
            if self._model is not None:
                return

            # Suppress HF Hub and other noisy logging before importing
            _suppress_hf_logging()

            try:
                # Suppress noisy "words count mismatch" warnings from phonemizer
                logging.getLogger("phonemizer").setLevel(logging.ERROR)

                from mlx_audio.tts.utils import load_model  # type: ignore[import-untyped]

                # Suppress stdout during model loading - misaki/spaCy prints
                # "You can now load the package via spacy.load(...)" on first run
                with _SuppressStdout():
                    self._model = load_model(self.model_name)
                log.info(f"Loaded TTS model: {self.model_name} (voice={self.voice})")
            except ImportError as exc:
                raise VoiceError(
                    "mlx-audio is required for voice mode. "
                    "Install it with: pip install 'cade-cli[voice]'"
                ) from exc
            except Exception as exc:
                raise VoiceError(f"Failed to load TTS model '{self.model_name}': {exc}") from exc

    async def synthesize(self, text: str) -> np.ndarray:
        """Synthesize speech audio from text.

        Args:
            text: Text to convert to speech.

        Returns:
            1-D float32 numpy array of audio samples at
            :data:`~cadecoder.voice.audio.PLAYBACK_SAMPLE_RATE`.

        Raises:
            VoiceError: If synthesis fails.
        """
        if not text.strip():
            return np.array([], dtype=np.float32)

        self._ensure_loaded()
        loop = asyncio.get_running_loop()
        return await loop.run_in_executor(None, self._synthesize_blocking, text)

    async def speak(self, text: str) -> None:
        """Synthesize and immediately play speech.

        Convenience method that calls :meth:`synthesize` followed by
        playback via :class:`~cadecoder.voice.audio.AudioPlayer`.
        Strips markdown formatting from *text* before synthesis.

        Args:
            text: Text to speak (may contain markdown).

        Raises:
            VoiceError: If synthesis or playback fails.
        """
        clean = strip_for_speech(text)
        if not clean.strip():
            return

        audio = await self.synthesize(clean)
        if audio.size == 0:
            return

        await self._player.play(audio)

    def stop(self) -> None:
        """Interrupt current speech playback immediately."""
        self._player.stop()

    @property
    def is_speaking(self) -> bool:
        """Return True if audio is currently being played."""
        return self._player.is_playing

    # -- private --

    def _synthesize_blocking(self, text: str) -> np.ndarray:
        """Run synthesis in a thread (blocking)."""
        if self._model is None:
            raise VoiceError("TTS model not loaded — call _ensure_loaded() first")

        # Suppress phonemizer "words count mismatch" warnings on every call
        logging.getLogger("phonemizer").setLevel(logging.ERROR)

        try:
            audio_chunks: list[np.ndarray] = []
            for result in self._model.generate(  # type: ignore[union-attr]
                text,
                voice=self.voice,
                speed=self.speed,
            ):
                if hasattr(result, "audio") and result.audio is not None:
                    chunk = np.array(result.audio, dtype=np.float32)
                    if chunk.ndim > 1:
                        chunk = chunk.flatten()
                    audio_chunks.append(chunk)

            if not audio_chunks:
                log.warning("TTS produced no audio output")
                return np.array([], dtype=np.float32)

            audio = np.concatenate(audio_chunks)
            duration = len(audio) / PLAYBACK_SAMPLE_RATE
            log.debug(
                f"Synthesized {duration:.1f}s of speech ({len(text)} chars -> {len(audio)} samples)"
            )
            return audio

        except Exception as exc:
            raise VoiceError(f"Speech synthesis failed: {exc}") from exc
