"""Tests for tools.voice_mode -- all mocked, no real microphone or API calls."""

import os
import struct
import time
import wave
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest


def _non_wsl_proc_version(real_open):
    """Return an open() shim that makes host WSL detection deterministic."""
    def _fake_open(file, *args, **kwargs):
        if file == "/proc/version":
            from io import StringIO

            return StringIO("Linux test-kernel")
        return real_open(file, *args, **kwargs)

    return _fake_open


# ============================================================================
# Fixtures
# ============================================================================

@pytest.fixture
def sample_wav(tmp_path):
    """Create a minimal valid WAV file (1 second of silence at 16kHz)."""
    wav_path = tmp_path / "test.wav"
    n_frames = 16000  # 1 second at 16kHz
    silence = struct.pack(f"<{n_frames}h", *([0] * n_frames))

    with wave.open(str(wav_path), "wb") as wf:
        wf.setnchannels(1)
        wf.setsampwidth(2)
        wf.setframerate(16000)
        wf.writeframes(silence)

    return str(wav_path)


@pytest.fixture
def temp_voice_dir(tmp_path, monkeypatch):
    """Redirect _TEMP_DIR to a temporary path."""
    voice_dir = tmp_path / "hermes_voice"
    voice_dir.mkdir()
    monkeypatch.setattr("tools.voice_mode._TEMP_DIR", str(voice_dir))
    return voice_dir


@pytest.fixture
def mock_sd(monkeypatch):
    """Mock _import_audio to return (mock_sd, real_np) so lazy imports work."""
    mock = MagicMock()
    try:
        import numpy as real_np
    except ImportError:
        real_np = MagicMock()

    def _fake_import_audio():
        return mock, real_np

    monkeypatch.setattr("tools.voice_mode._import_audio", _fake_import_audio)
    monkeypatch.setattr("tools.voice_mode._audio_available", lambda: True)
    return mock


class _FakeTime:
    """Stand-in for the ``time`` module with a monotonic clock the test drives.

    Silence detection compares ``time.monotonic()`` deltas against thresholds
    of a few dozen milliseconds.  Driving those deltas with real ``sleep()``
    calls only works when the platform clock is finer-grained than the margin
    the test leaves: ``time.monotonic()`` is ``GetTickCount64()`` (15.625 ms
    resolution) on Windows until CPython 3.13 moved it to
    ``QueryPerformanceCounter()``, so a 60 ms sleep can legitimately measure
    as 46 ms and land under a 50 ms threshold.  Advancing an explicit clock
    keeps the arithmetic exact on every platform.

    Everything other than ``monotonic`` delegates to the real module, so
    ``time.sleep``/``time.strftime`` in the code under test keep working.
    """

    def __init__(self, real_time, start: float = 1000.0) -> None:
        self._real = real_time
        self._now = start

    def monotonic(self) -> float:
        return self._now

    def advance(self, seconds: float) -> None:
        self._now += seconds

    def __getattr__(self, name):
        return getattr(self._real, name)


@pytest.fixture
def fake_clock(monkeypatch):
    """Give voice_mode a hand-driven clock.

    Patches the name ``time`` inside ``tools.voice_mode`` rather than
    ``time.monotonic`` itself -- ``voice_mode.time`` *is* the stdlib module,
    so setting the attribute on it would swap the clock out from under every
    other importer for the duration of the test.
    """
    import time as real_time

    import tools.voice_mode as voice_mode

    clock = _FakeTime(real_time)
    monkeypatch.setattr(voice_mode, "time", clock)
    return clock


# ============================================================================
# detect_audio_environment — WSL / SSH / Docker detection
# ============================================================================

class TestPulseSocketReachable:
    def test_stale_socket_file_not_reachable(self, monkeypatch, tmp_path):
        """A socket file with no listener should not count as reachable."""
        import socket as _socket
        sock_path = tmp_path / "pulse" / "native"
        sock_path.parent.mkdir(parents=True)
        # Create + bind, then close so the path is a stale socket file.
        s = _socket.socket(_socket.AF_UNIX, _socket.SOCK_STREAM)
        s.bind(str(sock_path))
        s.close()
        monkeypatch.delenv("PULSE_SERVER", raising=False)
        monkeypatch.delenv("PULSE_RUNTIME_PATH", raising=False)
        monkeypatch.setenv("XDG_RUNTIME_DIR", str(tmp_path))
        from tools.voice_mode import _pulse_socket_reachable
        assert _pulse_socket_reachable() is False

    def test_listening_socket_reachable_via_xdg_runtime(self, monkeypatch, tmp_path):
        """A live PulseAudio-style socket under XDG_RUNTIME_DIR is reachable (#35622)."""
        import socket as _socket
        sock_path = tmp_path / "pulse" / "native"
        sock_path.parent.mkdir(parents=True)
        server = _socket.socket(_socket.AF_UNIX, _socket.SOCK_STREAM)
        server.bind(str(sock_path))
        server.listen(1)
        try:
            monkeypatch.delenv("PULSE_SERVER", raising=False)
            monkeypatch.delenv("PULSE_RUNTIME_PATH", raising=False)
            monkeypatch.setenv("XDG_RUNTIME_DIR", str(tmp_path))
            from tools.voice_mode import _pulse_socket_reachable
            assert _pulse_socket_reachable() is True
        finally:
            server.close()

