"""Tests for the sovereign voice loop.

These tests verify the VoiceLoop components without requiring a microphone,
Whisper model, or Piper installation — all I/O is mocked.
"""

from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

try:
    import numpy as np
except ImportError:
    np = None

try:
    from timmy.voice_loop import VoiceConfig, VoiceLoop, _rms, _strip_markdown
except ImportError:
    pass  # pytestmark will skip all tests anyway

pytestmark = pytest.mark.skipif(np is None, reason="numpy not installed")


# ── VoiceConfig tests ──────────────────────────────────────────────────────


class TestVoiceConfig:
    def test_defaults(self):
        cfg = VoiceConfig()
        assert cfg.whisper_model == "base.en"
        assert cfg.sample_rate == 16000
        assert cfg.silence_threshold == 0.015
        assert cfg.silence_duration == 1.5
        assert cfg.min_utterance == 0.5
        assert cfg.max_utterance == 30.0
        assert cfg.session_id == "voice"
        assert cfg.use_say_fallback is False

    def test_custom_values(self):
        cfg = VoiceConfig(
            whisper_model="tiny.en",
            silence_threshold=0.02,
            session_id="custom",
            use_say_fallback=True,
        )
        assert cfg.whisper_model == "tiny.en"
        assert cfg.silence_threshold == 0.02
        assert cfg.session_id == "custom"
        assert cfg.use_say_fallback is True


# ── VoiceLoop unit tests ──────────────────────────────────────────────────


class TestVoiceLoopInit:
    def test_default_config(self):
        loop = VoiceLoop()
        assert loop.config.whisper_model == "base.en"
        assert loop._running is False
        assert loop._speaking is False

    def test_custom_config(self):
        cfg = VoiceConfig(whisper_model="tiny.en")
        loop = VoiceLoop(config=cfg)
        assert loop.config.whisper_model == "tiny.en"


class TestPiperFallback:
    def test_falls_back_to_say_when_no_voice_file(self):
        cfg = VoiceConfig(piper_voice=Path("/nonexistent/voice.onnx"))
        loop = VoiceLoop(config=cfg)
        loop._ensure_piper()
        assert loop.config.use_say_fallback is True

    def test_keeps_piper_when_voice_exists(self, tmp_path):
        voice_file = tmp_path / "test.onnx"
        voice_file.write_bytes(b"fake model")
        cfg = VoiceConfig(piper_voice=voice_file)
        loop = VoiceLoop(config=cfg)
        loop._ensure_piper()
        assert loop.config.use_say_fallback is False


class TestTranscribe:
    def test_transcribes_audio(self):
        """Whisper transcription returns cleaned text."""
        loop = VoiceLoop()

        mock_model = MagicMock()
        mock_model.transcribe.return_value = {"text": "  Hello Timmy  "}
        loop._whisper_model = mock_model

        audio = np.random.randn(16000).astype(np.float32)
        result = loop._transcribe(audio)

        assert result == "Hello Timmy"
        mock_model.transcribe.assert_called_once()

    def test_transcribes_empty_returns_empty(self):
        loop = VoiceLoop()
        mock_model = MagicMock()
        mock_model.transcribe.return_value = {"text": "   "}
        loop._whisper_model = mock_model

        audio = np.random.randn(16000).astype(np.float32)
        result = loop._transcribe(audio)
        assert result == ""


