"""Audio recording and playback utilities.

Provides microphone capture with WebRTC-based voice activity detection (VAD)
and asynchronous audio playback with interruption support.  Uses ``sounddevice``
for cross-platform audio I/O and ``webrtcvad`` for robust speech/non-speech
classification.
"""

import asyncio
import threading
from collections.abc import Callable
from typing import Final

import numpy as np
import sounddevice as sd

from cadecoder.core.errors import VoiceError
from cadecoder.core.logging import log

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

DEFAULT_SAMPLE_RATE: Final[int] = 16_000  # 16 kHz — required by webrtcvad & Whisper
DEFAULT_CHANNELS: Final[int] = 1  # mono
PLAYBACK_SAMPLE_RATE: Final[int] = 24_000  # Kokoro outputs at 24 kHz

# VAD frame configuration — webrtcvad requires 10, 20, or 30 ms frames at 16 kHz
VAD_FRAME_DURATION_MS: Final[int] = 30  # 30 ms per frame
VAD_FRAME_SAMPLES: Final[int] = DEFAULT_SAMPLE_RATE * VAD_FRAME_DURATION_MS // 1000  # 480

# How many consecutive non-speech frames trigger end-of-utterance
DEFAULT_SILENCE_FRAMES: Final[int] = 50  # 50 * 30ms = 1.5s of non-speech
DEFAULT_VAD_AGGRESSIVENESS: Final[int] = 1  # 0-3, lower = more sensitive to speech

MAX_RECORDING_DURATION_S: Final[float] = 30.0  # hard cap per utterance
MAX_LEADING_SILENCE_S: Final[float] = 8.0  # give up if no speech after this long

# Status callback type: (speech_detected, elapsed_seconds, rms_level)
RecordingStatusCallback = Callable[[bool, float, float], None]


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _rms(audio: np.ndarray) -> float:
    """Compute root-mean-square amplitude of an audio buffer."""
    if audio.size == 0:
        return 0.0
    return float(np.sqrt(np.mean(audio.astype(np.float64) ** 2)))


def check_mic_permission(
    device: str | int | None = None,
    sample_rate: int = DEFAULT_SAMPLE_RATE,
) -> tuple[bool, str]:
    """Quick check whether the microphone is accessible and returning audio.

    On macOS, apps without microphone permission receive all-zero samples
    from PortAudio without any error.  This function records a short burst
    and checks for that condition.

    Args:
        device: Input device to test (None for system default).
        sample_rate: Sample rate in Hz.

    Returns:
        Tuple of ``(ok, message)`` — *ok* is True if mic is working.
    """
    resolved = resolve_device(device, kind="input")

    try:
        device_info = sd.query_devices(resolved or sd.default.device[0])
        device_name = device_info["name"]
    except Exception:
        device_name = str(resolved or "default")

    try:
        with sd.InputStream(
            samplerate=sample_rate,
            channels=1,
            dtype="float32",
            blocksize=VAD_FRAME_SAMPLES,
            device=resolved,
        ) as stream:
            # Read ~300ms of audio (10 frames × 30ms)
            samples = []
            for _ in range(10):
                chunk, _ = stream.read(VAD_FRAME_SAMPLES)
                samples.append(chunk.flatten())
            audio = np.concatenate(samples)
    except sd.PortAudioError as exc:
        return False, f"Cannot open input device '{device_name}': {exc}"
    except Exception as exc:
        return False, f"Microphone check failed: {exc}"

    rms = _rms(audio)
    peak = float(np.max(np.abs(audio)))

    if peak == 0.0 and rms == 0.0:
        import platform

        if platform.system() == "Darwin":
            return False, (
                f"Microphone '{device_name}' returns silence (all zeros).\n"
                "This usually means macOS is blocking microphone access.\n"
                "\n"
                "To fix:\n"
                "  1. Open System Settings > Privacy & Security > Microphone\n"
                "  2. Find your terminal app (Terminal, iTerm2, Warp, etc.)\n"
                "  3. Toggle it ON\n"
                "  4. Restart your terminal and try again\n"
                "\n"
                "If your terminal isn't listed, run this once in a plain Terminal:\n"
                '  python3 -c "import sounddevice; print(sounddevice.rec(1000, 16000, 1))"\n'
                "macOS should then prompt for microphone access."
            )
        else:
            return False, (
                f"Microphone '{device_name}' returns silence (all zeros). "
                "Check that the device is unmuted and working."
            )

    log.debug(f"Mic check: device={device_name}, rms={rms:.6f}, peak={peak:.6f}")
    return True, f"Microphone '{device_name}' is working (RMS={rms:.4f})"