class TestDetectAudioEnvironment:
    def test_clean_environment_is_available(self, monkeypatch):
        """No SSH, Docker, or WSL — should be available."""
        monkeypatch.delenv("SSH_CLIENT", raising=False)
        monkeypatch.delenv("SSH_TTY", raising=False)
        monkeypatch.delenv("SSH_CONNECTION", raising=False)
        monkeypatch.setattr("hermes_constants.is_container", lambda: False)
        monkeypatch.setattr("tools.voice_mode._import_audio",
                            lambda: (MagicMock(), MagicMock()))
        monkeypatch.setattr("builtins.open", _non_wsl_proc_version(open))

        from tools.voice_mode import detect_audio_environment
        result = detect_audio_environment()
        assert result["available"] is True
        assert result["warnings"] == []

    def test_ssh_blocks_voice(self, monkeypatch):
        """SSH environment without a reachable sound server should block voice mode."""
        monkeypatch.setenv("SSH_CLIENT", "1.2.3.4 54321 22")
        monkeypatch.delenv("PULSE_SERVER", raising=False)
        monkeypatch.delenv("PIPEWIRE_REMOTE", raising=False)
        monkeypatch.setattr("tools.voice_mode._pulse_socket_reachable", lambda: False)
        monkeypatch.setattr("tools.voice_mode._import_audio",
                            lambda: (MagicMock(), MagicMock()))

        from tools.voice_mode import detect_audio_environment
        result = detect_audio_environment()
        assert result["available"] is False
        assert any("SSH" in w for w in result["warnings"])

    def test_ssh_with_pulse_server_allows_voice(self, monkeypatch):
        """SSH with PULSE_SERVER set should NOT block voice mode (#35622)."""
        monkeypatch.setenv("SSH_CLIENT", "1.2.3.4 54321 22")
        monkeypatch.setenv("PULSE_SERVER", "unix:/run/user/1002/pulse/native")
        monkeypatch.delenv("PIPEWIRE_REMOTE", raising=False)
        monkeypatch.setattr("tools.voice_mode._import_audio",
                            lambda: (MagicMock(), MagicMock()))
        monkeypatch.setattr("builtins.open", _non_wsl_proc_version(open))

        from tools.voice_mode import detect_audio_environment
        result = detect_audio_environment()
        assert result["available"] is True
        assert result["warnings"] == []
        assert any("SSH" in n for n in result.get("notices", []))

    def test_wsl_without_pulse_blocks_voice(self, monkeypatch, tmp_path):
        """WSL without PULSE_SERVER should block voice mode."""
        monkeypatch.delenv("SSH_CLIENT", raising=False)
        monkeypatch.delenv("SSH_TTY", raising=False)
        monkeypatch.delenv("SSH_CONNECTION", raising=False)
        monkeypatch.delenv("PULSE_SERVER", raising=False)
        monkeypatch.setattr("tools.voice_mode._pulse_socket_reachable", lambda: False)
        monkeypatch.setattr("tools.voice_mode._import_audio",
                            lambda: (MagicMock(), MagicMock()))

        proc_version = tmp_path / "proc_version"
        proc_version.write_text("Linux 5.15.0-microsoft-standard-WSL2")

        _real_open = open
        def _fake_open(f, *a, **kw):
            if f == "/proc/version":
                return _real_open(str(proc_version), *a, **kw)
            return _real_open(f, *a, **kw)

        with patch("builtins.open", side_effect=_fake_open):
            from tools.voice_mode import detect_audio_environment
            result = detect_audio_environment()

        assert result["available"] is False
        assert any("WSL" in w for w in result["warnings"])
        assert any("PulseAudio" in w for w in result["warnings"])

    def test_docker_with_pipewire_remote_and_no_devices_allows_voice(self, monkeypatch):
        """PIPEWIRE_REMOTE should bypass empty PortAudio device lists in Docker."""
        monkeypatch.delenv("SSH_CLIENT", raising=False)
        monkeypatch.delenv("SSH_TTY", raising=False)
        monkeypatch.delenv("SSH_CONNECTION", raising=False)
        monkeypatch.delenv("PULSE_SERVER", raising=False)
        monkeypatch.setenv("PIPEWIRE_REMOTE", "/run/user/1000/pipewire-0")
        monkeypatch.setattr("hermes_constants.is_container", lambda: True)

        sd = MagicMock()
        sd.query_devices.return_value = []
        monkeypatch.setattr("tools.voice_mode._import_audio", lambda: (sd, MagicMock()))

        from tools.voice_mode import detect_audio_environment
        result = detect_audio_environment()

        assert result["available"] is True
        assert result["warnings"] == []
        assert any("host audio forwarding" in n.lower() for n in result.get("notices", []))

    def test_docker_without_audio_forwarding_blocks_voice(self, monkeypatch):
        """Docker without PULSE_SERVER/PIPEWIRE_REMOTE keeps blocking voice mode."""
        monkeypatch.delenv("SSH_CLIENT", raising=False)
        monkeypatch.delenv("SSH_TTY", raising=False)
        monkeypatch.delenv("SSH_CONNECTION", raising=False)
        monkeypatch.delenv("PULSE_SERVER", raising=False)
        monkeypatch.delenv("PIPEWIRE_REMOTE", raising=False)
        monkeypatch.setattr("tools.voice_mode._pulse_socket_reachable", lambda: False)
        monkeypatch.setattr("hermes_constants.is_container", lambda: True)
        monkeypatch.setattr("tools.voice_mode._import_audio",
                            lambda: (MagicMock(), MagicMock()))

        from tools.voice_mode import detect_audio_environment
        result = detect_audio_environment()

        assert result["available"] is False
        assert any("container" in w.lower() for w in result["warnings"])
        assert any("PULSE_SERVER" in w or "PIPEWIRE_REMOTE" in w for w in result["warnings"])

# ============================================================================
# check_voice_requirements
# ============================================================================

class TestCheckVoiceRequirements:
    def test_all_requirements_met(self, monkeypatch):
        monkeypatch.setattr("tools.voice_mode._audio_available", lambda: True)
        monkeypatch.setattr("tools.voice_mode.detect_audio_environment",
                            lambda: {"available": True, "warnings": []})
        monkeypatch.setattr("tools.transcription_tools._get_provider", lambda cfg: "openai")

        from tools.voice_mode import check_voice_requirements

        result = check_voice_requirements()
        assert result["available"] is True
        assert result["audio_available"] is True
        assert result["stt_available"] is True
        assert result["missing_packages"] == []


    def test_plugin_stt_provider(self, monkeypatch):
        """Plugin STT provider is recognized."""
        monkeypatch.setattr("tools.voice_mode._audio_available", lambda: True)
        monkeypatch.setattr("tools.voice_mode.detect_audio_environment",
                            lambda: {"available": True, "warnings": []})
        monkeypatch.setattr(
            "tools.transcription_tools._load_stt_config",
            lambda: {"enabled": True, "provider": "my-plugin-stt"},
        )
        plugin_provider = MagicMock()
        plugin_provider.is_available.return_value = True
        monkeypatch.setattr(
            "agent.transcription_registry.get_provider",
            lambda p: plugin_provider if p == "my-plugin-stt" else None,
        )
        monkeypatch.setattr(
            "hermes_cli.plugins._ensure_plugins_discovered",
            lambda force=False: None,
        )

        from tools.voice_mode import check_voice_requirements

        result = check_voice_requirements()
        assert result["available"] is True
        assert result["stt_available"] is True
        assert "STT provider: OK (plugin: my-plugin-stt)" in result["details"]

# ============================================================================
# AudioRecorder
# ============================================================================

class TestCreateAudioRecorder:
    def test_termux_uses_termux_audio_recorder_when_api_present(self, monkeypatch):
        monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
        monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
        monkeypatch.setattr("tools.voice_mode._termux_microphone_command", lambda: "/data/data/com.termux/files/usr/bin/termux-microphone-record")
        monkeypatch.setattr("tools.voice_mode._termux_api_app_installed", lambda: True)

        from tools.voice_mode import create_audio_recorder, TermuxAudioRecorder
        recorder = create_audio_recorder()

        assert isinstance(recorder, TermuxAudioRecorder)
        assert recorder.supports_silence_autostop is False

class TestTermuxAudioRecorder:
    def test_start_and_stop_use_termux_microphone_commands(self, monkeypatch, temp_voice_dir):
        command_calls = []
        output_path = Path(temp_voice_dir) / "recording_20260409_120000.aac"

        def fake_run(cmd, **kwargs):
            command_calls.append(cmd)
            if cmd[1] == "-f":
                Path(cmd[2]).write_bytes(b"aac-bytes")
            return MagicMock(returncode=0, stdout="", stderr="")

        monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
        monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
        monkeypatch.setattr("tools.voice_mode._termux_microphone_command", lambda: "/data/data/com.termux/files/usr/bin/termux-microphone-record")
        monkeypatch.setattr("tools.voice_mode._termux_api_app_installed", lambda: True)
        monkeypatch.setattr("tools.voice_mode.time.strftime", lambda fmt: "20260409_120000")
        monkeypatch.setattr("tools.voice_mode.subprocess.run", fake_run)

        from tools.voice_mode import TermuxAudioRecorder
        recorder = TermuxAudioRecorder()
        recorder.start()
        recorder._start_time = time.monotonic() - 1.0
        result = recorder.stop()

        assert result == str(output_path)
        assert command_calls[0][:2] == ["/data/data/com.termux/files/usr/bin/termux-microphone-record", "-f"]
        assert command_calls[1] == ["/data/data/com.termux/files/usr/bin/termux-microphone-record", "-q"]

    def test_cancel_removes_partial_termux_recording(self, monkeypatch, temp_voice_dir):
        output_path = Path(temp_voice_dir) / "recording_20260409_120000.aac"

        def fake_run(cmd, **kwargs):
            if cmd[1] == "-f":
                Path(cmd[2]).write_bytes(b"aac-bytes")
            return MagicMock(returncode=0, stdout="", stderr="")

        monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
        monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
        monkeypatch.setattr("tools.voice_mode._termux_microphone_command", lambda: "/data/data/com.termux/files/usr/bin/termux-microphone-record")
        monkeypatch.setattr("tools.voice_mode._termux_api_app_installed", lambda: True)
        monkeypatch.setattr("tools.voice_mode.time.strftime", lambda fmt: "20260409_120000")
        monkeypatch.setattr("tools.voice_mode.subprocess.run", fake_run)

        from tools.voice_mode import TermuxAudioRecorder
        recorder = TermuxAudioRecorder()
        recorder.start()
        recorder.cancel()

        assert output_path.exists() is False
        assert recorder.is_recording is False