class TestStripMarkdown:
    def test_strips_bold(self):
        assert _strip_markdown("**hello**") == "hello"

    def test_strips_italic(self):
        assert _strip_markdown("*hello*") == "hello"

    def test_strips_headers(self):
        assert _strip_markdown("## Header\ntext") == "Header\ntext"

    def test_strips_bullets(self):
        assert _strip_markdown("- item one\n- item two") == "item one\nitem two"

    def test_strips_numbered_lists(self):
        assert _strip_markdown("1. first\n2. second") == "first\nsecond"

    def test_strips_inline_code(self):
        assert _strip_markdown("use `pip install`") == "use pip install"

    def test_strips_links(self):
        assert _strip_markdown("[click here](https://x.com)") == "click here"

    def test_preserves_plain_text(self):
        assert _strip_markdown("Hello, how are you?") == "Hello, how are you?"

    def test_empty_string(self):
        assert _strip_markdown("") == ""

    def test_none_passthrough(self):
        assert _strip_markdown(None) is None

    def test_complex_markdown(self):
        md = "**1. First** thing\n- use `code`\n*emphasis*"
        result = _strip_markdown(md)
        assert "**" not in result
        assert "`" not in result
        assert "*" not in result


class TestRms:
    def test_silent_block(self):
        block = np.zeros(1600, dtype=np.float32)
        assert _rms(block) == pytest.approx(0.0, abs=1e-7)

    def test_loud_block(self):
        block = np.ones(1600, dtype=np.float32)
        assert _rms(block) == pytest.approx(1.0, abs=1e-5)


class TestFinalizeUtterance:
    def test_returns_none_for_empty(self):
        assert VoiceLoop._finalize_utterance([], min_blocks=5, sample_rate=16000) is None

    def test_returns_none_for_too_short(self):
        chunks = [np.zeros(1600, dtype=np.float32) for _ in range(3)]
        assert VoiceLoop._finalize_utterance(chunks, min_blocks=5, sample_rate=16000) is None

    def test_returns_audio_for_sufficient_chunks(self):
        chunks = [np.ones(1600, dtype=np.float32) for _ in range(6)]
        result = VoiceLoop._finalize_utterance(chunks, min_blocks=5, sample_rate=16000)
        assert result is not None
        assert len(result) == 6 * 1600


class TestThink:
    def test_think_returns_response(self):
        loop = VoiceLoop()
        loop._loop = MagicMock()
        loop._loop.is_closed.return_value = False
        loop._loop.run_until_complete.return_value = "I am Timmy."
        result = loop._think("Who are you?")
        assert result == "I am Timmy."

    def test_think_handles_error(self):
        loop = VoiceLoop()
        loop._loop = MagicMock()
        loop._loop.is_closed.return_value = False
        loop._loop.run_until_complete.side_effect = RuntimeError("Ollama down")
        result = loop._think("test")
        assert "trouble" in result.lower()

    def test_think_strips_markdown(self):
        loop = VoiceLoop()
        loop._loop = MagicMock()
        loop._loop.is_closed.return_value = False
        loop._loop.run_until_complete.return_value = "**Hello** from *Timmy*"
        result = loop._think("test")
        assert "**" not in result
        assert "*" not in result
        assert "Hello" in result


class TestSpeakSay:
    @patch("subprocess.Popen")
    def test_speak_say_calls_subprocess(self, mock_popen):
        mock_proc = MagicMock()
        mock_proc.wait.return_value = 0
        mock_popen.return_value = mock_proc

        cfg = VoiceConfig(use_say_fallback=True)
        loop = VoiceLoop(config=cfg)
        loop._speak_say("Hello")

        mock_popen.assert_called_once()
        args = mock_popen.call_args[0][0]
        assert args[0] == "say"
        assert "Hello" in args

    @patch("subprocess.Popen", side_effect=FileNotFoundError)
    def test_speak_say_handles_missing(self, mock_popen):
        cfg = VoiceConfig(use_say_fallback=True)
        loop = VoiceLoop(config=cfg)
        # Should not raise
        loop._speak_say("Hello")