def record_fixed_duration(
    duration_s: float,
    sample_rate: int = DEFAULT_SAMPLE_RATE,
    channels: int = DEFAULT_CHANNELS,
    device: str | int | None = None,
    on_status: RecordingStatusCallback | None = None,
) -> np.ndarray:
    """Record a fixed number of seconds from the microphone (no VAD).

    Used for device testing — records exactly *duration_s* seconds
    regardless of speech content.

    Args:
        duration_s: How many seconds to record.
        sample_rate: Sample rate in Hz.
        channels: Number of channels.
        device: Input device name, index, or None for system default.
        on_status: Optional callback ``(speech_detected, elapsed, rms)``.

    Returns:
        1-D float32 numpy array of recorded samples.

    Raises:
        VoiceError: If the audio device is unavailable or recording fails.
    """
    resolved = resolve_device(device, kind="input")
    total_frames = int(duration_s * sample_rate / VAD_FRAME_SAMPLES)
    status_interval = 10  # ~300ms

    buffers: list[np.ndarray] = []

    try:
        with sd.InputStream(
            samplerate=sample_rate,
            channels=channels,
            dtype="float32",
            blocksize=VAD_FRAME_SAMPLES,
            device=resolved,
        ) as stream:
            for frame_idx in range(total_frames):
                chunk, _ = stream.read(VAD_FRAME_SAMPLES)
                chunk = chunk.flatten()
                buffers.append(chunk)

                if on_status and frame_idx % status_interval == 0:
                    elapsed = (frame_idx + 1) * VAD_FRAME_DURATION_MS / 1000.0
                    on_status(False, elapsed, _rms(chunk))

    except sd.PortAudioError as exc:
        raise VoiceError(f"Audio input device error: {exc}") from exc
    except Exception as exc:
        raise VoiceError(f"Recording failed: {exc}") from exc

    if not buffers:
        return np.array([], dtype=np.float32)

    return np.concatenate(buffers)


# ---------------------------------------------------------------------------
# VAD wrapper
# ---------------------------------------------------------------------------


class _VoiceActivityDetector:
    """Thin wrapper around webrtcvad for voice activity detection.

    Prefers the ``webrtcvad`` Python package which handles the C extension
    API internally.  Falls back to the ``_webrtcvad`` C module directly
    if the Python wrapper is not available.

    Args:
        aggressiveness: VAD aggressiveness mode (0-3).  Higher values
            filter more aggressively, reducing false positives at the
            cost of potentially clipping quiet speech.  **Use 0-1 for
            most microphones.**
        sample_rate: Audio sample rate in Hz (must be 8000, 16000, 32000, or 48000).
    """

    def __init__(
        self,
        aggressiveness: int = DEFAULT_VAD_AGGRESSIVENESS,
        sample_rate: int = DEFAULT_SAMPLE_RATE,
    ) -> None:
        try:
            import webrtcvad  # type: ignore[import-untyped]

            self._vad = webrtcvad.Vad(aggressiveness)
            self._use_python_api = True
        except ImportError:
            try:
                import _webrtcvad  # type: ignore[import-not-found]

                self._vad = _webrtcvad.create()
                _webrtcvad.init(self._vad)
                _webrtcvad.set_mode(self._vad, aggressiveness)
                self._c_mod = _webrtcvad
                self._use_python_api = False
            except ImportError as exc:
                raise VoiceError(
                    "webrtcvad is required for voice mode. Install it with: pip install webrtcvad"
                ) from exc

        self.sample_rate = sample_rate

    def is_speech(self, frame: np.ndarray) -> bool:
        """Classify a single audio frame as speech or non-speech.

        Args:
            frame: Float32 audio samples for one VAD frame.

        Returns:
            True if the frame contains speech.
        """
        # Convert float32 [-1.0, 1.0] to int16 PCM bytes
        pcm_bytes = (frame * 32767).clip(-32768, 32767).astype(np.int16).tobytes()

        if self._use_python_api:
            return bool(self._vad.is_speech(pcm_bytes, self.sample_rate))
        else:
            # C extension: pass (vad, sample_rate, pcm_bytes, num_samples)
            return bool(self._c_mod.process(self._vad, self.sample_rate, pcm_bytes, len(frame)))


# ---------------------------------------------------------------------------
# Device helpers
# ---------------------------------------------------------------------------


def list_audio_devices() -> list[dict]:
    """List all available audio devices.

    Returns:
        List of device info dicts with keys: index, name, max_input_channels,
        max_output_channels, default_samplerate.
    """
    devices = sd.query_devices()
    result = []
    for i, dev in enumerate(devices):
        result.append(
            {
                "index": i,
                "name": dev["name"],
                "max_input_channels": dev["max_input_channels"],
                "max_output_channels": dev["max_output_channels"],
                "default_samplerate": dev["default_samplerate"],
            }
        )
    return result