class TestAudioRecorder:
    def test_start_raises_without_audio_libs(self, monkeypatch):
        def _fail_import():
            raise ImportError("no sounddevice")
        monkeypatch.setattr("tools.voice_mode._import_audio", _fail_import)

        from tools.voice_mode import AudioRecorder

        recorder = AudioRecorder()
        with pytest.raises(RuntimeError, match="sounddevice and numpy"):
            recorder.start()

    def test_start_oserror_points_at_portaudio_not_pip(self, monkeypatch):
        """OSError from _import_audio means PortAudio's shared library is
        missing — pip can't fix that. The error must point at the system
        package, not 'pip install sounddevice numpy' (#18432)."""
        def _fail_import():
            raise OSError("PortAudio library not found")
        monkeypatch.setattr("tools.voice_mode._import_audio", _fail_import)
        monkeypatch.setattr("tools.voice_mode._is_termux_environment", lambda: False)

        from tools.voice_mode import AudioRecorder

        recorder = AudioRecorder()
        with pytest.raises(RuntimeError) as exc_info:
            recorder.start()
        msg = str(exc_info.value)
        assert "PortAudio system library not found" in msg
        assert "libportaudio2" in msg
        assert "pip install" not in msg

    def test_start_creates_and_starts_stream(self, mock_sd):
        mock_stream = MagicMock()
        mock_sd.InputStream.return_value = mock_stream

        from tools.voice_mode import AudioRecorder

        recorder = AudioRecorder()
        recorder.start()

        assert recorder.is_recording is True
        mock_sd.InputStream.assert_called_once()
        mock_stream.start.assert_called_once()

class TestAudioRecorderStop:
    def test_stop_writes_wav_file(self, mock_sd, temp_voice_dir):
        np = pytest.importorskip("numpy")

        mock_stream = MagicMock()
        mock_sd.InputStream.return_value = mock_stream

        from tools.voice_mode import AudioRecorder, SAMPLE_RATE

        recorder = AudioRecorder()
        recorder.start()

        # Simulate captured audio frames (1 second of loud audio above RMS threshold)
        frame = np.full((SAMPLE_RATE, 1), 1000, dtype="int16")
        recorder._frames = [frame]
        recorder._peak_rms = 1000  # Peak RMS above threshold

        wav_path = recorder.stop()

        assert wav_path is not None
        assert os.path.isfile(wav_path)
        assert wav_path.endswith(".wav")
        assert recorder.is_recording is False

        # Verify it is a valid WAV
        with wave.open(wav_path, "rb") as wf:
            assert wf.getnchannels() == 1
            assert wf.getsampwidth() == 2
            assert wf.getframerate() == SAMPLE_RATE

    def test_stop_returns_none_for_silent_recording(self, mock_sd, temp_voice_dir):
        np = pytest.importorskip("numpy")

        mock_stream = MagicMock()
        mock_sd.InputStream.return_value = mock_stream

        from tools.voice_mode import AudioRecorder, SAMPLE_RATE

        recorder = AudioRecorder()
        recorder.start()

        # 1 second of near-silence (RMS well below threshold)
        frame = np.full((SAMPLE_RATE, 1), 10, dtype="int16")
        recorder._frames = [frame]
        recorder._peak_rms = 10  # Peak RMS also below threshold

        wav_path = recorder.stop()
        assert wav_path is None


class TestAudioRecorderCancel:
    def test_cancel_discards_frames(self, mock_sd):
        mock_stream = MagicMock()
        mock_sd.InputStream.return_value = mock_stream

        from tools.voice_mode import AudioRecorder

        recorder = AudioRecorder()
        recorder.start()
        recorder._frames = [MagicMock()]  # simulate captured data

        recorder.cancel()

        assert recorder.is_recording is False
        assert recorder._frames == []
        # Stream is kept alive (persistent) — cancel() does NOT close it.
        mock_stream.stop.assert_not_called()
        mock_stream.close.assert_not_called()

# ============================================================================
# transcribe_recording
# ============================================================================

class TestTranscribeRecording:
    def test_filters_whisper_hallucination(self):
        mock_transcribe = MagicMock(return_value={
            "success": True,
            "transcript": "Thank you.",
        })

        with patch("tools.transcription_tools.transcribe_audio", mock_transcribe):
            from tools.voice_mode import transcribe_recording
            result = transcribe_recording("/tmp/test.wav")

        assert result["success"] is True
        assert result["transcript"] == ""
        assert result["filtered"] is True


    def test_other_error_does_not_trigger_chunk(self, tmp_path, monkeypatch):
        """Non-size errors from transcribe_audio are returned as-is."""
        wav_path = tmp_path / "record.wav"
        n_frames = 50000
        audio = struct.pack(f"<{n_frames}h", *([1000] * n_frames))
        with wave.open(str(wav_path), "wb") as wf:
            wf.setnchannels(1)
            wf.setsampwidth(2)
            wf.setframerate(16000)
            wf.writeframes(audio)

        mock_transcribe = MagicMock(return_value={
            "success": False,
            "transcript": "",
            "error": "STT is disabled in config.yaml",
        })

        with patch("tools.transcription_tools.transcribe_audio", mock_transcribe):
            from tools.voice_mode import transcribe_recording
            result = transcribe_recording(str(wav_path), model="base")

        assert result["success"] is False
        assert "STT is disabled" in result["error"]
        mock_transcribe.assert_called_once()


class TestWhisperHallucinationFilter:
    def test_known_hallucinations(self):
        from tools.voice_mode import is_whisper_hallucination

        assert is_whisper_hallucination("Thank you.") is True
        assert is_whisper_hallucination("thank you") is True
        assert is_whisper_hallucination("Thanks for watching.") is True
        assert is_whisper_hallucination("Bye.") is True
        assert is_whisper_hallucination("  Thank you.  ") is True  # with whitespace
        assert is_whisper_hallucination("you") is True

    def test_real_speech_not_filtered(self):
        from tools.voice_mode import is_whisper_hallucination

        assert is_whisper_hallucination("Hello, how are you?") is False
        assert is_whisper_hallucination("Thank you for your help with the project.") is False
        assert is_whisper_hallucination("Can you explain this code?") is False


# ============================================================================
# play_audio_file
# ============================================================================

class TestPlayAudioFile:
    def test_play_wav_via_sounddevice(self, monkeypatch, sample_wav):
        np = pytest.importorskip("numpy")
        # Pin to a non-macOS platform: on macOS WAV output deliberately skips
        # sounddevice (see TestMacOSAudioOutputPolicy), so this path is only
        # exercised off Darwin.
        monkeypatch.setattr("tools.voice_mode.platform.system", lambda: "Linux")

        mock_sd_obj = MagicMock()
        # Simulate stream completing immediately (get_stream().active = False)
        mock_stream = MagicMock()
        mock_stream.active = False
        mock_sd_obj.get_stream.return_value = mock_stream

        def _fake_import():
            return mock_sd_obj, np

        monkeypatch.setattr("tools.voice_mode._import_audio", _fake_import)

        from tools.voice_mode import play_audio_file

        result = play_audio_file(sample_wav)

        assert result is True
        mock_sd_obj.play.assert_called_once()
        mock_sd_obj.stop.assert_called_once()