class TestSpeakPiper:
    @patch("timmy.voice_loop.VoiceLoop._play_audio")
    @patch("subprocess.run")
    def test_speak_piper_generates_and_plays(self, mock_run, mock_play):
        mock_run.return_value = MagicMock(returncode=0, stderr="")

        voice_path = Path("/tmp/test_voice.onnx")
        cfg = VoiceConfig(piper_voice=voice_path)
        loop = VoiceLoop(config=cfg)
        loop._speak_piper("Hello from Piper")

        # Piper was called
        mock_run.assert_called_once()
        cmd = mock_run.call_args[0][0]
        assert cmd[0] == "piper"
        assert "--model" in cmd

        # Audio was played
        mock_play.assert_called_once()

    @patch("timmy.voice_loop.VoiceLoop._speak_say")
    @patch("subprocess.run")
    def test_speak_piper_falls_back_on_error(self, mock_run, mock_say):
        mock_run.return_value = MagicMock(returncode=1, stderr="model error")

        cfg = VoiceConfig(piper_voice=Path("/tmp/test.onnx"))
        loop = VoiceLoop(config=cfg)
        loop._speak_piper("test")

        # Should fall back to say
        mock_say.assert_called_once_with("test")


class TestHallucinationFilter:
    """Whisper tends to hallucinate on silence/noise. The loop should filter these."""

    def test_known_hallucinations_filtered(self):
        loop = VoiceLoop()
        hallucinations = [
            "you",
            "thanks.",
            "Thank you.",
            "Bye.",
            "Thanks for watching!",
            "Thank you for watching!",
            "",
        ]
        for text in hallucinations:
            assert loop._is_hallucination(text), f"'{text}' should be filtered"

    def test_real_speech_not_filtered(self):
        loop = VoiceLoop()
        assert not loop._is_hallucination("Hello Timmy")
        assert not loop._is_hallucination("What time is it?")


class TestExitCommands:
    """Voice loop should recognize exit commands."""

    def test_exit_commands(self):
        loop = VoiceLoop()
        exits = ["goodbye", "exit", "quit", "stop", "goodbye timmy", "stop listening"]
        for cmd in exits:
            assert loop._is_exit_command(cmd), f"'{cmd}' should be an exit command"

    def test_exit_with_punctuation(self):
        loop = VoiceLoop()
        assert loop._is_exit_command("goodbye!")
        assert loop._is_exit_command("stop.")

    def test_non_exit_commands(self):
        loop = VoiceLoop()
        assert not loop._is_exit_command("hello")
        assert not loop._is_exit_command("what time is it")


class TestPlayAudio:
    @patch("subprocess.Popen")
    def test_play_audio_calls_afplay(self, mock_popen):
        mock_proc = MagicMock()
        mock_proc.poll.side_effect = [None, 0]  # Running, then done
        mock_popen.return_value = mock_proc

        loop = VoiceLoop()
        loop._play_audio("/tmp/test.wav")

        mock_popen.assert_called_once()
        args = mock_popen.call_args[0][0]
        assert args[0] == "afplay"

    @patch("subprocess.Popen")
    def test_play_audio_interruptible(self, mock_popen):
        mock_proc = MagicMock()
        # Simulate running, then we interrupt
        call_count = 0

        def poll_side_effect():
            nonlocal call_count
            call_count += 1
            return None  # Always running

        mock_proc.poll.side_effect = poll_side_effect
        mock_popen.return_value = mock_proc

        loop = VoiceLoop()
        loop._interrupted = True  # Pre-set interrupt
        loop._play_audio("/tmp/test.wav")

        mock_proc.terminate.assert_called_once()


class TestStopMethod:
    def test_stop_sets_running_false(self):
        loop = VoiceLoop()
        loop._running = True
        loop.stop()
        assert loop._running is False


class TestSpeakSetsFlag:
    @patch("timmy.voice_loop.VoiceLoop._speak_say")
    def test_speaking_flag_set_during_speech(self, mock_say):
        cfg = VoiceConfig(use_say_fallback=True)
        loop = VoiceLoop(config=cfg)

        # Before speak
        assert loop._speaking is False

        # Mock say to check flag during execution
        def check_flag(text):
            assert loop._speaking is True

        mock_say.side_effect = check_flag
        loop._speak("Hello")

        # After speak
        assert loop._speaking is False