def resolve_device(device: str | int | None, kind: str = "input") -> int | str | None:
    """Resolve a device specifier to a value sounddevice accepts.

    Args:
        device: Device name (substring match), integer index, or None for default.
        kind: "input" or "output".

    Returns:
        Resolved device identifier, or None for system default.

    Raises:
        VoiceError: If the specified device cannot be found.
    """
    if device is None:
        return None

    if isinstance(device, int):
        try:
            info = sd.query_devices(device)
            channel_key = "max_input_channels" if kind == "input" else "max_output_channels"
            if info[channel_key] == 0:
                raise VoiceError(f"Device {device} ({info['name']}) has no {kind} channels")
            return device
        except sd.PortAudioError as exc:
            raise VoiceError(f"Audio device index {device} not found: {exc}") from exc

    # String — try name substring match
    if isinstance(device, str):
        # Try parsing as integer first
        try:
            return resolve_device(int(device), kind)
        except (ValueError, VoiceError):
            pass

        # Substring match against device names
        try:
            info = sd.query_devices(device, kind=kind)
            return info["name"]
        except ValueError as exc:
            raise VoiceError(f"No {kind} device matching '{device}': {exc}") from exc

    return None


# ---------------------------------------------------------------------------
# AudioRecorder
# ---------------------------------------------------------------------------


class AudioRecorder:
    """Record audio from a microphone with WebRTC voice activity detection.

    Uses webrtcvad to robustly detect when the user starts and stops
    speaking, avoiding the fragility of simple RMS thresholding.  The
    recorder analyzes 30 ms audio frames and classifies each as speech
    or non-speech.  Recording stops when ``silence_frames`` consecutive
    non-speech frames are detected after speech has begun.

    A leading silence timeout prevents waiting indefinitely when the
    microphone receives no speech input.

    Args:
        sample_rate: Sample rate in Hz (default 16 000).
        channels: Number of channels (default 1 — mono).
        vad_aggressiveness: WebRTC VAD aggressiveness (0-3, default 1).
        silence_frames: Non-speech frames before auto-stop (default 50 = 1.5s).
        device: Input device name, index, or None for system default.
    """

    def __init__(
        self,
        sample_rate: int = DEFAULT_SAMPLE_RATE,
        channels: int = DEFAULT_CHANNELS,
        vad_aggressiveness: int = DEFAULT_VAD_AGGRESSIVENESS,
        silence_frames: int = DEFAULT_SILENCE_FRAMES,
        device: str | int | None = None,
    ) -> None:
        self.sample_rate = sample_rate
        self.channels = channels
        self.vad_aggressiveness = vad_aggressiveness
        self.silence_frames = silence_frames
        self.device = device
        self._stop_event = threading.Event()

    async def record_until_silence(
        self,
        on_status: RecordingStatusCallback | None = None,
    ) -> np.ndarray:
        """Record from the microphone until end-of-speech is detected.

        Args:
            on_status: Optional callback invoked every ~300ms with
                ``(speech_detected, elapsed_seconds, rms_level)``.
                Can be used for live UI feedback.

        Returns:
            A 1-D float32 numpy array of recorded audio samples at
            ``self.sample_rate``.

        Raises:
            VoiceError: If no audio device is available or recording fails.
        """
        loop = asyncio.get_running_loop()
        return await loop.run_in_executor(None, self._record_blocking, on_status)

    def stop(self) -> None:
        """Signal the recorder to stop immediately."""
        self._stop_event.set()

    # -- private --

    def _record_blocking(
        self,
        on_status: RecordingStatusCallback | None = None,
    ) -> np.ndarray:
        """Blocking recording loop with VAD (runs in a thread)."""
        self._stop_event.clear()

        vad = _VoiceActivityDetector(
            aggressiveness=self.vad_aggressiveness,
            sample_rate=self.sample_rate,
        )

        max_frames = int(MAX_RECORDING_DURATION_S * 1000 / VAD_FRAME_DURATION_MS)
        max_leading_silence_frames = int(MAX_LEADING_SILENCE_S * 1000 / VAD_FRAME_DURATION_MS)
        resolved_device = resolve_device(self.device, kind="input")

        # How often to fire the status callback (every ~300ms = 10 frames at 30ms)
        status_interval = 10

        buffers: list[np.ndarray] = []
        non_speech_count = 0
        speech_detected = False
        first_speech_frame = -1

        try:
            with sd.InputStream(
                samplerate=self.sample_rate,
                channels=self.channels,
                dtype="float32",
                blocksize=VAD_FRAME_SAMPLES,
                device=resolved_device,
            ) as stream:
                for frame_idx in range(max_frames):
                    if self._stop_event.is_set():
                        break

                    chunk, _overflowed = stream.read(VAD_FRAME_SAMPLES)
                    chunk = chunk.flatten()
                    buffers.append(chunk)

                    is_speech = vad.is_speech(chunk)

                    if is_speech:
                        non_speech_count = 0
                        if not speech_detected:
                            speech_detected = True
                            first_speech_frame = frame_idx
                    else:
                        non_speech_count += 1

                    # Fire status callback periodically
                    if on_status and frame_idx % status_interval == 0:
                        elapsed = (frame_idx + 1) * VAD_FRAME_DURATION_MS / 1000.0
                        level = _rms(chunk)
                        on_status(speech_detected, elapsed, level)

                    # Stop on silence after speech
                    if speech_detected and non_speech_count >= self.silence_frames:
                        break

                    # Give up if no speech detected after leading silence timeout
                    if not speech_detected and frame_idx >= max_leading_silence_frames:
                        log.debug(f"No speech detected after {MAX_LEADING_SILENCE_S}s, stopping")
                        break

        except sd.PortAudioError as exc:
            raise VoiceError(f"Audio input device error: {exc}") from exc
        except Exception as exc:
            raise VoiceError(f"Recording failed: {exc}") from exc

        if not buffers or not speech_detected:
            log.warning("Recording produced no speech")
            return np.array([], dtype=np.float32)

        audio = np.concatenate(buffers)

        # Trim leading silence: keep 150ms (5 frames) before first speech
        if first_speech_frame > 5:
            trim_start = (first_speech_frame - 5) * VAD_FRAME_SAMPLES
            audio = audio[trim_start:]

        duration = len(audio) / self.sample_rate
        log.debug(f"Recorded {duration:.1f}s of audio ({len(audio)} samples)")
        return audio