# ============================================================================
# macOS output policy (no sounddevice for OUTPUT -> avoids TCC prompt)
# ============================================================================

class TestMacOSAudioOutputPolicy:
    def test_play_audio_file_skips_sounddevice_on_macos(self, monkeypatch, sample_wav):
        """On macOS, WAV playback must not import sounddevice; it routes to afplay."""
        monkeypatch.setattr("tools.voice_mode.platform.system", lambda: "Darwin")

        def _forbidden_import():
            raise AssertionError("sounddevice must not be imported for output on macOS")

        monkeypatch.setattr("tools.voice_mode._import_audio", _forbidden_import)

        popen_cmds = []

        class _FakeProc:
            returncode = 0

            def wait(self, timeout=None):
                return 0

            def kill(self):
                pass

        def _fake_popen(cmd, **kwargs):
            popen_cmds.append(cmd)
            return _FakeProc()

        monkeypatch.setattr("shutil.which", lambda exe: f"/usr/bin/{exe}")
        monkeypatch.setattr("subprocess.Popen", _fake_popen)

        from tools.voice_mode import play_audio_file

        result = play_audio_file(sample_wav)

        assert result is True
        assert popen_cmds, "expected a system player to be invoked"
        assert popen_cmds[0][0] == "afplay"

    def test_play_beep_routes_through_afplay_on_macos(self, monkeypatch):
        """On macOS, beeps synthesize with numpy but play via the tempfile/afplay path."""
        pytest.importorskip("numpy")
        monkeypatch.setattr("tools.voice_mode.platform.system", lambda: "Darwin")

        def _forbidden_import():
            raise AssertionError("sounddevice must not be imported for beeps on macOS")

        monkeypatch.setattr("tools.voice_mode._import_audio", _forbidden_import)

        calls = []
        monkeypatch.setattr(
            "tools.voice_mode._play_int16_via_tempfile",
            lambda audio, sample_rate: calls.append((len(audio), sample_rate)),
        )

        import tools.voice_mode as vm

        vm.play_beep(frequency=880, count=1)

        assert len(calls) == 1
        n_samples, sample_rate = calls[0]
        assert n_samples > 0
        assert sample_rate == vm.SAMPLE_RATE

# ============================================================================
# cleanup_temp_recordings
# ============================================================================

class TestCleanupTempRecordings:
    def test_old_files_deleted(self, temp_voice_dir):
        # Create an "old" file
        old_file = temp_voice_dir / "recording_20240101_000000.wav"
        old_file.write_bytes(b"\x00" * 100)
        # Set mtime to 2 hours ago
        old_mtime = time.time() - 7200
        os.utime(str(old_file), (old_mtime, old_mtime))

        from tools.voice_mode import cleanup_temp_recordings

        deleted = cleanup_temp_recordings(max_age_seconds=3600)
        assert deleted == 1
        assert not old_file.exists()

    def test_recent_files_preserved(self, temp_voice_dir):
        # Create a "recent" file
        recent_file = temp_voice_dir / "recording_20260303_120000.wav"
        recent_file.write_bytes(b"\x00" * 100)

        from tools.voice_mode import cleanup_temp_recordings

        deleted = cleanup_temp_recordings(max_age_seconds=3600)
        assert deleted == 0
        assert recent_file.exists()

# ============================================================================
# play_beep
# ============================================================================

class TestPlayBeep:
    def test_beep_calls_sounddevice_play(self, mock_sd):
        np = pytest.importorskip("numpy")

        from tools.voice_mode import play_beep

        # play_beep uses polling (get_stream) + sd.stop() instead of sd.wait()
        mock_stream = MagicMock()
        mock_stream.active = False
        mock_sd.get_stream.return_value = mock_stream

        play_beep(frequency=880, duration=0.1, count=1)

        mock_sd.play.assert_called_once()
        mock_sd.stop.assert_called()
        # Verify audio data is int16 numpy array
        audio_arg = mock_sd.play.call_args[0][0]
        assert audio_arg.dtype == np.int16
        assert len(audio_arg) > 0

# ============================================================================
# Silence detection
# ============================================================================

class TestSilenceDetection:
    def test_silence_callback_fires_after_speech_then_silence(self, mock_sd, fake_clock):
        np = pytest.importorskip("numpy")
        import threading

        mock_stream = MagicMock()
        mock_sd.InputStream.return_value = mock_stream

        from tools.voice_mode import AudioRecorder

        recorder = AudioRecorder()
        # Use very short durations for testing
        recorder._silence_duration = 0.05
        recorder._min_speech_duration = 0.05

        fired = threading.Event()

        def on_silence():
            fired.set()

        recorder.start(on_silence_stop=on_silence)

        # Get the callback function from InputStream constructor
        callback = mock_sd.InputStream.call_args.kwargs.get("callback")
        if callback is None:
            callback = mock_sd.InputStream.call_args[1]["callback"]

        # Simulate sustained speech (multiple loud chunks to exceed min_speech_duration)
        loud_frame = np.full((1600, 1), 5000, dtype="int16")
        callback(loud_frame, 1600, None, None)
        fake_clock.advance(0.06)
        callback(loud_frame, 1600, None, None)
        assert recorder._has_spoken is True

        # Simulate silence
        silent_frame = np.zeros((1600, 1), dtype="int16")
        callback(silent_frame, 1600, None, None)

        # Move past the silence duration, then send another silent frame
        fake_clock.advance(0.06)
        callback(silent_frame, 1600, None, None)

        # The callback should have been fired (it runs on a real thread, so
        # this wait is the one place real time is still involved)
        assert fired.wait(timeout=5.0) is True

        recorder.cancel()

    def test_micro_pause_tolerance_during_speech(self, mock_sd, fake_clock):
        """Brief dips below threshold during speech should NOT reset speech tracking."""
        np = pytest.importorskip("numpy")
        import threading

        mock_stream = MagicMock()
        mock_sd.InputStream.return_value = mock_stream

        from tools.voice_mode import AudioRecorder

        recorder = AudioRecorder()
        recorder._silence_duration = 0.05
        recorder._min_speech_duration = 0.15
        recorder._max_dip_tolerance = 0.1

        fired = threading.Event()
        recorder.start(on_silence_stop=lambda: fired.set())

        callback = mock_sd.InputStream.call_args.kwargs.get("callback")
        if callback is None:
            callback = mock_sd.InputStream.call_args[1]["callback"]

        loud_frame = np.full((1600, 1), 5000, dtype="int16")
        quiet_frame = np.full((1600, 1), 50, dtype="int16")

        # Speech chunk 1
        callback(loud_frame, 1600, None, None)
        fake_clock.advance(0.05)
        # Brief micro-pause (dip < max_dip_tolerance)
        callback(quiet_frame, 1600, None, None)
        fake_clock.advance(0.05)
        # Speech resumes -- speech_start should NOT have been reset
        callback(loud_frame, 1600, None, None)
        assert recorder._speech_start > 0, "Speech start should be preserved across brief dips"
        fake_clock.advance(0.06)
        # Another speech chunk to exceed min_speech_duration
        callback(loud_frame, 1600, None, None)
        assert recorder._has_spoken is True, "Speech should be confirmed after tolerating micro-pause"

        recorder.cancel()

