"""
AudioService - sounddevice-based microphone recording with proper device
detection, native sample-rate capture, and resampling for Whisper (16 kHz).
"""
from __future__ import annotations

import asyncio
import sys
import threading
from collections import deque
from pathlib import Path
from typing import TYPE_CHECKING

import numpy as np
from loguru import logger

from .base_service import BaseService
from .i18n import t
from .types import AppState, AudioDevice

if TYPE_CHECKING:
    from bridge.signals import AppSignals

TARGET_SAMPLE_RATE = 16000  # Whisper expects 16 kHz
CHANNELS = 1
DTYPE = "float32"
BLOCKSIZE = 1024
MAX_RECORD_SECONDS = 300  # 5 minutes
WARN_AFTER_SECONDS = 240  # warn at 4 minutes (1 min before limit)
VAD_CHUNK_SAMPLES = 512   # Silero VAD window size at 16 kHz
VAD_SPEECH_THRESHOLD = 0.5  # Speech probability threshold
VAD_RMS_THRESHOLD = 0.01  # Fallback when Silero VAD unavailable

# Virtual audio cables that appear even under WASAPI — the only name-based
# filter still needed after switching to WASAPI-only enumeration.
_VIRTUAL_DEVICE_KEYWORDS = (
    "cable output", "vb-audio",
    "voicemeeter",
    "virtual audio cable", "virtual cable",
)


class _StreamingVAD:
    """Streaming wrapper around faster-whisper's bundled Silero VAD v6 ONNX model.

    Unlike faster-whisper's batch SileroVADModel, this preserves LSTM state (h/c)
    across calls, enabling real-time per-chunk speech probability during recording.
    """

    _CONTEXT = 64

    def __init__(self) -> None:
        import os

        import onnxruntime
        from faster_whisper.vad import get_assets_path

        path = os.path.join(get_assets_path(), "silero_vad_v6.onnx")
        opts = onnxruntime.SessionOptions()
        opts.inter_op_num_threads = 1
        opts.intra_op_num_threads = 1
        opts.log_severity_level = 4
        self._session = onnxruntime.InferenceSession(
            path, providers=["CPUExecutionProvider"], sess_options=opts,
        )
        self.reset()

    def reset(self) -> None:
        """Reset LSTM state for a new recording session."""
        self._h = np.zeros((1, 1, 128), dtype=np.float32)
        self._c = np.zeros((1, 1, 128), dtype=np.float32)
        self._ctx = np.zeros((1, self._CONTEXT), dtype=np.float32)

    def __call__(self, chunk_512: np.ndarray) -> float:
        """Process exactly 512 samples at 16 kHz, return speech probability [0..1]."""
        inp = np.concatenate([self._ctx, chunk_512.reshape(1, -1)], axis=1)
        self._ctx = chunk_512[-self._CONTEXT:].reshape(1, -1).copy()
        out, self._h, self._c = self._session.run(
            None, {"input": inp, "h": self._h, "c": self._c},
        )
        return float(out[0])


