"""Piper TTS Adapter - Zero-dependency TTS engine.

Piper is a fast, local neural text-to-speech system that:
- Requires NO system dependencies (no espeak-ng)
- Works on Windows, macOS, Linux out of the box
- Supports 40+ languages with 100+ voices
- Uses ONNX Runtime for cross-platform compatibility
- Has small model sizes (15-60MB vs 200-500MB VITS)
"""

from __future__ import annotations

import gc
import os
import io
import logging
import numpy as np
from pathlib import Path
from typing import Optional, Dict, Any
import wave
import struct

from ..voice_profiles import VoiceProfile
from .base import TTSAdapter

logger = logging.getLogger(__name__)


class PiperTTSAdapter(TTSAdapter):
    """Piper TTS adapter using piper-tts package.
    
    This adapter provides cross-platform TTS without system dependencies,
    making it ideal for easy installation on Windows, macOS, and Linux.
    """

    engine_id = "piper"
    
    # Language-to-voice mapping (using quality 'medium' models for balance of size/quality)
    # Format: language_code -> (hf_path, model_filename)
    PIPER_MODELS = {
        'en': ('en/en_US/amy/medium', 'en_US-amy-medium'),         # US English, female voice
        'fr': ('fr/fr_FR/siwis/medium', 'fr_FR-siwis-medium'),     # France French
        'de': ('de/de_DE/thorsten/medium', 'de_DE-thorsten-medium'), # German
        'es': ('es/es_ES/carlfm/medium', 'es_ES-carlfm-medium'),   # Spain Spanish
        'ru': ('ru/ru_RU/dmitri/medium', 'ru_RU-dmitri-medium'),   # Russian
        'zh': ('zh/zh_CN/huayan/medium', 'zh_CN-huayan-medium'),   # Mandarin Chinese
    }
    
    # Model download sizes (for user information)
    MODEL_SIZES = {
        'en': '50MB',
        'fr': '45MB',
        'de': '48MB',
        'es': '47MB',
        'ru': '52MB',
        'zh': '55MB',
    }
    
    def __init__(
        self,
        language: str = "en",
        model_dir: Optional[str] = None,
        *,
        allow_downloads: bool = True,
        auto_load: bool = True,
    ):
        """Initialize Piper TTS adapter.
        
        Args:
            language: Initial language (default: 'en')
            model_dir: Directory to store models (default: ~/.piper/models)
        """
        self._piper_available = False
        self._voice = None
        self._current_language = None
        self._requested_language = str(language or "en").strip().lower() or "en"
        self._sample_rate = 22050  # Piper default
        self._allow_downloads = bool(allow_downloads)
        # Track whether we requested CUDA for ONNX runtime (best-effort).
        self._use_cuda: bool = False
        
        # Set model directory
        if model_dir is None:
            home = Path.home()
            self._model_dir = home / '.piper' / 'models'
        else:
            self._model_dir = Path(model_dir)
        
        self._model_dir.mkdir(parents=True, exist_ok=True)
        
        # Try to import piper-tts
        try:
            from piper import PiperVoice
            self._PiperVoice = PiperVoice
            self._piper_available = True
            logger.info("✅ Piper TTS initialized successfully")
            
            # Load initial language model (best-effort). In offline-first contexts
            # `allow_downloads=False` prevents implicit downloads and will fail fast
            # if models are not already cached locally.
            if bool(auto_load):
                self.set_language(language)
            
        except ImportError as e:
            logger.warning(f"⚠️  Piper TTS not available: {e}")
            logger.info(
                "To install Piper TTS:\n"
                "  pip install piper-tts>=1.2.0\n"
                "This will enable zero-dependency TTS on all platforms."
            )
    
    def _coerce_bool_env(self, key: str) -> Optional[bool]:
        """Parse common boolean env var values.

        Returns:
        - True/False when key is present and parseable
        - None when key is unset/empty
        """
        raw = os.environ.get(str(key), None)
        if raw is None:
            return None
        s = str(raw).strip().lower()
        if not s:
            return None
        if s in ("1", "true", "yes", "y", "on"):
            return True
        if s in ("0", "false", "no", "n", "off"):
            return False
        return None

    def _onnx_cuda_available(self) -> bool:
        """Return True if ONNX Runtime advertises CUDAExecutionProvider."""
        try:
            import onnxruntime as ort  # type: ignore

            providers = ort.get_available_providers()  # type: ignore[attr-defined]
            return "CUDAExecutionProvider" in list(providers or [])
        except Exception:
            return False

    def _resolve_use_cuda(self) -> bool:
        """Best-effort policy for Piper CUDA usage.

        Piper uses ONNX Runtime. On NVIDIA machines, CUDA can be enabled when the
        environment has onnxruntime-gpu (CUDAExecutionProvider).

        Control:
        - ABSTRACTVOICE_PIPER_USE_CUDA=1/0 forces on/off
        - default: enable when CUDAExecutionProvider is available
        """
        forced = self._coerce_bool_env("ABSTRACTVOICE_PIPER_USE_CUDA")
        if forced is not None:
            return bool(forced)
        return bool(self._onnx_cuda_available())

    @staticmethod
    def _voice_id_from_hf_path(hf_path: str) -> str:
        # e.g. "en/en_US/amy/medium" -> "amy"
        parts = (hf_path or "").split("/")
        return parts[2] if len(parts) >= 3 else str(hf_path or "")

    def _profile_for_language(self, language: str) -> Optional[VoiceProfile]:
        model_info = self.PIPER_MODELS.get(str(language or ""))
        if not model_info:
            return None

        hf_path, model_filename = model_info
        voice_id = self._voice_id_from_hf_path(hf_path)
        if not voice_id:
            return None

        cached = False
        try:
            model_path, config_path = self._get_model_path(str(language))
            cached = model_path.exists() and config_path.exists()
        except Exception:
            cached = False

        return VoiceProfile(
            engine_id="piper",
            profile_id=voice_id,
            label=f"Piper {voice_id}",
            description=f"Default Piper voice for {language}",
            params={
                "provider": "piper",
                "language": str(language),
                "voice": voice_id,
                "model": model_filename,
                "model_filename": model_filename,
            },
            tags={
                "provider": "piper",
                "engine_id": "piper",
                "kind": "profile",
                "cached": "true" if cached else "false",
            },
        )

    def _profile_candidates_for_language(self, language: str) -> set[str]:
        model_info = self.PIPER_MODELS.get(str(language or ""))
        if not model_info:
            return set()
        hf_path, model_filename = model_info
        voice_id = self._voice_id_from_hf_path(hf_path)
        values = {
            str(language),
            str(voice_id),
            str(model_filename),
            f"{model_filename}.onnx",
            f"{language}:{voice_id}",
            f"{language}/{voice_id}",
            f"{language}.{voice_id}",
            f"{language}:{model_filename}",
            f"{language}/{model_filename}",
            f"{language}.{model_filename}",
        }
        return {v.strip().lower() for v in values if str(v or "").strip()}

    def _get_model_path(self, language: str) -> tuple[Path, Path]:
        """Get paths for model and config files.
        
        Args:
            language: Language code
            
        Returns:
            Tuple of (model_path, config_path)
        """
        model_info = self.PIPER_MODELS.get(language)
        if not model_info:
            raise ValueError(f"Unsupported language: {language}")
        
        _, model_filename = model_info
        model_path = self._model_dir / f"{model_filename}.onnx"
        config_path = self._model_dir / f"{model_filename}.onnx.json"
        
        return model_path, config_path

    def ensure_model_downloaded(self, language: str) -> bool:
        """Explicitly download Piper model files for a language (no implicit calls).

        This downloads the ONNX model + JSON config into the local cache directory.
        """
        try:
            model_path, config_path = self._get_model_path(language)
        except Exception:
            return False

        if model_path.exists() and config_path.exists():
            return True

        return bool(self._download_model(language))

    def unload(self) -> None:
        """Best-effort release of loaded voice/session to free memory."""
        self._voice = None
        try:
            gc.collect()
        except Exception:
            pass
    
    def _download_model(self, language: str) -> bool:
        """Download Piper model for specified language using Hugging Face Hub.
        
        Args:
            language: Language code
            
        Returns:
            True if successful, False otherwise
        """
        if not self._piper_available:
            return False

        if not bool(getattr(self, "_allow_downloads", True)):
            # Offline-first: never hit the network implicitly.
            logger.info(f"ℹ️ Piper model for '{language}' not cached locally (offline mode).")
            return False
        
        model_info = self.PIPER_MODELS.get(language)
        if not model_info:
            logger.error(f"❌ No Piper model defined for language: {language}")
            return False
        
        hf_path, model_filename = model_info
        model_path, config_path = self._get_model_path(language)
        
        # Check if already downloaded
        if model_path.exists() and config_path.exists():
            logger.debug(f"✅ Model already exists: {model_filename}")
            return True
        
        # Download from Piper repository.
        #
        # IMPORTANT: we intentionally avoid importing `huggingface_hub` here.
        # In some environments we've observed intermittent interpreter crashes
        # during deep import chains (pure-Python packages should not segfault,
        # which strongly suggests native extension interactions elsewhere).
        #
        # Using direct HTTPS downloads is simpler, more predictable, and keeps
        # the adapter robust in "fresh install" scenarios.
        logger.info(f"⬇️  Downloading Piper model for {language} ({self.MODEL_SIZES.get(language, 'unknown size')})...")
        
        existing_paths = {model_path: model_path.exists(), config_path: config_path.exists()}
        downloaded_paths: set[Path] = set()

        try:
            repo_id = "rhasspy/piper-voices"
            base_url = f"https://huggingface.co/{repo_id}/resolve/main"

            def _download(url: str, dest: Path) -> None:
                import requests
                import tempfile

                dest.parent.mkdir(parents=True, exist_ok=True)
                tmp_path: Path | None = None

                try:
                    with requests.get(url, stream=True, timeout=60) as r:
                        r.raise_for_status()

                        # Write atomically to avoid leaving corrupt partial files.
                        with tempfile.NamedTemporaryFile(dir=str(dest.parent), delete=False) as tmp:
                            tmp_path = Path(tmp.name)
                            for chunk in r.iter_content(chunk_size=1024 * 256):
                                if chunk:
                                    tmp.write(chunk)

                    tmp_path.replace(dest)
                    downloaded_paths.add(dest)
                    tmp_path = None
                finally:
                    if tmp_path is not None:
                        try:
                            tmp_path.unlink(missing_ok=True)
                        except Exception:
                            pass

            # Download model file
            if not model_path.exists():
                logger.info(f"   Downloading {model_path.name}...")
                _download(f"{base_url}/{hf_path}/{model_filename}.onnx", model_path)

            # Download config file
            if not config_path.exists():
                logger.info(f"   Downloading {config_path.name}...")
                _download(f"{base_url}/{hf_path}/{model_filename}.onnx.json", config_path)
            
            logger.info(f"✅ Successfully downloaded Piper model for {language}")
            return True
            
        except Exception as e:
            logger.error(f"❌ Failed to download Piper model: {e}")
            logger.info(f"   If this persists, manually download from: https://huggingface.co/rhasspy/piper-voices")
            # Clean up only files created by this attempt; never delete an
            # already-valid cached companion after a partial failure.
            for path in downloaded_paths:
                if not existing_paths.get(path, False) and path.exists():
                    try:
                        path.unlink()
                    except Exception:
                        pass
            return False
    
    def _load_voice(self, language: str) -> bool:
        """Load Piper voice for specified language.
        
        Args:
            language: Language code
            
        Returns:
            True if successful, False otherwise
        """
        if not self._piper_available:
            return False
        
        # Download model if needed
        model_path, config_path = self._get_model_path(language)
        if not (model_path.exists() and config_path.exists()):
            # Offline-first: do not attempt downloads unless explicitly allowed.
            if not bool(getattr(self, "_allow_downloads", True)):
                return False
            if not self._download_model(language):
                return False
        
        # Load the voice
        try:
            logger.debug(f"Loading Piper voice: {model_path}")
            use_cuda = bool(self._resolve_use_cuda())
            try:
                self._voice = self._PiperVoice.load(str(model_path), str(config_path), use_cuda=bool(use_cuda))
                self._use_cuda = bool(use_cuda)
            except TypeError:
                # Backward-compat for older piper versions without use_cuda kwarg.
                self._voice = self._PiperVoice.load(str(model_path), str(config_path))
                self._use_cuda = False
            except Exception:
                # If CUDA was requested but runtime doesn't support it (or driver issues),
                # fall back to CPU for robustness.
                if use_cuda:
                    self._voice = self._PiperVoice.load(str(model_path), str(config_path), use_cuda=False)
                    self._use_cuda = False
                else:
                    raise
            self._current_language = language
            
            # Update sample rate from config
            if hasattr(self._voice, 'config') and hasattr(self._voice.config, 'sample_rate'):
                self._sample_rate = self._voice.config.sample_rate
            
            logger.info(f"✅ Loaded Piper voice for {language}")
            return True
            
        except Exception as e:
            logger.error(f"❌ Failed to load Piper voice for {language}: {e}")
            return False
    
    def synthesize(self, text: str) -> np.ndarray:
        """Convert text to audio array for immediate playback.
        
        Args:
            text: The text to synthesize
            
        Returns:
            Audio data as numpy array (float32, range -1.0 to 1.0)
        """
        if not self.is_available():
            raise RuntimeError("Piper TTS is not available. Install with: pip install piper-tts>=1.2.0")
        
        if not self._voice:
            raise RuntimeError(f"No voice loaded. Call set_language() first.")
        
        try:
            # Piper synthesize returns an iterable of AudioChunk objects
            audio_chunks = list(self._voice.synthesize(text))
            
            if not audio_chunks:
                return np.array([], dtype=np.float32)
            
            # Combine all audio chunks into single array
            # Each chunk has audio_float_array attribute with normalized float32 audio
            audio_arrays = [chunk.audio_float_array for chunk in audio_chunks]
            
            # Concatenate all arrays
            audio_array = np.concatenate(audio_arrays)
            
            return audio_array
            
        except Exception as e:
            logger.error(f"❌ Piper synthesis failed: {e}")
            raise RuntimeError(f"Piper synthesis failed: {e}") from e

    def synthesize_to_audio_chunks(self, text: str):
        """Stream Piper audio chunks as they are synthesized (best-effort)."""
        if not self.is_available():
            raise RuntimeError("Piper TTS is not available. Install with: pip install piper-tts>=1.2.0")

        if not self._voice:
            raise RuntimeError("No voice loaded. Call set_language() first.")

        try:
            sr = int(getattr(self, "_sample_rate", 0) or 0) or int(self.get_sample_rate())
        except Exception:
            sr = 0

        try:
            # Piper synthesize returns an iterable of AudioChunk objects.
            for chunk in self._voice.synthesize(str(text)):
                try:
                    arr = np.asarray(getattr(chunk, "audio_float_array", chunk), dtype=np.float32).reshape(-1)
                except Exception:
                    arr = np.asarray(chunk, dtype=np.float32).reshape(-1)
                if arr.size:
                    yield arr, int(sr or 0)
        except Exception as e:
            logger.error(f"❌ Piper streaming synthesis failed: {e}")
            raise RuntimeError(f"Piper synthesis failed: {e}") from e
    
    def synthesize_to_bytes(self, text: str, format: str = 'wav') -> bytes:
        """Convert text to audio bytes for network transmission.
        
        Args:
            text: The text to synthesize
            format: Audio format ('wav' only supported currently)
            
        Returns:
            Audio data as bytes in WAV format
        """
        if format.lower() != 'wav':
            raise ValueError(f"Piper adapter currently only supports WAV format, not {format}")
        
        # Get audio array
        audio_array = self.synthesize(text)
        
        # Convert to bytes
        return self._array_to_wav_bytes(audio_array)
    
    def synthesize_to_file(self, text: str, output_path: str, format: Optional[str] = None) -> str:
        """Convert text to audio file.
        
        Args:
            text: The text to synthesize
            output_path: Path to save the audio file
            format: Audio format (optional, inferred from extension)
            
        Returns:
            Path to the saved audio file
        """
        # Infer format from extension if not provided
        if format is None:
            format = Path(output_path).suffix.lstrip('.') or "wav"
        
        if format.lower() != 'wav':
            raise ValueError(f"Piper adapter currently only supports WAV format, not {format}")
        
        # Get audio bytes
        audio_bytes = self.synthesize_to_bytes(text, format='wav')
        
        # Write to file
        output_path = Path(output_path)
        output_path.parent.mkdir(parents=True, exist_ok=True)
        
        with open(output_path, 'wb') as f:
            f.write(audio_bytes)
        
        logger.info(f"✅ Saved audio to: {output_path}")
        return str(output_path)
    
    def _array_to_wav_bytes(self, audio_array: np.ndarray) -> bytes:
        """Convert numpy array to WAV bytes.
        
        Args:
            audio_array: Audio as float32 array [-1.0, 1.0]
            
        Returns:
            WAV file as bytes
        """
        # Convert to 16-bit PCM
        audio_int16 = (audio_array * 32767).astype(np.int16)
        
        # Create WAV file in memory
        buffer = io.BytesIO()
        
        with wave.open(buffer, 'wb') as wav_file:
            wav_file.setnchannels(1)  # Mono
            wav_file.setsampwidth(2)  # 16-bit
            wav_file.setframerate(self._sample_rate)
            wav_file.writeframes(audio_int16.tobytes())
        
        return buffer.getvalue()
    
    def set_language(self, language: str) -> bool:
        """Switch the TTS language.
        
        Args:
            language: ISO 639-1 language code (e.g., 'en', 'fr', 'de')
            
        Returns:
            True if language switch successful, False otherwise
        """
        if language not in self.PIPER_MODELS:
            logger.warning(f"⚠️  Language {language} not supported by Piper adapter")
            return False

        self._requested_language = str(language)
        
        # Don't reload if already loaded
        if self._current_language == language and self._voice is not None:
            logger.debug(f"Language {language} already loaded")
            return True
        
        # Load new voice
        return self._load_voice(language)
    
    def get_supported_languages(self) -> list[str]:
        """Get list of supported language codes.
        
        Returns:
            List of ISO 639-1 language codes
        """
        return list(self.PIPER_MODELS.keys())
    
    def get_sample_rate(self) -> int:
        """Get the sample rate of the synthesized audio.
        
        Returns:
            Sample rate in Hz (typically 22050)
        """
        return self._sample_rate
    
    def is_available(self) -> bool:
        """Check if Piper TTS is available and functional.
        
        Returns:
            True if Piper can be used, False otherwise
        """
        return self._piper_available and self._voice is not None

    def get_unavailable_reason(self) -> str | None:
        """Return an actionable reason when Piper cannot synthesize."""
        if not bool(getattr(self, "_piper_available", False)):
            return (
                "Piper TTS is not installed.\n"
                "Install with:\n"
                '  pip install "abstractvoice[piper]"\n'
                '  pip install "abstractvoice[apple]"  # Apple profile\n'
                '  pip install "abstractvoice[gpu]"    # GPU profile'
            )

        lang = str(
            getattr(self, "_current_language", None)
            or getattr(self, "_requested_language", None)
            or "en"
        ).strip().lower() or "en"
        return (
            f"Piper voice model for '{lang}' is not available locally.\n"
            f"Run: python -m abstractvoice download --piper {lang}\n"
            f"Or in the REPL: /tts_download piper {lang}"
        )
    
    def get_info(self) -> Dict[str, Any]:
        """Get metadata about Piper TTS engine.
        
        Returns:
            Dictionary with engine information
        """
        info = super().get_info()
        info.update({
            'engine': 'Piper TTS',
            'version': '1.2.0+',
            'current_language': self._current_language,
            'model_dir': str(self._model_dir),
            'requires_system_deps': False,
            'cross_platform': True,
            'use_cuda': bool(getattr(self, "_use_cuda", False)),
        })
        # Best-effort: expose available ONNX Runtime providers for debugging.
        try:
            import onnxruntime as ort  # type: ignore

            info["onnxruntime_providers_available"] = list(ort.get_available_providers() or [])  # type: ignore[attr-defined]
        except Exception:
            pass
        return info

    def get_profiles(self) -> list[VoiceProfile]:
        """Expose Piper voices as selectable TTS profiles.

        The profile id is the Piper voice id (for example "amy"), while the
        backing model filename is preserved in params for catalog consumers.
        """
        profiles: list[VoiceProfile] = []
        for language in self.PIPER_MODELS:
            profile = self._profile_for_language(str(language))
            if profile is not None:
                profiles.append(profile)
        return profiles

    def set_profile(self, profile_id: str) -> bool:
        """Select a Piper voice/profile by voice id, language, or model id."""
        requested = str(profile_id or "").strip().lower()
        if not requested:
            return False
        requested_no_ext = requested[: -len(".onnx")] if requested.endswith(".onnx") else requested

        for language in self.PIPER_MODELS:
            candidates = self._profile_candidates_for_language(str(language))
            if requested in candidates or requested_no_ext in candidates:
                return bool(self.set_language(str(language)))
        return False

    def get_default_profile_id(self, language: str | None = None) -> str | None:
        lang = str(
            language
            or getattr(self, "_current_language", None)
            or getattr(self, "_requested_language", None)
            or "en"
        ).strip().lower() or "en"
        profile = self._profile_for_language(lang)
        return str(profile.profile_id) if profile is not None else None

    def get_active_profile(self) -> VoiceProfile | None:
        language = str(
            getattr(self, "_current_language", None)
            or getattr(self, "_requested_language", None)
            or "en"
        ).strip()
        return self._profile_for_language(language)

    def synthesize_to_bytes_with_voice(self, text: str, *, format: str = "wav", voice: str | None = None, **_kwargs: Any) -> bytes:
        """Core/Gateway compatibility hook for profile-style voice selection."""
        if voice and not self.set_profile(str(voice)):
            raise ValueError(f"Unknown Piper voice/profile: {voice}")
        return self.synthesize_to_bytes(text, format=format)

    def list_available_models(self, language: Optional[str] = None) -> Dict[str, Any]:
        """List available Piper voices with cache status.

        This is a small, stable introspection surface used by the CLI to present
        selectable voices. Piper model downloads happen on-demand in `set_language()`.
        """
        def _parse_size_mb(size: str) -> int:
            try:
                return int(str(size).lower().replace("mb", "").strip())
            except Exception:
                return 0

        models: Dict[str, Any] = {}
        languages = [language] if language else list(self.PIPER_MODELS.keys())

        for lang in languages:
            if lang not in self.PIPER_MODELS:
                continue

            hf_path, model_filename = self.PIPER_MODELS[lang]
            voice_id = self._voice_id_from_hf_path(hf_path)
            model_path, config_path = self._get_model_path(lang)
            cached = model_path.exists() and config_path.exists()

            models.setdefault(lang, {})
            models[lang][voice_id] = {
                "name": f"Piper {voice_id}",
                "quality": "medium",
                "size_mb": _parse_size_mb(self.MODEL_SIZES.get(lang, "0MB")),
                "description": f"Default Piper voice for {lang}",
                "requires_espeak": False,
                "cached": cached,
                "model_filename": model_filename,
            }

        return models