# ============================================================================
# Max recording length cap (voice.max_recording_seconds)
# ============================================================================

class TestMaxRecordingCap:
    """The hard cap must auto-stop through the real InputStream-callback
    path — not just the predicate — and fire the one-shot callback exactly
    once, independent of the silence-detection branches."""

    def _get_stream_callback(self, mock_sd):
        callback = mock_sd.InputStream.call_args.kwargs.get("callback")
        if callback is None:
            callback = mock_sd.InputStream.call_args[1]["callback"]
        return callback

    def test_cap_fires_one_shot_callback_during_continuous_speech(self, mock_sd, fake_clock):
        np = pytest.importorskip("numpy")
        import threading

        mock_sd.InputStream.return_value = MagicMock()

        from tools.voice_mode import AudioRecorder

        recorder = AudioRecorder()
        recorder._max_recording_seconds = 0.1
        # Park the other auto-stop branches far away so only the cap can fire:
        # loud frames keep the silence branch off, and max_wait covers the
        # no-speech branch.
        recorder._silence_duration = 60.0
        recorder._max_wait = 60.0

        fires = []
        fired = threading.Event()

        def on_stop():
            fires.append(1)
            fired.set()

        recorder.start(on_silence_stop=on_stop)
        callback = self._get_stream_callback(mock_sd)

        loud_frame = np.full((1600, 1), 5000, dtype="int16")
        callback(loud_frame, 1600, None, None)
        assert not fired.is_set(), "cap must not fire before the limit elapses"

        # Cross the cap while the user is STILL speaking — the silence branch
        # can never fire here, so a hit proves the cap path.
        fake_clock.advance(0.12)
        callback(loud_frame, 1600, None, None)
        assert fired.wait(timeout=5.0) is True

        # One-shot: the handler cleared _on_silence_stop, further frames past
        # the cap must not fire again — with the callback cleared, no further
        # notifier thread is even spawned, so this needs no settle time.
        assert recorder._on_silence_stop is None
        callback(loud_frame, 1600, None, None)
        assert len(fires) == 1

        recorder.cancel()

    def test_disabled_cap_never_fires_on_duration(self, mock_sd, fake_clock):
        np = pytest.importorskip("numpy")
        import threading

        mock_sd.InputStream.return_value = MagicMock()

        from tools.voice_mode import AudioRecorder

        recorder = AudioRecorder()
        recorder._max_recording_seconds = 0.0  # disabled (previous behaviour)
        recorder._silence_duration = 60.0
        recorder._max_wait = 60.0

        fired = threading.Event()
        recorder.start(on_silence_stop=lambda: fired.set())
        callback = self._get_stream_callback(mock_sd)

        loud_frame = np.full((1600, 1), 5000, dtype="int16")
        callback(loud_frame, 1600, None, None)
        fake_clock.advance(0.12)
        callback(loud_frame, 1600, None, None)

        assert fired.wait(timeout=0.1) is False
        recorder.cancel()


# ============================================================================
# Playback interrupt
# ============================================================================

class TestPlaybackInterrupt:
    """Verify that TTS playback can be interrupted."""

    def test_stop_playback_terminates_process(self):
        from tools.voice_mode import stop_playback, _playback_lock
        import tools.voice_mode as vm

        mock_proc = MagicMock()
        mock_proc.poll.return_value = None  # process is running

        with _playback_lock:
            vm._active_playback = mock_proc

        stop_playback()

        mock_proc.terminate.assert_called_once()

        with _playback_lock:
            assert vm._active_playback is None

# ============================================================================
# Continuous mode flow
# ============================================================================

class TestContinuousModeFlow:
    """Verify continuous mode: auto-restart after transcription or silence."""

    def test_continuous_restart_on_no_speech(self, mock_sd, temp_voice_dir):
        np = pytest.importorskip("numpy")

        mock_stream = MagicMock()
        mock_sd.InputStream.return_value = mock_stream

        from tools.voice_mode import AudioRecorder

        recorder = AudioRecorder()

        # First recording: only silence -> stop returns None
        recorder.start()
        callback = mock_sd.InputStream.call_args.kwargs.get("callback")
        if callback is None:
            callback = mock_sd.InputStream.call_args[1]["callback"]

        for _ in range(10):
            silence = np.full((1600, 1), 10, dtype="int16")
            callback(silence, 1600, None, None)

        wav_path = recorder.stop()
        assert wav_path is None

        # Simulate continuous mode restart
        recorder.start()
        assert recorder.is_recording is True

        callback = mock_sd.InputStream.call_args.kwargs.get("callback")
        if callback is None:
            callback = mock_sd.InputStream.call_args[1]["callback"]

        for _ in range(10):
            speech = np.full((1600, 1), 5000, dtype="int16")
            callback(speech, 1600, None, None)

        wav_path = recorder.stop()
        assert wav_path is not None

        recorder.cancel()

# ============================================================================
# Audio level indicator
# ============================================================================

class TestAudioLevelIndicator:
    """Verify current_rms property updates in real-time for UI feedback."""

    def test_peak_rms_tracks_maximum(self, mock_sd):
        np = pytest.importorskip("numpy")

        mock_stream = MagicMock()
        mock_sd.InputStream.return_value = mock_stream

        from tools.voice_mode import AudioRecorder

        recorder = AudioRecorder()
        recorder.start()
        callback = mock_sd.InputStream.call_args.kwargs.get("callback")
        if callback is None:
            callback = mock_sd.InputStream.call_args[1]["callback"]

        frames = [
            np.full((1600, 1), 100, dtype="int16"),
            np.full((1600, 1), 8000, dtype="int16"),
            np.full((1600, 1), 500, dtype="int16"),
            np.full((1600, 1), 3000, dtype="int16"),
        ]
        for frame in frames:
            callback(frame, 1600, None, None)

        assert recorder._peak_rms == 8000
        assert recorder.current_rms == 3000

        recorder.cancel()


# ============================================================================
# Configurable silence parameters
# ============================================================================

class TestConfigurableSilenceParams:
    """Verify that silence detection params can be configured."""

    def test_custom_threshold_and_duration(self, mock_sd, fake_clock):
        np = pytest.importorskip("numpy")

        mock_stream = MagicMock()
        mock_sd.InputStream.return_value = mock_stream

        from tools.voice_mode import AudioRecorder
        import threading

        recorder = AudioRecorder()
        recorder._silence_threshold = 5000
        recorder._silence_duration = 0.05
        recorder._min_speech_duration = 0.05

        fired = threading.Event()
        recorder.start(on_silence_stop=lambda: fired.set())
        callback = mock_sd.InputStream.call_args.kwargs.get("callback")
        if callback is None:
            callback = mock_sd.InputStream.call_args[1]["callback"]

        # Audio at RMS 1000 -- below custom threshold (5000)
        moderate = np.full((1600, 1), 1000, dtype="int16")
        for _ in range(5):
            callback(moderate, 1600, None, None)
            fake_clock.advance(0.02)

        assert recorder._has_spoken is False
        assert fired.wait(timeout=0.2) is False

        # Now send really loud audio (above 5000 threshold)
        very_loud = np.full((1600, 1), 8000, dtype="int16")
        callback(very_loud, 1600, None, None)
        fake_clock.advance(0.06)
        callback(very_loud, 1600, None, None)
        assert recorder._has_spoken is True

        recorder.cancel()


# ============================================================================
# Bugfix regression tests
# ============================================================================