class AudioService(BaseService):
    # Class-level lock serialising all PortAudio C-calls across threads.
    # PortAudio's Pa_Terminate() invalidates state used by concurrent
    # Pa_OpenStream / Pa_GetDeviceInfo calls — without this lock, the
    # 5-second device-rescan races recording/playback threads and crashes
    # libportaudio64bit.dll with an access violation (0xc0000005).
    _PA_LOCK = threading.Lock()

    def __init__(self, signals: AppSignals) -> None:
        self._signals = signals
        self._ready = False
        self._recording = False
        self._playing = False  # True while play_sound's stream is active
        self._lock = threading.Lock()
        self._buffer: deque[np.ndarray] = deque(
            maxlen=int(MAX_RECORD_SECONDS * 48000 / BLOCKSIZE)  # worst-case at 48 kHz
        )
        self._stream = None
        self._device_index: int | None = None
        self._capture_rate: int = TARGET_SAMPLE_RATE  # actual rate used during recording
        self._vad: _StreamingVAD | None = None
        self._vad_chunks: deque[np.ndarray] = deque(maxlen=200)
        self._vad_residual = np.array([], dtype=np.float32)
        self._speech_detected = False
        self._silence_start: float | None = None
        self._latest_speech_prob: float = 0.0
        self._latest_rms: float = 0.0  # written by audio thread, read by main thread
        self._level_task: asyncio.Task | None = None
        self._loop: asyncio.AbstractEventLoop | None = None
        self._recording_start: float = 0.0
        self._warned_duration = False
        self._captured_total: int = 0  # monotonic samples this recording
        self._test_mode = False
        self._stream_error_count: int = 0  # consecutive callback errors
        self._session_gen: int = 0  # bumped per recording session (VAD isolation)

    async def start(self) -> None:
        try:
            import sounddevice as sd  # type: ignore[import]
            self._sd = sd
            logger.debug("AudioService: sounddevice available")
        except ImportError:
            logger.error("sounddevice not installed")
            self._signals.error_occurred.emit(t("err_audio_lib"))
            return
        self._loop = asyncio.get_running_loop()

        # Prewarm scipy BEFORE _ready flips: _resample imports scipy.signal on
        # first use (52 MB package in the frozen build; the GIL-held import
        # froze the waveform of the FIRST recording on slower machines when it
        # raced a fire-and-forget prewarm). Awaiting it here makes it part of
        # audio readiness — a hotkey pressed earlier is queued by the app and
        # starts the moment we are ready, with scipy guaranteed warm.
        def _prewarm_scipy() -> None:
            # The prewarm is a pure optimization and now sits on the awaited
            # readiness path — NO failure here may brick the audio subsystem
            # (_resample falls back to np.interp without scipy anyway).
            try:
                import scipy.signal  # noqa: F401
            except Exception as e:
                logger.warning("scipy prewarm failed (resample falls back): {}", e)

        await self._loop.run_in_executor(None, _prewarm_scipy)

        # ONNX Runtime segfaults in Nuitka-compiled builds when creating
        # InferenceSession. Detect frozen builds and skip Silero VAD.
        _is_frozen = getattr(sys, "frozen", False) or (
            Path(sys.argv[0]).resolve().parent / "assets"
        ).is_dir()

        if _is_frozen:
            logger.info("Frozen build detected, using RMS-based VAD (no ONNX)")
            self._vad = None
        else:
            try:
                self._vad = await self._loop.run_in_executor(None, _StreamingVAD)
                # Prewarm: the FIRST inference compiles/initialises ONNX
                # kernels while holding the GIL (~1s) — paying that during a
                # live recording froze the waveform. Pay it here instead.
                await self._loop.run_in_executor(
                    None, self._vad, np.zeros(VAD_CHUNK_SAMPLES, dtype=np.float32)
                )
                self._vad.reset()
                logger.info("Silero VAD loaded for real-time speech detection")
            except Exception as e:
                logger.warning("Silero VAD unavailable, using RMS fallback: {}", e)
                self._vad = None
        self._ready = True
        self._last_device_names: set[str] = set()
        self._device_monitor_task = self._loop.create_task(
            self._monitor_devices()
        )
        logger.info("AudioService started")

    async def stop(self) -> None:
        if hasattr(self, "_device_monitor_task") and self._device_monitor_task:
            self._device_monitor_task.cancel()
            try:
                await self._device_monitor_task
            except asyncio.CancelledError:
                pass
        if self._recording:
            await self.async_stop_recording()
        self._ready = False

    # ─── Device enumeration ──────────────────────────────────────────────

    def _get_wasapi_index(self) -> int | None:
        """Return the PortAudio host API index for WASAPI, or None."""
        try:
            for idx, api in enumerate(self._sd.query_hostapis()):
                if "WASAPI" in api.get("name", ""):
                    return idx
        except Exception as e:
            logger.debug("WASAPI index lookup failed: {}", e)
        return None

    def _is_wasapi_device(self, device_index: int | None) -> bool:
        """Check whether a device index belongs to the WASAPI host API."""
        if device_index is None:
            return False
        wasapi_idx = self._get_wasapi_index()
        if wasapi_idx is None:
            return False
        try:
            info = self._sd.query_devices(device_index)
            return info.get("hostapi") == wasapi_idx
        except Exception:
            return False

    def list_devices(self) -> list[AudioDevice]:
        """List microphone input devices via WASAPI.

        Only WASAPI devices are shown because each physical device appears
        exactly once under WASAPI — matching what Windows shows the user.
        MME, DirectSound, and WDM-KS duplicates are excluded by design.
        Falls back to all host APIs only if WASAPI has no input devices.
        """
        if not self._ready:
            return []
        try:
            all_devices = self._sd.query_devices()
            wasapi_idx = self._get_wasapi_index()

            result: list[AudioDevice] = []
            for i, d in enumerate(all_devices):
                if d["max_input_channels"] <= 0:
                    continue
                # Only WASAPI devices (no MME/DirectSound/WDM-KS duplicates)
                if wasapi_idx is not None and d.get("hostapi") != wasapi_idx:
                    continue
                # Skip virtual audio cables
                name_lower = d["name"].strip().lower()
                if any(kw in name_lower for kw in _VIRTUAL_DEVICE_KEYWORDS):
                    continue

                result.append({
                    "index": i,
                    "name": d["name"].strip(),
                    "max_input_channels": d["max_input_channels"],
                    "default_samplerate": d.get("default_samplerate", 48000),
                    "hostapi": d.get("hostapi", -1),
                })

            # Fallback: if WASAPI found nothing, include all input devices
            if not result and wasapi_idx is not None:
                logger.warning("No WASAPI input devices found, falling back to all APIs")
                for i, d in enumerate(all_devices):
                    if d["max_input_channels"] <= 0:
                        continue
                    name_lower = d["name"].strip().lower()
                    if any(kw in name_lower for kw in _VIRTUAL_DEVICE_KEYWORDS):
                        continue
                    result.append({
                        "index": i,
                        "name": d["name"].strip(),
                        "max_input_channels": d["max_input_channels"],
                        "default_samplerate": d.get("default_samplerate", 44100),
                        "hostapi": d.get("hostapi", -1),
                    })

            logger.debug("list_devices: {} mic(s) found", len(result))
            for d in result:
                logger.debug("  [{}] {} ({}Hz, api={})",
                             d["index"], d["name"],
                             int(d["default_samplerate"]), d["hostapi"])
            return result
        except Exception as e:
            logger.error("Failed to list audio devices: {}", e)
            return []

    def _resolve_device(self) -> tuple[int | None, int]:
        """Determine device index and native sample rate to use.

        Returns (device_index, sample_rate).
        - If user selected a device, use that with its native rate.
        - If 'System Default' (None), use the WASAPI default input.
        """
        if self._device_index is not None:
            # User selected a specific device — verify it still exists
            try:
                info = self._sd.query_devices(self._device_index)
                if info["max_input_channels"] > 0:
                    rate = int(info.get("default_samplerate", 48000))
                    logger.debug("_resolve_device: user selected [{}] at {}Hz",
                                 self._device_index, rate)
                    return self._device_index, rate
                logger.warning("Selected device [{}] has no input channels",
                               self._device_index)
            except Exception as e:
                logger.warning("Selected device [{}] unavailable: {}",
                               self._device_index, e)
            # Device gone — clear selection, fall through to default
            logger.info("Falling back to system default audio device")
            self._device_index = None
            self._signals.error_occurred.emit(
                "Selected microphone unavailable. Using system default."
            )

        # System Default: WASAPI default input
        wasapi_idx = self._get_wasapi_index()
        if wasapi_idx is not None:
            try:
                api_info = self._sd.query_hostapis(wasapi_idx)
                wasapi_default_in = api_info.get("default_input_device", -1)
                if wasapi_default_in >= 0:
                    info = self._sd.query_devices(wasapi_default_in)
                    if info["max_input_channels"] > 0:
                        rate = int(info.get("default_samplerate", 48000))
                        logger.info("_resolve_device: WASAPI default [{}] '{}' at {}Hz",
                                    wasapi_default_in, info["name"], rate)
                        return wasapi_default_in, rate
            except Exception as e:
                logger.warning("WASAPI default input lookup failed: {}", e)

        # Fallback: PortAudio default (usually MME)
        try:
            default_in = self._sd.default.device[0]
            if default_in >= 0:
                info = self._sd.query_devices(default_in)
                rate = int(info.get("default_samplerate", 44100))
                logger.info("_resolve_device: PortAudio default [{}] '{}' at {}Hz",
                            default_in, info["name"], rate)
                return default_in, rate
        except Exception as e:
            logger.debug("PortAudio default device lookup failed: {}", e)

        # Last resort: let sounddevice pick
        logger.warning("_resolve_device: no device resolved, using sounddevice default")
        return None, 44100

    # ─── Recording ───────────────────────────────────────────────────────

    def _audio_callback(self, indata: np.ndarray, frames: int, time_info, status) -> None:
        if status:
            logger.warning("Audio status: {}", status)
            self._stream_error_count += 1
        else:
            self._stream_error_count = 0
        if not self._recording:
            return

        chunk = indata[:, 0].copy()  # mono
        with self._lock:
            self._buffer.append(chunk)
            # Monotonic total of captured samples this recording (for live
            # transcription's absolute windowing; unaffected by deque eviction).
            self._captured_total += len(chunk)

        # Store RMS for the level-polling task (thread-safe on CPython via GIL)
        rms = float(np.sqrt(np.mean(chunk ** 2)))
        self._latest_rms = rms

        # Store chunk for VAD processing (consumed by _process_vad in _poll_level)
        self._vad_chunks.append(chunk)

    def start_recording(self, *, test_mode: bool = False) -> None:
        """Synchronous start (called from Qt slot).

        When *test_mode* is True the main app state machine is left untouched
        (no ``state_changed`` emission) and VAD processing is skipped.
        ``level_updated`` still fires so the settings waveform can visualise input.
        """
        if self._recording or not self._ready:
            logger.warning("start_recording ignored (recording={}, ready={})",
                           self._recording, self._ready)
            return

        self._test_mode = test_mode
        device_idx, native_rate = self._resolve_device()
        self._capture_rate = native_rate

        # Force microphone to 100% volume before recording
        try:
            from utils.win32_audio import set_mic_volume
            set_mic_volume(1.0)
        except Exception as e:
            logger.debug("Could not set mic volume: {}", e)

        try:
            import time
            # New session: a _process_vad batch dispatched for the PREVIOUS
            # session (now on a worker thread) must not touch the state that
            # is reset below — it checks this generation and bails.
            self._session_gen += 1
            self._buffer.clear()
            self._speech_detected = False
            self._silence_start = None
            self._vad_chunks.clear()
            self._vad_residual = np.array([], dtype=np.float32)
            self._latest_speech_prob = 0.0
            if self._vad is not None:
                self._vad.reset()
            self._latest_rms = 0.0
            self._stream_error_count = 0
            self._recording_start = time.monotonic()
            self._warned_duration = False
            self._captured_total = 0
            self._recording = True

            # Serialise PortAudio stream creation against the device-rescan
            # monitor and any concurrent playback — _terminate/_initialize
            # called during InputStream creation crashes libportaudio.
            with AudioService._PA_LOCK:
                # Use WASAPI shared mode with auto_convert for sample rate
                # compatibility (only valid for WASAPI devices).
                extra = None
                if self._is_wasapi_device(device_idx):
                    try:
                        extra = self._sd.WasapiSettings(auto_convert=True)
                    except Exception as e:
                        logger.debug("WasapiSettings unavailable: {}", e)

                self._stream = self._sd.InputStream(
                    samplerate=native_rate,
                    channels=CHANNELS,
                    dtype=DTYPE,
                    blocksize=BLOCKSIZE,
                    device=device_idx,
                    callback=self._audio_callback,
                    extra_settings=extra,
                )
                self._stream.start()

            # Start the level-polling task on the main event loop
            if self._loop is not None:
                self._loop.call_soon_threadsafe(self._start_level_polling)

            if not test_mode:
                self._signals.state_changed.emit(AppState.LISTENING)
            logger.info("Recording started: device=[{}] rate={}Hz test_mode={}", device_idx, native_rate, test_mode)
        except Exception as e:
            logger.error("Failed to start recording: {}", e)
            self._recording = False
            self._test_mode = False
            self._signals.error_occurred.emit(t("err_mic"))
            if not test_mode:
                self._signals.state_changed.emit(AppState.ERROR)

    def _start_level_polling(self) -> None:
        """Create an async task that polls _latest_rms and emits level_updated."""
        if self._level_task is not None and not self._level_task.done():
            return
        self._level_task = self._loop.create_task(self._poll_level())

    async def _poll_level(self) -> None:
        """Poll RMS level at ~30 fps, run VAD, and detect device disconnect."""
        import time
        while self._recording:
            self._signals.level_updated.emit(self._latest_rms)

            # Detect device disconnect: many consecutive stream errors
            if self._stream_error_count >= 10:
                logger.error("Audio stream lost ({} consecutive errors), stopping recording",
                             self._stream_error_count)
                self._signals.error_occurred.emit(
                    "Microphone disconnected. Recording stopped."
                )
                self._signals.recording_should_stop.emit()
                break

            # In test mode, skip VAD processing and duration warnings
            if not self._test_mode:
                # Process VAD for visualization only (no auto-stop —
                # the user controls start/stop via hotkey in both hold and
                # toggle mode). On a worker thread: Silero inference on weak
                # CPUs otherwise starves the GUI loop (stuttering waveform).
                await self._loop.run_in_executor(
                    None, self._process_vad, self._session_gen)

                # Warn when approaching the recording limit
                if not self._warned_duration:
                    elapsed = time.monotonic() - self._recording_start
                    if elapsed >= WARN_AFTER_SECONDS:
                        self._warned_duration = True
                        remaining = MAX_RECORD_SECONDS - int(elapsed)
                        self._signals.error_occurred.emit(
                            f"Recording limit in {remaining}s. Finish soon."
                        )

            await asyncio.sleep(0.033)  # ~30 fps
        # Emit zero when done
        self._signals.level_updated.emit(0.0)

    def _process_vad(self, session_gen: int | None = None) -> None:
        """Drain queued audio chunks from the callback thread and run Silero VAD
        to track speech probability for visualization.

        Runs on a worker thread. ``session_gen`` guards against a batch from a
        finished session mutating the state a quick restart already reset
        (checked at entry; a mid-inference restart can still lose one ~30ms
        window of visualization state, which is harmless)."""
        import time

        if session_gen is not None and session_gen != self._session_gen:
            return

        # Drain all available chunks (thread-safe: deque append/popleft under GIL)
        chunks: list[np.ndarray] = []
        while self._vad_chunks:
            try:
                chunks.append(self._vad_chunks.popleft())
            except IndexError:
                break
        if not chunks:
            return

        audio = np.concatenate(chunks)

        # Resample to 16 kHz for VAD processing
        if self._capture_rate != TARGET_SAMPLE_RATE:
            audio = self._resample(audio, self._capture_rate, TARGET_SAMPLE_RATE)

        # Prepend leftover samples from previous iteration
        if len(self._vad_residual) > 0:
            audio = np.concatenate([self._vad_residual, audio])

        if self._vad is not None:
            # Silero VAD: process in 512-sample windows
            pos = 0
            while pos + VAD_CHUNK_SAMPLES <= len(audio):
                prob = self._vad(audio[pos : pos + VAD_CHUNK_SAMPLES])
                self._latest_speech_prob = prob

                if prob >= VAD_SPEECH_THRESHOLD:
                    self._speech_detected = True
                    self._silence_start = None
                elif self._speech_detected and self._silence_start is None:
                    self._silence_start = time.monotonic()
                pos += VAD_CHUNK_SAMPLES

            self._vad_residual = (
                audio[pos:].copy() if pos < len(audio)
                else np.array([], dtype=np.float32)
            )
        else:
            # RMS fallback when Silero VAD is unavailable
            rms = float(np.sqrt(np.mean(audio ** 2)))
            if rms >= VAD_RMS_THRESHOLD:
                self._speech_detected = True
                self._silence_start = None
            elif self._speech_detected and self._silence_start is None:
                self._silence_start = time.monotonic()

    def stop_recording(self) -> np.ndarray | None:
        """Synchronous stop, returns audio buffer resampled to 16 kHz."""
        if not self._recording:
            return None
        # Hold the PA lock across stream teardown so the device-rescan
        # monitor cannot terminate PortAudio mid-close. ``_recording``
        # is cleared inside the lock to keep the rescan-skip check
        # consistent with stream state.
        with AudioService._PA_LOCK:
            self._recording = False
            self._test_mode = False
            if self._stream is not None:
                try:
                    self._stream.stop()
                    self._stream.close()
                except Exception as e:
                    logger.warning("Error closing audio stream: {}", e)
                self._stream = None

        with self._lock:
            if not self._buffer:
                return None
            audio = np.concatenate(list(self._buffer), axis=0).astype(np.float32)
            self._buffer.clear()

        duration = len(audio) / self._capture_rate
        logger.info("Recording stopped: {} samples ({:.1f}s) at {}Hz",
                     len(audio), duration, self._capture_rate)

        # Resample to 16 kHz if captured at a different rate
        if self._capture_rate != TARGET_SAMPLE_RATE:
            audio = self._resample(audio, self._capture_rate, TARGET_SAMPLE_RATE)
            logger.debug("Resampled to {}Hz: {} samples ({:.1f}s)",
                         TARGET_SAMPLE_RATE, len(audio), len(audio) / TARGET_SAMPLE_RATE)

        # Trim trailing silence to reduce Whisper processing time
        audio = self._trim_trailing_silence(audio)

        return audio

    def captured_samples_16k(self) -> int:
        """Cheap: total captured samples so far in the 16 kHz domain (no copy or
        resample). Lets the live loop decide whether to bother snapshotting."""
        rate = self._capture_rate or TARGET_SAMPLE_RATE
        return int(round(self._captured_total * TARGET_SAMPLE_RATE / rate))

    def snapshot_buffer(self) -> tuple[np.ndarray, int]:
        """Return (audio_16k, base_offset_16k) captured so far, non-destructive.

        base_offset_16k is how many 16 kHz samples were already evicted from the
        front of the (bounded) buffer since recording started, so a caller using
        absolute-from-start indices can map them onto this snapshot even after
        the deque drops old chunks on very long recordings. Thread-safe."""
        with self._lock:
            captured = self._captured_total
            if not self._buffer:
                buf_len = 0
                audio = np.zeros(0, dtype=np.float32)
            else:
                audio = np.concatenate(list(self._buffer), axis=0).astype(np.float32)
                buf_len = len(audio)
        rate = self._capture_rate or TARGET_SAMPLE_RATE
        if rate != TARGET_SAMPLE_RATE:
            audio = self._resample(audio, rate, TARGET_SAMPLE_RATE)
        dropped_capture = max(0, captured - buf_len)
        base16k = int(round(dropped_capture * TARGET_SAMPLE_RATE / rate))
        return audio, base16k

    @staticmethod
    def _trim_trailing_silence(
        audio: np.ndarray, threshold: float = 0.005, min_samples: int = 4000,
    ) -> np.ndarray:
        """Remove trailing silence, keeping at least min_samples (~250ms at 16kHz)."""
        if len(audio) <= min_samples:
            return audio
        # Walk backwards from end to find last sample above threshold
        abs_audio = np.abs(audio)
        # Check in chunks of 800 samples (~50ms) for efficiency
        chunk = 800
        end = len(audio)
        while end > min_samples:
            start = max(end - chunk, 0)
            if np.max(abs_audio[start:end]) > threshold:
                # Keep a small tail (1600 samples = 100ms) after last loud sample
                end = min(end + 1600, len(audio))
                break
            end = start
        if end < len(audio):
            trimmed = len(audio) - end
            logger.debug("Trimmed {:.0f}ms trailing silence", trimmed / TARGET_SAMPLE_RATE * 1000)
            return audio[:end]
        return audio

    @staticmethod
    def _resample(audio: np.ndarray, from_rate: int, to_rate: int) -> np.ndarray:
        """Resample audio with anti-aliasing via polyphase filtering."""
        if from_rate == to_rate:
            return audio
        try:
            from scipy.signal import resample_poly
            from math import gcd
            g = gcd(to_rate, from_rate)
            return resample_poly(audio, to_rate // g, from_rate // g).astype(np.float32)
        except ImportError:
            # Fallback to linear interpolation if scipy unavailable
            ratio = to_rate / from_rate
            n_out = int(len(audio) * ratio)
            indices = np.linspace(0, len(audio) - 1, n_out)
            return np.interp(indices, np.arange(len(audio)), audio).astype(np.float32)

    async def async_start_recording(self) -> None:
        loop = asyncio.get_running_loop()
        await loop.run_in_executor(None, self.start_recording)

    async def async_stop_recording(self) -> np.ndarray | None:
        loop = asyncio.get_running_loop()
        return await loop.run_in_executor(None, self.stop_recording)

    # ─── Device monitoring ───────────────────────────────────────────────

    def _rescan_portaudio(self) -> None:
        """Force PortAudio to re-enumerate audio devices.

        sounddevice/PortAudio caches the device list at init time.
        New USB headsets, Bluetooth mics, or default-device changes are
        invisible until PortAudio is re-initialised.  Only safe to call
        when no stream is open (i.e. not recording or playing).

        Guarded by ``_PA_LOCK`` plus the ``_recording``/``_playing``
        flags — terminating PortAudio while a stream is active causes a
        hard access violation in libportaudio64bit.dll.
        """
        # Skip immediately if the lock is contended — better to miss
        # one rescan tick than to block the monitor or risk a race.
        if not AudioService._PA_LOCK.acquire(timeout=0.5):
            logger.debug("PortAudio rescan skipped: lock busy")
            return
        try:
            if self._recording or self._playing:
                logger.debug(
                    "PortAudio rescan skipped: recording={} playing={}",
                    self._recording, self._playing,
                )
                return
            try:
                self._sd._terminate()
                self._sd._initialize()
            except Exception as e:
                logger.debug("PortAudio rescan failed: {}", e)
        finally:
            AudioService._PA_LOCK.release()

    async def _monitor_devices(self) -> None:
        """Poll for device changes every 5 seconds and emit signal only on change."""
        initial = self.list_devices()
        self._last_device_names = {d["name"] for d in initial}
        while True:
            try:
                await asyncio.sleep(5)
                if not self._ready:
                    continue
                # Re-initialise PortAudio to discover newly connected devices
                # and updated Windows default. Only when not recording (stream open).
                if not self._recording:
                    await self._loop.run_in_executor(None, self._rescan_portaudio)
                devices = self.list_devices()
                current_names = {d["name"] for d in devices}
                if current_names != self._last_device_names:
                    self._last_device_names = current_names
                    # Always update device list (even during recording)
                    self._signals.device_list_updated.emit(devices)
                    logger.info("Audio devices changed: {}", [d["name"] for d in devices])
            except asyncio.CancelledError:
                break
            except Exception as e:
                logger.debug("Device monitor error: {}", e)

    def set_device(self, index: int | None) -> None:
        self._device_index = index
        logger.info("Audio device set to index={}", index)

    def play_sound(self, path: str, volume: float = 1.0) -> None:
        """Play a WAV file non-blocking in a background thread.

        ``_playing`` is set True before the play thread starts so the
        device-rescan monitor skips while playback is in flight.  All
        PortAudio C-calls (query + play start) happen inside
        ``_PA_LOCK``; the blocking ``wait()`` runs outside the lock so
        recording start is not delayed by playback length.
        """
        # Set the flag on the calling thread so a rescan tick that
        # races the thread spawn still sees `_playing == True`.
        self._playing = True

        def _play():
            try:
                import wave as _wave
                import sounddevice as _sd_local

                with _wave.open(path, "rb") as wf:
                    rate = wf.getframerate()
                    n = wf.getnframes()
                    raw = wf.readframes(n)
                    channels = wf.getnchannels()

                data = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0
                if channels > 1:
                    data = data.reshape(-1, channels)
                data *= max(0.0, min(1.0, volume))

                with AudioService._PA_LOCK:
                    out_device = None
                    try:
                        default_out = _sd_local.default.device[1]
                        if default_out >= 0:
                            out_device = default_out
                    except Exception:
                        pass
                    if out_device is None:
                        try:
                            for i, d in enumerate(_sd_local.query_devices()):
                                if d["max_output_channels"] > 0:
                                    out_device = i
                                    break
                        except Exception:
                            pass
                    _sd_local.play(data, samplerate=rate, device=out_device)
                # wait() blocks but no longer touches PA state — safe
                # to release the lock so concurrent recording can start.
                _sd_local.wait()
            except Exception as e:
                logger.warning("Could not play sound {}: {}", path, e)
            finally:
                self._playing = False

        threading.Thread(target=_play, daemon=True).start()

    @staticmethod
    def play_sound_blocking(path: str, volume: float = 1.0) -> None:
        """Play a WAV file and block until finished."""
        try:
            import wave as _wave
            import sounddevice as _sd

            with _wave.open(path, "rb") as wf:
                rate = wf.getframerate()
                raw = wf.readframes(wf.getnframes())
                channels = wf.getnchannels()

            data = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0
            if channels > 1:
                data = data.reshape(-1, channels)
            data *= max(0.0, min(1.0, volume))
            with AudioService._PA_LOCK:
                _sd.play(data, samplerate=rate)
            _sd.wait()
        except Exception as e:
            logger.warning("Could not play sound {}: {}", path, e)

    @property
    def is_ready(self) -> bool:
        return self._ready

    @property
    def is_recording(self) -> bool:
        return self._recording

    @staticmethod
    def save_test_wav(audio: np.ndarray, path: str) -> None:
        """Save float32 mono audio (16 kHz) to a WAV file for mic-test playback."""
        import wave
        pcm = (np.clip(audio, -1.0, 1.0) * 32767).astype(np.int16)
        with wave.open(path, "wb") as wf:
            wf.setnchannels(1)
            wf.setsampwidth(2)
            wf.setframerate(TARGET_SAMPLE_RATE)
            wf.writeframes(pcm.tobytes())

    @property
    def is_test_mode(self) -> bool:
        return self._test_mode

    @property
    def current_device_index(self) -> int | None:
        return self._device_index

    @property
    def capture_rate(self) -> int:
        return self._capture_rate