# ---------------------------------------------------------------------------
# AudioPlayer
# ---------------------------------------------------------------------------


class AudioPlayer:
    """Play audio arrays through an output device.

    Supports asynchronous playback with interruption — calling :meth:`stop`
    will immediately silence the output.

    Args:
        sample_rate: Playback sample rate in Hz (must match the audio data).
        device: Output device name, index, or None for system default.
    """

    def __init__(
        self,
        sample_rate: int = PLAYBACK_SAMPLE_RATE,
        device: str | int | None = None,
    ) -> None:
        self.sample_rate = sample_rate
        self.device = device
        self._stop_event = threading.Event()
        self._stream: sd.OutputStream | None = None

    async def play(self, audio: np.ndarray) -> None:
        """Play an audio array asynchronously.

        Args:
            audio: 1-D float32 numpy array of audio samples.

        Raises:
            VoiceError: If the audio output device is unavailable.
        """
        if audio.size == 0:
            return
        loop = asyncio.get_running_loop()
        await loop.run_in_executor(None, self._play_blocking, audio)

    def stop(self) -> None:
        """Interrupt playback immediately."""
        self._stop_event.set()
        # Don't call stream.abort() — it triggers PortAudio AUHAL errors on macOS.
        # Setting _stop_event causes the callback to fill zeros and raise
        # CallbackAbort, which lets PortAudio shut down the stream gracefully.

    @property
    def is_playing(self) -> bool:
        """Return True if audio is currently being played."""
        return self._stream is not None and self._stream.active

    # -- private --

    def _play_blocking(self, audio: np.ndarray) -> None:
        """Blocking playback (runs in a thread)."""
        self._stop_event.clear()

        # Ensure audio is 2-D (samples, channels) for sounddevice
        if audio.ndim == 1:
            audio = audio.reshape(-1, 1)

        resolved_device = resolve_device(self.device, kind="output")

        try:
            finished = threading.Event()
            pos = 0

            def _callback(
                outdata: np.ndarray,
                frames: int,
                _time: object,
                _status: object,
            ) -> None:
                nonlocal pos
                if self._stop_event.is_set():
                    outdata.fill(0)
                    raise sd.CallbackAbort
                end = pos + frames
                if end >= len(audio):
                    chunk = audio[pos:]
                    outdata[: len(chunk)] = chunk
                    outdata[len(chunk) :] = 0
                    raise sd.CallbackStop
                outdata[:] = audio[pos:end]
                pos = end

            self._stream = sd.OutputStream(
                samplerate=self.sample_rate,
                channels=audio.shape[1],
                dtype="float32",
                callback=_callback,
                finished_callback=finished.set,
                device=resolved_device,
            )
            self._stream.start()
            finished.wait()
            self._stream.close()
        except sd.PortAudioError as exc:
            raise VoiceError(f"Audio output device error: {exc}") from exc
        except Exception as exc:
            if self._stop_event.is_set():
                return  # interrupted — not an error
            raise VoiceError(f"Playback failed: {exc}") from exc
        finally:
            self._stream = None