class TestStreamLeakOnStartFailure:
    """Bug: stream.start() failure left stream unclosed."""

    def test_stream_closed_on_start_failure(self, mock_sd):
        mock_stream = MagicMock()
        mock_stream.start.side_effect = OSError("Audio device busy")
        mock_sd.InputStream.return_value = mock_stream

        from tools.voice_mode import AudioRecorder
        recorder = AudioRecorder()

        with pytest.raises(RuntimeError, match="Failed to open audio input stream"):
            recorder._ensure_stream()

        mock_stream.close.assert_called_once()


# ============================================================================
# listen_for_speech — VAD barge-in monitor
# ============================================================================

class _FakeInputStream:
    """Context-manager InputStream serving a fixed sequence of RMS levels."""

    def __init__(self, np, levels):
        self._np = np
        self._levels = list(levels)
        self.reads = 0

    def __enter__(self):
        return self

    def __exit__(self, *args):
        return False

    def read(self, frames):
        level = self._levels[min(self.reads, len(self._levels) - 1)]
        self.reads += 1
        return self._np.full((frames, 1), level, dtype=self._np.int16), False


class TestListenForSpeech:
    """listen_for_speech: calibration → sustained-speech trigger → barge-in."""

    CALIB_BLOCKS = 14   # 400ms / 30ms
    TRIP_BLOCKS = 10    # 300ms / 30ms

    def _run(self, mock_sd, levels, should_stop=None, **kwargs):
        np = pytest.importorskip("numpy")
        stream = _FakeInputStream(np, levels)
        mock_sd.InputStream.return_value = stream
        from tools.voice_mode import listen_for_speech
        stops = iter([False] * 200 + [True] * 10_000)
        return listen_for_speech(should_stop or (lambda: next(stops)), **kwargs), stream

    def test_sustained_speech_triggers(self, mock_sd):
        levels = [0] * self.CALIB_BLOCKS + [5000] * 50
        heard, _ = self._run(mock_sd, levels)
        assert heard is True


    def test_returns_false_when_audio_unavailable(self, monkeypatch):
        monkeypatch.setattr("tools.voice_mode._import_audio", MagicMock(side_effect=OSError("no audio")))
        from tools.voice_mode import listen_for_speech
        assert listen_for_speech(lambda: False) is False

    def test_quiet_then_loud_playback_does_not_trip(self, mock_sd):
        """TTS that starts quiet and gets louder must NOT trip barge-in.

        This is the core regression: a one-shot calibration freezes the
        floor from the quiet opening, then louder TTS exceeds the stale
        floor and false-triggers.  The rolling window keeps the floor
        current so the louder passage is absorbed into the floor.
        """
        levels = [100] * self.CALIB_BLOCKS + [200] * 30 + [500] * 30 + [1000] * 30
        heard, _ = self._run(mock_sd, levels)
        assert heard is False

    def test_silence_calibration_does_not_false_trip_on_tts(self, mock_sd):
        """Calibration during an inter-sentence gap must NOT false-trip.

        If the grace period ends during a pause between TTS sentences, the
        calibration window samples near-silence.  Without the min_floor clamp,
        min_floor locks near zero, the trigger drops to 400 RMS (SILENCE_RMS_THRESHOLD
        * 2), and the next TTS sentence at 800 RMS exceeds it — those blocks are
        excluded from the rolling window (rms >= trigger), the floor freezes, and
        after sustained_ms the VAD false-triggers and cuts playback mid-sentence.

        With the clamp, min_floor stays at SILENCE_RMS_THRESHOLD * 2 = 400, the
        trigger is max(400, 400 * 8.0) = 3200, and 800-RMS TTS stays below it and
        feeds the rolling floor.  No false trip.
        """
        # calibration_ms=800 → CALIB_BLOCKS = 800/30 ≈ 26 blocks of silence
        # Then TTS resumes at 800 RMS — must NOT trip (below 3200 trigger).
        calib = 800 // 30
        levels = [0] * calib + [800] * 100
        heard, _ = self._run(
            mock_sd, levels,
            sustained_ms=1000,
            calibration_ms=800,
        )
        assert heard is False


class TestListenForSpeechCapture:
    """capture=True: the barge monitor records the interruption with pre-roll,
    so the utterance is complete from its first syllable — nothing is lost
    between detection and a recorder restart."""

    CALIB_BLOCKS = 14   # 400ms / 30ms
    LOUD_BLOCKS = 30    # speech: trips after 10, keeps talking
    BLOCK = 480         # 16000 * 0.03

    def _run(self, mock_sd, monkeypatch, levels, should_stop=None, **kwargs):
        np = pytest.importorskip("numpy")
        stream = _FakeInputStream(np, levels)
        mock_sd.InputStream.return_value = stream
        written = {}
        monkeypatch.setattr(
            "tools.voice_mode.AudioRecorder._write_wav",
            staticmethod(lambda audio: written.update(audio=audio) or "/tmp/barge.wav"),
        )
        from tools.voice_mode import listen_for_speech
        stops = iter([False] * 200 + [True] * 10_000)
        path = listen_for_speech(
            should_stop or (lambda: next(stops)), capture=True, **kwargs
        )
        return path, written.get("audio"), stream

    def test_captured_utterance_includes_speech_onset(self, mock_sd, monkeypatch):
        """Every loud block — including the ones BEFORE detection tripped —
        must land in the WAV. That pre-roll is the whole point."""
        triggered = []
        levels = [0] * self.CALIB_BLOCKS + [5000] * self.LOUD_BLOCKS + [0] * 500
        path, audio, _ = self._run(
            mock_sd, monkeypatch, levels,
            should_stop=lambda: False,
            on_trigger=lambda: triggered.append(True),
        )
        assert path == "/tmp/barge.wav"
        assert triggered == [True]
        assert int((audio == 5000).sum()) == self.LOUD_BLOCKS * self.BLOCK

    def test_no_trip_returns_none(self, mock_sd, monkeypatch):
        triggered = []
        path, audio, _ = self._run(
            mock_sd, monkeypatch, [0] * 500,
            on_trigger=lambda: triggered.append(True),
        )
        assert path is None
        assert audio is None
        assert triggered == []

class TestFullDuplexListen:
    """full_duplex_listen: one agent-turn listener spanning generation and
    playback — pre-playback calibration, phase-aware trigger, grace window."""

    CALIB = 15       # 450ms / 30ms — pre-playback quiet calibration
    TRIP = 10        # 300ms / 30ms window; trip needs >=8 above
    GRACE = 16       # 500ms / 30ms
    BLOCK = 480      # 30ms at 16 kHz

    def _run(self, mock_sd, monkeypatch, levels, playing_from=None,
             playing_until=None, should_stop=None, on_trigger=None, **kwargs):
        np = pytest.importorskip("numpy")
        stream = _FakeInputStream(np, levels)
        mock_sd.InputStream.return_value = stream
        written = {}
        monkeypatch.setattr(
            "tools.voice_mode.AudioRecorder._write_wav",
            staticmethod(lambda audio: written.update(audio=audio) or "/tmp/fd.wav"),
        )
        from tools.voice_mode import full_duplex_listen

        def is_playing():
            if playing_from is None:
                return False
            if stream.reads < playing_from:
                return False
            if playing_until is not None and stream.reads >= playing_until:
                return False
            return True

        stops = iter([False] * len(levels) + [True] * 10_000)
        path = full_duplex_listen(
            should_stop or (lambda: next(stops)),
            is_playing=is_playing,
            on_trigger=on_trigger,
            **kwargs,
        )
        return path, written.get("audio"), stream

    def test_generation_phase_speech_trips_and_captures(self, mock_sd, monkeypatch):
        """Speech while the LLM generates (no playback) trips with
        phase='generation' and the utterance is captured with pre-roll."""
        phases = []
        levels = [100] * self.CALIB + [5000] * 30 + [0] * 500
        path, audio, _ = self._run(
            mock_sd, monkeypatch, levels,
            on_trigger=lambda phase: phases.append(phase),
        )
        assert path == "/tmp/fd.wav"
        assert phases == ["generation"]
        assert int((audio == 5000).sum()) > 0  # speech onset in the capture

    def test_playback_bleed_alone_does_not_trip(self, mock_sd, monkeypatch):
        """Speaker bleed (~1000 RMS) during playback stays below the playback
        trigger clamp — the old monitor's self-calibration deafness class."""
        phases = []
        levels = [100] * self.CALIB + [1000] * 300
        path, _, _ = self._run(
            mock_sd, monkeypatch, levels,
            playing_from=self.CALIB,
            on_trigger=lambda phase: phases.append(phase),
        )
        assert path is None
        assert phases == []

    def test_speech_over_bleed_trips_in_playback_phase(self, mock_sd, monkeypatch):
        """Real speech (5000 RMS) over playback trips with phase='playback'
        even though playback bleed was present — the trigger comes from the
        PRE-playback quiet floor, never from bleed self-calibration."""
        phases = []
        # quiet calib → playback bleed (past grace) → user speaks over it
        levels = (
            [100] * self.CALIB
            + [1000] * (self.GRACE + 20)
            + [5000] * 30
            + [1000] * 200
        )
        path, _, _ = self._run(
            mock_sd, monkeypatch, levels,
            playing_from=self.CALIB,
            on_trigger=lambda phase: phases.append(phase),
        )
        assert path == "/tmp/fd.wav"
        assert phases == ["playback"]

    def test_grace_window_suppresses_playback_onset(self, mock_sd, monkeypatch):
        """Loud blocks inside the 0.5s grace right after playback starts are
        suppressed (onset transient), but speech after grace still trips."""
        phases = []
        # loud transient fully inside grace, then quiet bleed, then speech
        levels = (
            [100] * self.CALIB
            + [5000] * 8            # onset transient (inside 16-block grace)
            + [800] * 40
            + [5000] * 30
            + [0] * 200
        )

        def trig(phase):
            phases.append(phase)

        path, _, stream = self._run(
            mock_sd, monkeypatch, levels,
            playing_from=self.CALIB,
            on_trigger=trig,
        )
        assert path == "/tmp/fd.wav"
        assert phases == ["playback"]
        # Trip must come from the post-grace speech, not the onset transient:
        # by the time capture starts, we're past calib+transient+bleed blocks.
        assert stream.reads > self.CALIB + 8 + 40

class TestGetBeepVolume:
    """Issue #55908: beep amplitude must come from config.yaml, with safe fallback."""

    def _get(self):
        from tools.voice_mode import _get_beep_volume
        return _get_beep_volume()

    @pytest.mark.parametrize("config,expected", [
        ({"voice": {}}, 0.3),                              # unset -> default
        ({"voice": {"beep_volume": 0.0}}, 0.0),            # boundary, honored
        ({"voice": {"beep_volume": 1.5}}, 0.3),            # out of range -> default
        ({"voice": {"beep_volume": "0.7"}}, 0.7),          # numeric string coerced
        ({"voice": {"beep_volume": True}}, 0.3),           # bool is not a volume
    ])
    def test_config_value_resolution(self, config, expected):
        with patch("hermes_cli.config.load_config", return_value=config):
            assert self._get() == expected

    def test_load_config_exception_falls_back(self):
        with patch("hermes_cli.config.load_config",
                   side_effect=RuntimeError("broken config")):
            assert self._get() == 0.3

# ============================================================================
# Device-native input sample rate — mics that reject 16 kHz capture
# ============================================================================

class TestDefaultInputSamplerate:
    def test_uses_device_default_rate(self):
        from tools.voice_mode import _default_input_samplerate

        sd = MagicMock()
        sd.query_devices.return_value = {"default_samplerate": 44100.0}
        assert _default_input_samplerate(sd) == 44100


    def test_wav_written_at_capture_rate(self, mock_sd, temp_voice_dir):
        np = pytest.importorskip("numpy")

        mock_sd.query_devices.return_value = {"default_samplerate": 48000.0}
        mock_stream = MagicMock()
        mock_sd.InputStream.return_value = mock_stream

        from tools.voice_mode import AudioRecorder

        recorder = AudioRecorder()
        recorder.start()

        # 1 second of loud audio at the device rate (above RMS threshold)
        frame = np.full((48000, 1), 1000, dtype="int16")
        recorder._frames = [frame]
        recorder._peak_rms = 1000

        wav_path = recorder.stop()

        assert wav_path is not None
        with wave.open(wav_path, "rb") as wf:
            assert wf.getframerate() == 48000


class TestWSL2PowerShellFallback:
    """Regression tests for WSL2 PowerShell TTS fallback (issue #17608).

    On WSL2 without a PulseAudio bridge, ffplay/aplay have no audio device.
    play_audio_file() should insert a PowerShell-based player at the front
    of the player list when powershell.exe and ffmpeg are available.
    """

    def _fake_check_output(self, responses):
        """Build a subprocess.check_output side_effect from a list of responses."""
        it = iter(responses)
        def _side_effect(cmd, **kwargs):
            return next(it)
        return _side_effect

    def test_powershell_pipeline_preserves_real_exit_status(self, sample_wav):
        """Regression (review of #63768): the shell pipeline must preserve
        the (ffmpeg && powershell) exit status past the unconditional
        cleanup, so a real conversion/playback failure falls through to the
        next player instead of being masked by rm -f's always-zero exit."""
        from unittest.mock import patch, MagicMock
        from tools import voice_mode as vm

        captured_cmds = []

        def _capture_popen(cmd, **kw):
            captured_cmds.append(list(cmd))
            m = MagicMock()
            # Simulate the PowerShell pipeline failing (nonzero rc), and
            # the fallback ffplay succeeding.
            if cmd[0] in ("/bin/sh", "sh"):
                m.returncode = 1
            else:
                m.returncode = 0
            m.wait = MagicMock(return_value=m.returncode)
            return m

        with patch("tools.voice_mode._is_wsl2_env", return_value=True), \
             patch("tools.voice_mode._import_audio", side_effect=ImportError), \
             patch("tools.voice_mode.shutil.which",
                   side_effect=lambda x: f"/bin/{x}" if x in ("powershell.exe", "ffmpeg", "ffplay", "sh") else (x if x.startswith("/") else None)), \
             patch("tools.voice_mode.subprocess.check_output",
                   side_effect=self._fake_check_output([
                       b"C:/Temp\r\n",
                       b"/mnt/c/Temp\n",
                       b"C:/Temp/hermes.wav\n",
                   ])), \
             patch("tools.voice_mode.subprocess.Popen", side_effect=_capture_popen):
            result = vm.play_audio_file(str(sample_wav))

        assert result is True, "Must fall through to ffplay and succeed"
        assert len(captured_cmds) == 2, (
            f"Expected sh pipeline to be tried and fail, then ffplay to be "
            f"tried: {captured_cmds}"
        )
        assert captured_cmds[0][0] in ("/bin/sh", "sh")
        assert captured_cmds[1][0] == "ffplay"
        # The subshell command must capture and re-exit with $rc, not rely
        # on rm -f's exit status.
        sh_script = captured_cmds[0][2]
        assert "rc=$?" in sh_script and "exit $rc" in sh_script, (
            "Shell pipeline must preserve the real exit status past cleanup: " + sh_script
        )

    def test_wsl2_unique_temp_filename(self, monkeypatch, tmp_path, sample_wav):
        """Two concurrent calls must use different temp WAV filenames."""
        from unittest.mock import patch, MagicMock
        from tools import voice_mode as vm

        filenames = []

        def _capture_check_output(cmd, **kwargs):
            cmd_str = " ".join(str(c) for c in cmd)
            if "TEMP" in cmd_str:
                return b"C:\\Temp\r\n"
            if "wslpath" in cmd_str and "-u" in cmd_str:
                return b"/mnt/c/Temp\n"
            if "wslpath" in cmd_str and "-w" in cmd_str:
                wsl_path = cmd[-1] if isinstance(cmd[-1], str) else cmd[-1].decode()
                filenames.append(wsl_path.split("/")[-1])
                return f"C:\\Temp\\{wsl_path.split('/')[-1]}\n".encode()
            return b""

        def _fake_open(path, *args, **kwargs):
            if str(path) == "/proc/version":
                import io
                return io.StringIO("Linux Microsoft WSL2")
            return open(path, *args, **kwargs)

        with patch("builtins.open", side_effect=_fake_open), \
             patch("shutil.which", side_effect=lambda x: f"/bin/{x}" if x in ("powershell.exe", "ffmpeg", "ffplay") else None), \
             patch("subprocess.check_output", side_effect=_capture_check_output), \
             patch("subprocess.Popen", return_value=MagicMock(returncode=0, wait=lambda **k: 0)), \
             patch("tools.voice_mode._playback_lock"), \
             patch("tools.voice_mode._active_playback", None):
            vm.play_audio_file(str(sample_wav))
            vm.play_audio_file(str(sample_wav))

        # Regression (review of #63768): the original test made this
        # assertion conditional on len(filenames) >= 2, so a broken
        # (zero-captured) run passed trivially. Require exactly two.
        assert len(filenames) == 2, (
            f"Expected exactly 2 captured temp filenames from 2 calls, got "
            f"{len(filenames)}: {filenames}"
        )
        assert filenames[0] != filenames[1], (
            "Concurrent TTS calls must use unique temp WAV filenames"
        )

    def test_non_wsl_skips_powershell_fallback(self, monkeypatch, sample_wav):
        """On non-WSL Linux, the PowerShell player must not be inserted."""
        from unittest.mock import patch, MagicMock
        from tools import voice_mode as vm

        captured_players = []

        def _capture_popen(cmd, **kw):
            captured_players.append(cmd)
            m = MagicMock()
            m.returncode = 0
            m.wait.return_value = 0
            return m

        def _fake_open(path, *args, **kwargs):
            if str(path) == "/proc/version":
                import io
                return io.StringIO("Linux version 5.15.0-generic #72-Ubuntu")
            return open(path, *args, **kwargs)

        with patch("builtins.open", side_effect=_fake_open), \
             patch("tools.voice_mode._import_audio", side_effect=ImportError), \
             patch("shutil.which", side_effect=lambda x: f"/bin/{x}" if x in ("ffplay", "aplay") else None), \
             patch("subprocess.Popen", side_effect=_capture_popen), \
             patch("tools.voice_mode._playback_lock"), \
             patch("tools.voice_mode._active_playback", None):
            vm.play_audio_file(str(sample_wav))

        assert captured_players, "No players were tried"
        for cmd in captured_players:
            assert not (cmd[0] == "sh" and "powershell" in " ".join(str(c) for c in cmd)), (
                "PowerShell player must not appear on non-WSL Linux"
            )


class TestWSLAudioEnvironmentGate:
    """Regression tests (review of #63768) for detect_audio_environment()'s
    WSL gate: when the PowerShell TTS fallback is viable, voice mode must
    not be hard-blocked, but the recording/STT PulseAudio-bridge guidance
    must still be surfaced (as a non-blocking notice)."""

    def _fake_open_wsl(self, path, *args, **kwargs):
        if str(path) == "/proc/version":
            import io
            return io.StringIO("Linux version 5.15 Microsoft Standard WSL2")
        return open(path, *args, **kwargs)

    def test_wsl_no_pulse_but_powershell_available_not_hard_blocked(self, monkeypatch):
        from unittest.mock import patch
        from tools import voice_mode as vm

        monkeypatch.delenv("PULSE_SERVER", raising=False)
        monkeypatch.delenv("PIPEWIRE_REMOTE", raising=False)
        for _ssh_var in ("SSH_CLIENT", "SSH_TTY", "SSH_CONNECTION"):
            monkeypatch.delenv(_ssh_var, raising=False)
        monkeypatch.setattr("tools.voice_mode._import_audio",
                            lambda: (MagicMock(), MagicMock()))
        with patch("builtins.open", side_effect=self._fake_open_wsl), \
             patch("tools.voice_mode._wsl_powershell_tts_available", return_value=True), \
             patch("tools.voice_mode._pulse_socket_reachable", return_value=False), \
             patch("hermes_constants.is_container", return_value=False):
            result = vm.detect_audio_environment()

        assert result["available"] is True, (
            "PowerShell TTS fallback must keep voice mode enabled even "
            "without a PulseAudio bridge: " + str(result["warnings"])
        )
        assert any("PowerShell" in n or "Media.SoundPlayer" in n for n in result["notices"]), (
            "The PowerShell fallback path must be mentioned in notices"
        )
        assert any("recording" in n.lower() or "PulseAudio" in n for n in result["notices"]), (
            "The recording/STT PulseAudio caveat must still be surfaced"
        )

    def test_wsl_no_pulse_no_powershell_still_blocked(self, monkeypatch):
        from unittest.mock import patch
        from tools import voice_mode as vm

        monkeypatch.delenv("PULSE_SERVER", raising=False)
        monkeypatch.delenv("PIPEWIRE_REMOTE", raising=False)
        for _ssh_var in ("SSH_CLIENT", "SSH_TTY", "SSH_CONNECTION"):
            monkeypatch.delenv(_ssh_var, raising=False)
        monkeypatch.setattr("tools.voice_mode._import_audio",
                            lambda: (MagicMock(), MagicMock()))
        with patch("builtins.open", side_effect=self._fake_open_wsl), \
             patch("tools.voice_mode._wsl_powershell_tts_available", return_value=False), \
             patch("tools.voice_mode._pulse_socket_reachable", return_value=False), \
             patch("hermes_constants.is_container", return_value=False):
            result = vm.detect_audio_environment()

        assert result["available"] is False, (
            "Without PulseAudio AND without the PowerShell fallback, WSL "
            "must still be hard-blocked as before"
        )

    def test_wsl_with_pulse_server_unaffected(self, monkeypatch):
        """PULSE_SERVER already configured: existing behavior unchanged."""
        from unittest.mock import patch
        from tools import voice_mode as vm

        monkeypatch.setenv("PULSE_SERVER", "unix:/mnt/wslg/PulseServer")
        for _ssh_var in ("SSH_CLIENT", "SSH_TTY", "SSH_CONNECTION"):
            monkeypatch.delenv(_ssh_var, raising=False)
        monkeypatch.setattr("tools.voice_mode._import_audio",
                            lambda: (MagicMock(), MagicMock()))
        with patch("builtins.open", side_effect=self._fake_open_wsl), \
             patch("hermes_constants.is_container", return_value=False):
            result = vm.detect_audio_environment()

        assert result["available"] is True
        # Merged with #37346: any forwarded sound server (PULSE_SERVER or
        # PIPEWIRE_REMOTE) yields the shared reachable-sound-server notice.
        assert any(
            "PulseAudio" in n and "WSL" in n for n in result["notices"]
        )
