"""
Tests for user input processing around the hot window.

These tests verify observable behaviour: given a sequence of events (TTS finishes,
user speaks, time passes), does the system accept or reject the input, and does
the accepted query contain the right text?

Tests exercise VoiceListener._process_transcript with mocked TTS and intent judge
but use real StateManager and EchoDetector instances to avoid coupling to internals.
"""

import time
from unittest.mock import patch, MagicMock

import pytest

from jarvis.listening.state_manager import StateManager, ListeningState
from jarvis.listening.intent_judge import IntentJudgment


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

def _create_listener(**kwargs):
    """Create a VoiceListener with mocked heavy subsystems.

    Returns (listener, mock_tts) so tests can control TTS state.
    Uses real StateManager and EchoDetector — only Whisper, audio, and
    the intent judge are mocked.
    """
    mock_cfg = MagicMock()
    mock_cfg.whisper_model = "small"
    mock_cfg.whisper_device = "auto"
    mock_cfg.whisper_compute_type = "int8"
    mock_cfg.whisper_backend = "faster-whisper"
    mock_cfg.sample_rate = 16000
    mock_cfg.vad_enabled = False
    mock_cfg.vad_aggressiveness = 2
    mock_cfg.echo_tolerance = kwargs.get("echo_tolerance", 0.3)
    mock_cfg.echo_energy_threshold = 2.0
    mock_cfg.hot_window_seconds = kwargs.get("hot_window_seconds", 3.0)
    mock_cfg.hot_window_enabled = True
    mock_cfg.voice_collect_seconds = 2.0
    mock_cfg.voice_max_collect_seconds = 60.0
    mock_cfg.voice_device = None
    mock_cfg.voice_debug = False
    mock_cfg.voice_min_energy = 0.0045
    mock_cfg.tune_enabled = False
    mock_cfg.wake_word = "jarvis"
    mock_cfg.wake_aliases = []
    mock_cfg.wake_fuzzy_ratio = 0.78
    mock_cfg.stop_commands = ["stop", "quiet"]
    mock_cfg.tts_rate = 200
    mock_cfg.transcript_buffer_duration_sec = 120.0
    mock_cfg.intent_judge_model = "gemma4:e2b"
    mock_cfg.ollama_base_url = "http://127.0.0.1:11434"
    mock_cfg.intent_judge_timeout_sec = 3.0
    mock_db = MagicMock()
    mock_tts = MagicMock()
    mock_tts.enabled = True
    mock_tts.is_speaking.return_value = kwargs.get("tts_speaking", False)
    mock_dialogue_memory = MagicMock()

    with patch("jarvis.listening.listener.webrtcvad", None), \
         patch("jarvis.listening.listener.sd", None), \
         patch("jarvis.listening.listener.np", None), \
         patch("jarvis.listening.listener.create_intent_judge", return_value=None):
        from jarvis.listening.listener import VoiceListener
        listener = VoiceListener(mock_db, mock_cfg, mock_tts, mock_dialogue_memory)

    return listener, mock_tts


def _make_judgment(directed=True, query="", stop=False, confidence="high", reasoning="test"):
    """Build an IntentJudgment."""
    return IntentJudgment(
        directed=directed, query=query, stop=stop,
        confidence=confidence, reasoning=reasoning,
    )


def _install_intent_judge(listener, judgment):
    """Replace the listener's intent judge with a mock returning *judgment*."""
    mock_judge = MagicMock()
    mock_judge.available = True
    mock_judge.judge.return_value = judgment
    listener._intent_judge = mock_judge
    return mock_judge


def _simulate_tts_finish(listener):
    """Simulate TTS finishing: track finish time and schedule hot window activation."""
    listener.echo_detector.track_tts_finish()
    listener.state_manager.schedule_hot_window_activation()


def _wait_for_hot_window_active(listener, timeout=0.5):
    """Wait until hot window is formally active (past echo_tolerance delay)."""
    deadline = time.time() + timeout
    while time.time() < deadline:
        if listener.state_manager.is_hot_window_active():
            return True
        time.sleep(0.01)
    return False


def _accepted_query(listener) -> str:
    """Return the accepted query text, or empty string if input was rejected."""
    if listener.state_manager.get_pending_query():
        return listener.state_manager.get_pending_query()
    return ""


# ---------------------------------------------------------------------------
# Tests: User speaks during active hot window
# ---------------------------------------------------------------------------

@pytest.mark.unit
class TestUserSpeaksDuringHotWindow:
    """TTS finishes, hot window activates, user speaks within the window."""

    @patch("builtins.print")
    def test_directed_follow_up_is_accepted(self, _print):
        """User's follow-up question during hot window is accepted."""
        listener, _ = _create_listener(echo_tolerance=0.02, hot_window_seconds=3.0)

        listener.echo_detector.track_tts_start("The weather is sunny today.")
        _simulate_tts_finish(listener)
        _wait_for_hot_window_active(listener)

        _install_intent_judge(listener, _make_judgment(directed=True, query="thanks"))

        listener._process_transcript("thanks", utterance_energy=0.01)

        assert _accepted_query(listener) == "thanks"
        listener.state_manager.stop()

    @patch("builtins.print")
    def test_undirected_background_speech_is_accepted_in_hot_window(self, _print):
        """Non-echo speech during hot window is accepted even if judge says not directed.

        The 3s hot window is short enough that false positives (accepting
        background speech) are preferable to false negatives (ignoring genuine
        follow-ups like 'don't you already know that?'). Small LLMs sometimes
        reject valid follow-ups, so we override in hot window mode.
        """
        listener, _ = _create_listener(echo_tolerance=0.02, hot_window_seconds=3.0)

        listener.echo_detector.track_tts_start("Here is your answer.")
        _simulate_tts_finish(listener)
        _wait_for_hot_window_active(listener)

        _install_intent_judge(listener, _make_judgment(
            directed=False, query="", confidence="high",
            reasoning="background conversation"))

        listener._process_transcript("did you see the game last night", utterance_energy=0.01)

        # In hot window, non-echo speech is always accepted
        assert _accepted_query(listener) == "did you see the game last night"
        listener.state_manager.stop()

    @patch("builtins.print")
    def test_judge_query_is_used_in_hot_window(self, _print):
        """In hot window, the intent judge's extracted query is authoritative.

        The judge is the canonical echo-stripper and noise-pruner; its output
        always wins over the raw transcript. This prevents partial-salvage
        leakage where echo fragments ride through on the raw text. If the
        judge returns an empty query, the listener falls back to raw text.
        """
        listener, _ = _create_listener(echo_tolerance=0.02, hot_window_seconds=3.0)

        listener.echo_detector.track_tts_start("Do you want to know more?")
        _simulate_tts_finish(listener)
        _wait_for_hot_window_active(listener)

        _install_intent_judge(listener, _make_judgment(
            directed=True, query="what is the weather tomorrow"))

        listener._process_transcript(
            "uh okay what is the weather tomorrow", utterance_energy=0.01)

        assert _accepted_query(listener) == "what is the weather tomorrow"
        listener.state_manager.stop()

    @patch("builtins.print")
    def test_empty_judge_query_falls_back_to_raw_text(self, _print):
        """If the judge is directed but returns no query, fall back to raw text."""
        listener, _ = _create_listener(echo_tolerance=0.02, hot_window_seconds=3.0)

        listener.echo_detector.track_tts_start("Do you want to know more?")
        _simulate_tts_finish(listener)
        _wait_for_hot_window_active(listener)

        _install_intent_judge(listener, _make_judgment(directed=True, query=""))

        listener._process_transcript("tell me a joke please", utterance_energy=0.01)

        assert _accepted_query(listener) == "tell me a joke please"
        listener.state_manager.stop()


# ---------------------------------------------------------------------------
# Tests: User starts speaking during hot window, transcript arrives after expiry
# ---------------------------------------------------------------------------

@pytest.mark.unit
class TestTranscriptArrivesAfterHotWindowExpiry:
    """User speaks during hot window but Whisper is slow — transcript arrives after expiry.

    Uses timestamp-based detection: utterance_start_time is compared against the
    hot window's time span, so it doesn't matter when Whisper finishes."""

    @patch("builtins.print")
    def test_speech_started_during_window_accepted_after_expiry(self, _print):
        """Speech that STARTED during the hot window is accepted even after expiry.

        This is the core scenario: user starts speaking at 2.5s into a 3s window,
        Whisper takes 2s to transcribe, so transcript arrives at 4.5s — after
        "Returning to wake word mode". The timestamp check still detects the
        speech started during the window.
        """
        listener, _ = _create_listener(echo_tolerance=0.02, hot_window_seconds=0.08)

        listener.echo_detector.track_tts_start("Short answer.")
        _simulate_tts_finish(listener)
        _wait_for_hot_window_active(listener)

        # Speech starts during active window
        speech_start = time.time()

        # Wait for hot window to expire (simulates Whisper delay)
        time.sleep(0.12)
        assert not listener.state_manager.is_hot_window_active()

        # Transcript arrives after expiry — but speech_start was during window
        _install_intent_judge(listener, _make_judgment(directed=True, query="tell me more"))
        listener._process_transcript(
            "tell me more", utterance_energy=0.01,
            utterance_start_time=speech_start, utterance_end_time=time.time())

        assert _accepted_query(listener) == "tell me more"
        listener.state_manager.stop()

    @patch("builtins.print")
    def test_speech_started_after_expiry_rejected(self, _print):
        """Speech starting AFTER window expired is rejected (requires wake word)."""
        listener, _ = _create_listener(echo_tolerance=0.02, hot_window_seconds=0.05)

        listener.echo_detector.track_tts_start("Short answer.")
        _simulate_tts_finish(listener)
        _wait_for_hot_window_active(listener)

        # Wait for hot window to expire
        time.sleep(0.1)
        assert not listener.state_manager.is_hot_window_active()

        # Speech starts AFTER expiry
        speech_start = time.time()

        _install_intent_judge(listener, _make_judgment(directed=True, query="tell me more"))
        listener._process_transcript(
            "tell me more", utterance_energy=0.01,
            utterance_start_time=speech_start, utterance_end_time=time.time())

        assert _accepted_query(listener) == ""
        listener.state_manager.stop()

    @patch("builtins.print")
    def test_voice_during_active_window_accepted_before_expiry(self, _print):
        """Voice processed while hot window is still active succeeds."""
        listener, _ = _create_listener(echo_tolerance=0.02, hot_window_seconds=3.0)

        listener.echo_detector.track_tts_start("Short answer.")
        _simulate_tts_finish(listener)
        _wait_for_hot_window_active(listener)

        speech_start = time.time()

        _install_intent_judge(listener, _make_judgment(directed=True, query="tell me more"))
        listener._process_transcript(
            "tell me more", utterance_energy=0.01,
            utterance_start_time=speech_start, utterance_end_time=time.time())

        assert _accepted_query(listener) == "tell me more"
        listener.state_manager.stop()

    @patch("builtins.print")
    def test_voice_during_pending_activation_accepted(self, _print):
        """Voice start during echo_tolerance delay (pending activation) still counts."""
        listener, _ = _create_listener(echo_tolerance=0.5, hot_window_seconds=3.0)

        listener.echo_detector.track_tts_start("Answer text.")
        _simulate_tts_finish(listener)

        # Hot window not yet active (still in echo_tolerance delay)
        assert not listener.state_manager.is_hot_window_active()

        # Speech starts now during pending period
        speech_start = time.time()

        _install_intent_judge(listener, _make_judgment(directed=True, query="yes please"))
        listener._process_transcript(
            "yes please", utterance_energy=0.01,
            utterance_start_time=speech_start, utterance_end_time=time.time())

        assert _accepted_query(listener) == "yes please"
        listener.state_manager.stop()

    @patch("builtins.print")
    def test_speech_minutes_after_window_not_treated_as_hot(self, _print):
        """Speech a minute after hot window expired is NOT treated as hot window.

        Regression test: a stale boolean flag previously caused speech long
        after the window to be treated as hot window input.
        """
        listener, _ = _create_listener(echo_tolerance=0.02, hot_window_seconds=0.05)

        listener.echo_detector.track_tts_start("Quick answer.")
        _simulate_tts_finish(listener)
        _wait_for_hot_window_active(listener)

        # Wait for window to expire
        time.sleep(0.1)
        assert not listener.state_manager.is_hot_window_active()

        # Simulate speech "a minute later" (use a start time well after expiry)
        speech_start = time.time() + 0.5  # even 500ms later should be rejected

        _install_intent_judge(listener, _make_judgment(
            directed=True, query="something funny"))
        listener._process_transcript(
            "something funny", utterance_energy=0.01,
            utterance_start_time=speech_start, utterance_end_time=speech_start + 1.0)

        assert _accepted_query(listener) == ""
        listener.state_manager.stop()


# ---------------------------------------------------------------------------
# Tests: Echo and user speech in the same Whisper chunk
# ---------------------------------------------------------------------------

@pytest.mark.unit
class TestEchoAndUserSpeechInSameChunk:
    """Whisper merges echo + user speech into one transcript chunk."""

    @patch("builtins.print")
    def test_mixed_echo_and_speech_after_tts_accepted_in_hot_window(self, _print):
        """When echo + user speech arrive as one chunk in hot window, input is accepted."""
        listener, _ = _create_listener(echo_tolerance=0.02, hot_window_seconds=3.0)

        tts_text = "here is the answer"
        listener.echo_detector.track_tts_start(tts_text)
        _simulate_tts_finish(listener)
        _wait_for_hot_window_active(listener)

        now = time.time()
        # Intent judge sees the mixed text and marks it directed
        _install_intent_judge(listener, _make_judgment(
            directed=True, query="thanks can you also check email"))

        # Mixed chunk: echo + user speech
        listener._process_transcript(
            "here is the answer thanks can you also check email",
            utterance_energy=0.01,
            utterance_start_time=now - 3.0,
            utterance_end_time=now - 0.5,
        )

        # Hot window uses raw text (intent judge handles echo stripping)
        query = _accepted_query(listener)
        assert query != ""
        assert "thanks" in query or "check email" in query
        listener.state_manager.stop()

    @patch("builtins.print")
    def test_echo_plus_speech_from_during_tts_accepted_after_expiry(self, _print):
        """Mixed echo+speech chunk where VAD triggered during TTS is accepted
        even after the hot window expires.

        Real scenario: TTS plays, mic picks up echo (VAD triggers during TTS),
        user speaks during hot window, Whisper takes >3s to transcribe the long
        combined audio, hot window expires, transcript arrives.

        The utterance started BEFORE the hot window span (during TTS) but
        ended DURING the span (user spoke during window). The system should
        recognise this overlap and treat it as hot window input.
        """
        listener, _ = _create_listener(echo_tolerance=0.02, hot_window_seconds=3.0)

        tts_text = "Got it. I will keep my responses short and to the point from now on."
        listener.echo_detector.track_tts_start(tts_text)
        _simulate_tts_finish(listener)
        _wait_for_hot_window_active(listener)

        span_start = listener.state_manager._hot_window_span_start

        # Manually expire hot window (simulates Whisper taking >3s)
        listener.state_manager.expire_hot_window()
        assert not listener.state_manager.is_hot_window_active()

        # Intent judge correctly extracts user speech from mixed transcript
        _install_intent_judge(listener, _make_judgment(
            directed=True,
            query="tell me something random"))

        # Mixed chunk: full TTS echo + user speech appended
        # utterance_start_time is BEFORE span_start (VAD triggered during TTS)
        # utterance_end_time is AFTER span_start (user spoke during window)
        mixed_text = (
            "Got it. I will keep my responses short and to the point from now on. "
            "Yeah, I guess that's fine, but tell me something random."
        )
        listener._process_transcript(
            mixed_text,
            utterance_energy=0.01,
            utterance_start_time=span_start - 2.0,
            utterance_end_time=span_start + 0.05,
        )

        query = _accepted_query(listener)
        assert query != "", (
            "Mixed echo+speech where utterance overlaps hot window should be "
            "accepted, not dropped because utterance_start_time < span_start"
        )
        assert "random" in query
        listener.state_manager.stop()

    @patch("builtins.print")
    def test_mixed_echo_speech_unsalvaged_uses_judge_extraction(self, _print):
        """When salvage fails to strip echo, the post-judge echo check should
        use the intent judge's extraction instead of rejecting everything.

        If the heard text is much longer than TTS (mixed content), the echo
        check should recognise it's not pure echo and fall through to use the
        judge's extracted query.
        """
        listener, _ = _create_listener(echo_tolerance=0.02, hot_window_seconds=3.0)

        tts_text = "The current temperature is around nine degrees celsius."
        listener.echo_detector.track_tts_start(tts_text)
        _simulate_tts_finish(listener)
        _wait_for_hot_window_active(listener)

        # Intent judge correctly extracts user speech
        _install_intent_judge(listener, _make_judgment(
            directed=True,
            query="what will it be tomorrow"))

        # Mixed text where salvage won't work (Whisper transcribed echo differently
        # from TTS text, so exact word matching fails). User speech is substantially
        # longer than TTS echo so word count guard lets it through.
        mixed_text = (
            "the temperature is about 9 degrees. "
            "yeah I figured as much but what will it be like tomorrow afternoon"
        )
        listener._process_transcript(
            mixed_text,
            utterance_energy=0.01,
        )

        query = _accepted_query(listener)
        assert query != "", (
            "Mixed echo+speech should not be rejected when text is longer than TTS"
        )
        assert "tomorrow" in query
        listener.state_manager.stop()

    @patch("builtins.print")
    def test_judge_echo_reasoning_overridden_for_mixed_content_in_hot_window(self, _print):
        """When the intent judge says 'not directed' with echo reasoning but the
        utterance overlaps the hot window and text is longer than TTS (mixed
        echo+speech), the rejection should be overridden.

        Real scenario: TTS plays, mic picks up echo + user speaks during hot window,
        hot window expires, Whisper delivers mixed transcript. Intent judge sees TTS
        text in transcript and says 'echo, not directed'. But the word-count guard
        shows it's mixed content and could_be_hot_window is True, so the override
        should kick in.
        """
        listener, _ = _create_listener(echo_tolerance=0.02, hot_window_seconds=3.0)

        tts_text = "You are currently in Tbilisi, Georgia."
        listener.echo_detector.track_tts_start(tts_text)
        _simulate_tts_finish(listener)
        _wait_for_hot_window_active(listener)

        span_start = listener.state_manager._hot_window_span_start

        # Hot window expires (Whisper is slow)
        listener.state_manager.expire_hot_window()
        assert not listener.state_manager.is_hot_window_active()

        # Intent judge incorrectly classifies as echo (sees TTS text in transcript)
        _install_intent_judge(listener, _make_judgment(
            directed=False,
            query="",
            confidence="high",
            reasoning="echo of TTS output"))

        mixed_text = (
            "you are currently in T-Ballista Georgia and what do you think "
            "about Joseph Stalin and communism in general?"
        )
        listener._process_transcript(
            mixed_text,
            utterance_energy=0.01,
            utterance_start_time=span_start - 2.0,
            utterance_end_time=span_start + 0.05,
        )

        query = _accepted_query(listener)
        assert query != "", (
            "Mixed echo+speech should be accepted in hot window even when "
            "intent judge says 'echo, not directed' — word count shows mixed content"
        )
        assert "stalin" in query.lower() or "communism" in query.lower()
        listener.state_manager.stop()

    @patch("builtins.print")
    def test_judge_returns_none_hot_window_speech_still_accepted(self, _print):
        """When the intent judge times out or errors (returns None), hot window
        speech that passes the echo check should still be accepted.

        Real scenario: user speaks during hot window, Whisper delivers mixed
        echo+speech, intent judge times out on the long transcript. The beep
        started (early check passed) but the query is silently dropped because
        the judge-None path falls through to wake word detection.
        """
        listener, _ = _create_listener(echo_tolerance=0.02, hot_window_seconds=3.0)

        tts_text = "You are currently in Tbilisi, Georgia."
        listener.echo_detector.track_tts_start(tts_text)
        _simulate_tts_finish(listener)
        _wait_for_hot_window_active(listener)

        span_start = listener.state_manager._hot_window_span_start

        # Hot window expires (Whisper is slow)
        listener.state_manager.expire_hot_window()

        # Intent judge returns None (timeout)
        _install_intent_judge(listener, None)

        mixed_text = (
            "you are currently in T-Ballista Georgia and what do you think "
            "about Joseph Stalin and communism in general?"
        )
        listener._process_transcript(
            mixed_text,
            utterance_energy=0.01,
            utterance_start_time=span_start - 2.0,
            utterance_end_time=span_start + 0.05,
        )

        query = _accepted_query(listener)
        assert query != "", (
            "Hot window speech should be accepted even when intent judge "
            "times out — the early echo check already cleared it"
        )
        assert "stalin" in query.lower() or "communism" in query.lower()
        listener.state_manager.stop()

    @patch("builtins.print")
    def test_utterance_starting_during_tts_ending_after_treated_as_hot_window(self, _print):
        """Utterance that starts before TTS finishes is still treated as hot window context."""
        listener, _ = _create_listener(echo_tolerance=0.02, hot_window_seconds=3.0)

        listener.echo_detector.track_tts_start("some response text")
        tts_finish = time.time()
        listener.echo_detector.track_tts_finish()
        listener.state_manager.schedule_hot_window_activation()
        _wait_for_hot_window_active(listener)

        # Utterance started 0.5s BEFORE TTS finished, ended 1s after
        utterance_start = tts_finish - 0.5
        utterance_end = tts_finish + 1.0

        _install_intent_judge(listener, _make_judgment(directed=True, query="tell me more"))

        listener._process_transcript(
            "tell me more",
            utterance_energy=0.01,
            utterance_start_time=utterance_start,
            utterance_end_time=utterance_end,
        )

        assert _accepted_query(listener) == "tell me more"
        listener.state_manager.stop()

    @patch("builtins.print")
    def test_early_echo_check_salvages_trailing_user_speech(self, _print):
        """Early echo check must salvage user speech appended after an echo prefix.

        Whisper often merges the tail of TTS echo with the user's follow-up into
        one transcript. The early fuzzy echo check used to reject the whole chunk,
        so the user's real speech was dropped before the intent judge could see it.
        """
        listener, _ = _create_listener(echo_tolerance=0.02, hot_window_seconds=3.0)
        tts_text = (
            "I do have a tool to check the weather, but I need to use it with a "
            "location. I can check the forecast for London for you right now."
        )
        listener.echo_detector.track_tts_start(tts_text)
        _simulate_tts_finish(listener)
        _wait_for_hot_window_active(listener)

        _install_intent_judge(listener, _make_judgment(
            directed=True, query="yeah go ahead and do that"))

        # Mixed chunk: exact tail of TTS echo + user's follow-up
        listener._process_transcript(
            "I can check the forecast for London for you right now. "
            "Yeah, go ahead and do that.",
            utterance_energy=0.01,
        )

        query = _accepted_query(listener)
        assert query != "", "Trailing user speech should be salvaged, not rejected as echo"
        assert "go ahead" in query.lower()
        listener.state_manager.stop()

    @patch("builtins.print")
    def test_early_echo_salvage_accepts_at_minimum_word_count(self, _print):
        """Salvaged remainder at exactly min_salvage_words should be accepted."""
        listener, _ = _create_listener(echo_tolerance=0.02, hot_window_seconds=3.0)
        min_words = listener.echo_detector.min_salvage_words

        tts_text = "The weather is going to be sunny today in London."
        listener.echo_detector.track_tts_start(tts_text)
        _simulate_tts_finish(listener)
        _wait_for_hot_window_active(listener)

        follow_up_words = ["thanks", "tell", "me", "more", "please"][:min_words]
        follow_up = " ".join(follow_up_words)
        _install_intent_judge(listener, _make_judgment(directed=True, query=follow_up))

        listener._process_transcript(
            f"{tts_text} {follow_up}",
            utterance_energy=0.01,
        )

        assert _accepted_query(listener) != "", (
            f"Remainder of exactly {min_words} words should be salvaged"
        )
        listener.state_manager.stop()

    @patch("builtins.print")
    def test_early_echo_salvage_rejects_below_minimum_word_count(self, _print):
        """Salvaged remainder below min_salvage_words should be rejected as echo."""
        listener, _ = _create_listener(echo_tolerance=0.02, hot_window_seconds=3.0)
        min_words = listener.echo_detector.min_salvage_words

        tts_text = "The weather is going to be sunny today in London."
        listener.echo_detector.track_tts_start(tts_text)
        _simulate_tts_finish(listener)
        _wait_for_hot_window_active(listener)

        short_tail = " ".join(["really", "nice"][: max(min_words - 1, 1)])
        judge = _install_intent_judge(listener, _make_judgment(
            directed=True, query=short_tail, reasoning="should not be consulted"))

        listener._process_transcript(
            f"{tts_text} {short_tail}",
            utterance_energy=0.01,
        )

        assert _accepted_query(listener) == "", (
            f"Remainder below {min_words} words should be rejected"
        )
        judge.judge.assert_not_called()
        listener.state_manager.stop()

    @patch("builtins.print")
    def test_early_echo_salvage_rejects_when_no_prefix_match(self, _print):
        """If cleanup_leading_echo can't strip any prefix, fall back to rejection."""
        listener, _ = _create_listener(echo_tolerance=0.02, hot_window_seconds=3.0)

        tts_text = "alpha beta gamma delta epsilon zeta"
        listener.echo_detector.track_tts_start(tts_text)
        _simulate_tts_finish(listener)
        _wait_for_hot_window_active(listener)

        judge = _install_intent_judge(listener, _make_judgment(
            directed=False, query="", reasoning="should not be consulted"))

        # Shares enough words with TTS to clear partial_ratio >= 70 (marks it
        # echo) but the tokens are in a different order so cleanup_leading_echo
        # cannot find a matching prefix — nothing to salvage.
        listener._process_transcript(
            "beta alpha delta gamma zeta epsilon",
            utterance_energy=0.01,
        )

        assert _accepted_query(listener) == "", (
            "Chunk with no strippable prefix should be rejected as pure echo"
        )
        judge.judge.assert_not_called()
        listener.state_manager.stop()


# ---------------------------------------------------------------------------
# Tests: Grace period boundaries
# ---------------------------------------------------------------------------

@pytest.mark.unit
class TestHotWindowOnlyFromStateManager:
    """Hot window status comes exclusively from the state manager's formal
    activation/expiry — not from time-based grace periods. This prevents
    false hot window claims after the user has seen 'Returning to wake word mode'."""

    @patch("builtins.print")
    def test_recent_tts_without_hot_window_activation_not_treated_as_hot(self, _print):
        """TTS finishing without hot window activation does not create a hot window."""
        listener, _ = _create_listener(
            hot_window_seconds=3.0,
            echo_tolerance=0.3,
        )

        # Track TTS finish but do NOT schedule hot window activation
        listener.echo_detector.track_tts_start("answer text")
        listener.echo_detector.track_tts_finish()

        # Judge says directed, but no wake word and no hot window
        _install_intent_judge(listener, _make_judgment(directed=True, query="thanks"))

        listener._process_transcript("thanks", utterance_energy=0.01)

        # Should NOT be accepted — no hot window active, no wake word
        assert _accepted_query(listener) == ""
        listener.state_manager.stop()

    @patch("builtins.print")
    def test_formal_hot_window_activation_required(self, _print):
        """Only formally activated hot window allows wake-word-free input."""
        listener, _ = _create_listener(
            hot_window_seconds=3.0,
            echo_tolerance=0.02,
        )

        listener.echo_detector.track_tts_start("old answer")
        listener.echo_detector.track_tts_finish()
        tts_finish = listener.echo_detector._last_tts_finish_time

        # Judge says directed, but no wake word in text — should be rejected
        _install_intent_judge(listener, _make_judgment(directed=True, query="hello there"))

        listener._process_transcript(
            "hello there",
            utterance_energy=0.01,
            utterance_start_time=tts_finish + 0.5,
            utterance_end_time=tts_finish + 1.0,
        )

        assert _accepted_query(listener) == ""
        listener.state_manager.stop()

    @patch("builtins.print")
    def test_no_timestamps_with_active_hot_window_accepted(self, _print):
        """When Whisper provides no timestamps but hot window is active, accepted."""
        listener, _ = _create_listener(hot_window_seconds=3.0, echo_tolerance=0.02)

        listener.echo_detector.track_tts_start("recent response")
        _simulate_tts_finish(listener)
        _wait_for_hot_window_active(listener)

        _install_intent_judge(listener, _make_judgment(directed=True, query="and also"))

        listener._process_transcript(
            "and also",
            utterance_energy=0.01,
            utterance_start_time=0,
            utterance_end_time=0,
        )

        assert _accepted_query(listener) == "and also"
        listener.state_manager.stop()

    @patch("builtins.print")
    def test_no_timestamps_without_hot_window_rejected(self, _print):
        """When Whisper provides no timestamps and no hot window, requires wake word."""
        listener, _ = _create_listener(hot_window_seconds=3.0, echo_tolerance=0.3)

        listener.echo_detector.track_tts_start("stale response")
        # TTS finished but no hot window scheduled
        listener.echo_detector.track_tts_finish()

        _install_intent_judge(listener, _make_judgment(directed=True, query="random remark"))

        listener._process_transcript(
            "random remark",
            utterance_energy=0.01,
            utterance_start_time=0,
            utterance_end_time=0,
        )

        assert _accepted_query(listener) == ""
        listener.state_manager.stop()


# ---------------------------------------------------------------------------
# Tests: Echo rejection does NOT extend the hot window
# ---------------------------------------------------------------------------

@pytest.mark.unit
class TestEchoRejectionDoesNotExtendFollowUpWindow:
    """Echo is caught early (instant fuzzy check), so it doesn't block the
    audio loop or extend the hot window. The original window duration applies."""

    @patch("builtins.print")
    def test_echo_does_not_reset_window_timer(self, _print):
        """Echo rejection leaves the original window timer untouched."""
        listener, _ = _create_listener(echo_tolerance=0.02, hot_window_seconds=3.0)

        listener.echo_detector.track_tts_start("The answer is 42.")
        _simulate_tts_finish(listener)
        _wait_for_hot_window_active(listener)

        original_start = listener.state_manager._hot_window_start_time

        # Feed echo — caught early
        listener._process_transcript("The answer is 42", utterance_energy=0.01)

        # Window timer should not have been reset
        assert listener.state_manager._hot_window_start_time == original_start
        # Window still active (within original 3s)
        assert listener.state_manager.is_hot_window_active()

        # User speaks within the original window
        _install_intent_judge(listener, _make_judgment(directed=True, query="thanks"))
        listener._process_transcript("thanks", utterance_energy=0.01)

        assert _accepted_query(listener) == "thanks"
        listener.state_manager.stop()

    @patch("builtins.print")
    def test_echo_after_window_expiry_does_not_reactivate(self, _print):
        """Late echo arrival after window expired does NOT reactivate the window."""
        listener, _ = _create_listener(echo_tolerance=0.02, hot_window_seconds=0.05)

        listener.echo_detector.track_tts_start("Short reply.")
        _simulate_tts_finish(listener)
        _wait_for_hot_window_active(listener)

        # Let hot window expire
        time.sleep(0.1)
        assert not listener.state_manager.is_hot_window_active()

        # Late echo arrives — window should stay expired
        listener._process_transcript("Short reply", utterance_energy=0.01)
        assert not listener.state_manager.is_hot_window_active()

        # Speech without wake word should be rejected
        _install_intent_judge(listener, _make_judgment(directed=True, query="one more thing"))
        listener._process_transcript("one more thing", utterance_energy=0.01)

        assert _accepted_query(listener) == ""
        listener.state_manager.stop()


@pytest.mark.unit
class TestLongTtsTailEcho:
    """Echoes of the TAIL of a long TTS response must still be rejected. The
    fuzzy echo check previously truncated TTS to 300 chars, so tail echoes from
    longer responses slipped through and were accepted as user speech."""

    @patch("builtins.print")
    def test_tail_echo_from_long_tts_rejected(self, _print):
        """Echo of the final clause of a ~370-char TTS is caught, not accepted."""
        long_tts = (
            "You asked for something interesting, so I found that there are "
            "over 1800 creative writing prompts available across various genres, "
            "including themes like a character losing the ability to create or "
            "an intangible concept becoming a real object. I also found that "
            "evolving marketing tactics rely on using data, leveraging "
            "analytics, and being agile to understand user behavior."
        )
        assert len(long_tts) > 300  # Guard: the bug only manifests past old cap

        listener, _ = _create_listener(echo_tolerance=0.02, hot_window_seconds=3.0)
        listener.echo_detector.track_tts_start(long_tts)
        _simulate_tts_finish(listener)
        _wait_for_hot_window_active(listener)

        # Mic picks up the tail of the TTS response — this is pure echo.
        tail_echo = "leveraging analytics and being agile to understand user behavior."
        _install_intent_judge(
            listener,
            _make_judgment(directed=False, reasoning="Segment is an echo"),
        )
        listener._process_transcript(tail_echo, utterance_energy=0.01)

        assert _accepted_query(listener) == ""
        listener.state_manager.stop()


# ---------------------------------------------------------------------------
# Tests: Early beep and face state feedback
# ---------------------------------------------------------------------------

def _is_beeping(listener) -> bool:
    """Check if the thinking tune is currently active."""
    return listener._tune_player is not None


@pytest.mark.unit
class TestEarlyBeepFeedback:
    """Beep should start immediately after Whisper transcription, before the
    intent judge runs. This gives instant auditory feedback to the user."""

    @patch("builtins.print")
    def test_beep_starts_on_wake_word_before_intent_judge(self, _print):
        """Beep starts right after 'Heard' when wake word is present."""
        listener, _ = _create_listener(echo_tolerance=0.02, hot_window_seconds=3.0)
        listener.cfg.tune_enabled = True

        # No intent judge installed — beep should still start from the
        # early detection path, then fallback wake word check processes query.
        listener._process_transcript("jarvis what time is it", utterance_energy=0.01)

        assert _accepted_query(listener) != ""
        listener.state_manager.stop()

    @patch("builtins.print")
    def test_beep_starts_in_hot_window_before_intent_judge(self, _print):
        """Beep starts right after 'Heard' when in hot window."""
        listener, _ = _create_listener(echo_tolerance=0.02, hot_window_seconds=3.0)
        listener.cfg.tune_enabled = True

        listener.echo_detector.track_tts_start("Here is the answer.")
        _simulate_tts_finish(listener)
        _wait_for_hot_window_active(listener)

        _install_intent_judge(listener, _make_judgment(directed=True, query="tell me more"))
        listener._process_transcript("tell me more", utterance_energy=0.01)

        assert _accepted_query(listener) == "tell me more"
        listener.state_manager.stop()

    @patch("builtins.print")
    def test_no_beep_without_wake_word_or_hot_window(self, _print):
        """No beep when there's no wake word and not in hot window."""
        listener, _ = _create_listener(echo_tolerance=0.02, hot_window_seconds=3.0)
        listener.cfg.tune_enabled = True

        # Random speech, no wake word, no hot window
        listener._process_transcript("the weather is nice today", utterance_energy=0.01)

        assert _accepted_query(listener) == ""
        # Beep should not have been started (and if it was, it was stopped)
        assert not _is_beeping(listener)
        listener.state_manager.stop()

    @patch("builtins.print")
    def test_beep_stops_when_intent_judge_rejects(self, _print):
        """Early beep is stopped if intent judge rejects the input."""
        listener, _ = _create_listener(echo_tolerance=0.02, hot_window_seconds=3.0)
        listener.cfg.tune_enabled = True

        # Install judge that rejects — speech has wake word so early beep fires,
        # but judge says not directed so beep should be stopped.
        _install_intent_judge(listener, _make_judgment(
            directed=False, query="", confidence="high",
            reasoning="narrative mention"))

        listener._process_transcript("jarvis is a cool name", utterance_energy=0.01)

        # Query should NOT be accepted (judge rejected + fallback wake word
        # check won't find a query after "jarvis")
        assert not _is_beeping(listener)
        listener.state_manager.stop()

    @patch("builtins.print")
    def test_no_beep_during_tts_playback(self, _print):
        """Beep does not start while TTS is actively speaking."""
        listener, mock_tts = _create_listener(
            echo_tolerance=0.02, hot_window_seconds=3.0, tts_speaking=True)
        listener.cfg.tune_enabled = True

        listener._process_transcript("jarvis what time is it", utterance_energy=0.01)

        # Should not beep during TTS (stop command path handles TTS interrupts)
        assert not _is_beeping(listener)
        listener.state_manager.stop()


# ---------------------------------------------------------------------------
# Tests: Echo caught early in hot window (no intent judge, no window reset)
# ---------------------------------------------------------------------------

@pytest.mark.unit
class TestEchoRejectionInHotWindow:
    """Echo in the hot window is caught by the early fuzzy check before
    the intent judge runs. The hot window timer is NOT reset."""

    @patch("builtins.print")
    def test_confirmed_echo_rejected_without_intent_judge(self, _print):
        """Echo matching TTS is caught early — intent judge never runs."""
        listener, _ = _create_listener(echo_tolerance=0.02, hot_window_seconds=3.0)
        tts_text = "The weather will be sunny tomorrow."

        listener.echo_detector.track_tts_start(tts_text)
        _simulate_tts_finish(listener)
        _wait_for_hot_window_active(listener)

        judge = _install_intent_judge(listener, _make_judgment(
            directed=False, query="", confidence="high",
            reasoning="echo of assistant speech"))

        listener._process_transcript(
            "the weather will be sunny tomorrow",
            utterance_energy=0.01)

        # Echo caught early — no query accepted, no intent judge called
        assert _accepted_query(listener) == ""
        judge.judge.assert_not_called()
        # Hot window still active (within original 3s, NOT reset)
        assert listener.state_manager.is_hot_window_active()
        listener.state_manager.stop()

    @patch("builtins.print")
    def test_echo_rejected_before_intent_judge_can_accept(self, _print):
        """Echo is caught early even when intent judge would say directed.

        The mic picks up Jarvis's TTS output and Whisper transcribes it.
        The early fuzzy check catches it before the intent judge runs.
        """
        listener, _ = _create_listener(echo_tolerance=0.02, hot_window_seconds=3.0)
        tts_text = "Georgian cuisine is incredibly rich and you should try Khachapuri and Georgian bread."

        listener.echo_detector.track_tts_start(tts_text)
        _simulate_tts_finish(listener)
        _wait_for_hot_window_active(listener)

        judge = _install_intent_judge(listener, _make_judgment(
            directed=True, query="and kg chai like georgian bread",
            confidence="high", reasoning="user follow-up"))

        listener._process_transcript(
            "and kg chai like georgian bread",
            utterance_energy=0.01)

        # Echo caught early — no query accepted
        assert _accepted_query(listener) == ""
        # Intent judge never called
        judge.judge.assert_not_called()
        listener.state_manager.stop()

    @patch("builtins.print")
    def test_non_echo_speech_accepted_via_override(self, _print):
        """Non-echo speech in hot window is accepted even if judge rejects.

        In hot window, non-echo speech is always accepted (override), since
        small LLMs sometimes reject valid follow-ups.
        """
        listener, _ = _create_listener(echo_tolerance=0.02, hot_window_seconds=3.0)
        tts_text = "The weather will be sunny tomorrow."

        listener.echo_detector.track_tts_start(tts_text)
        _simulate_tts_finish(listener)
        _wait_for_hot_window_active(listener)

        # Judge rejects unrelated speech
        _install_intent_judge(listener, _make_judgment(
            directed=False, query="", confidence="high",
            reasoning="background conversation"))

        listener._process_transcript(
            "did you see the game last night",
            utterance_energy=0.01)

        # Non-echo speech in hot window is accepted via override
        assert _accepted_query(listener) == "did you see the game last night"
        listener.state_manager.stop()


# ---------------------------------------------------------------------------
# Tests: Hot window boundary enforcement
# ---------------------------------------------------------------------------

@pytest.mark.unit
class TestHotWindowBoundary:
    """The hot window has a strict time boundary. Speech arriving after
    the window expires should require wake word detection."""

    @patch("builtins.print")
    def test_speech_within_window_accepted(self, _print):
        """Speech processed while hot window is active is accepted."""
        listener, _ = _create_listener(echo_tolerance=0.02, hot_window_seconds=3.0)

        listener.echo_detector.track_tts_start("Short answer.")
        _simulate_tts_finish(listener)
        _wait_for_hot_window_active(listener)

        _install_intent_judge(listener, _make_judgment(directed=True, query="thanks"))
        listener._process_transcript("thanks", utterance_energy=0.01)

        assert _accepted_query(listener) == "thanks"
        listener.state_manager.stop()

    @patch("builtins.print")
    def test_speech_after_window_requires_wake_word(self, _print):
        """Speech arriving after hot window expired requires wake word."""
        listener, _ = _create_listener(echo_tolerance=0.02, hot_window_seconds=0.05)

        listener.echo_detector.track_tts_start("Short answer.")
        _simulate_tts_finish(listener)
        _wait_for_hot_window_active(listener)

        # Let hot window expire
        time.sleep(0.1)
        assert not listener.state_manager.is_hot_window_active()

        # Speech without wake word — should be rejected
        _install_intent_judge(listener, _make_judgment(directed=True, query="tell me more"))
        listener._process_transcript("tell me more", utterance_energy=0.01)

        assert _accepted_query(listener) == ""
        listener.state_manager.stop()

    @patch("builtins.print")
    def test_speech_after_window_with_wake_word_accepted(self, _print):
        """Speech after hot window expired but containing wake word is accepted."""
        listener, _ = _create_listener(echo_tolerance=0.02, hot_window_seconds=0.05)

        listener.echo_detector.track_tts_start("Short answer.")
        _simulate_tts_finish(listener)
        _wait_for_hot_window_active(listener)

        # Let hot window expire
        time.sleep(0.1)
        assert not listener.state_manager.is_hot_window_active()

        # Speech with wake word — accepted via wake word detection fallback
        _install_intent_judge(listener, _make_judgment(
            directed=True, query="what time is it"))
        listener._process_transcript("jarvis what time is it", utterance_energy=0.01)

        assert _accepted_query(listener) != ""
        listener.state_manager.stop()


# ---------------------------------------------------------------------------
# Tests: Echo is caught early (before beep and intent judge)
# ---------------------------------------------------------------------------

@pytest.mark.unit
class TestEchoCaughtBeforeBeepAndIntentJudge:
    """Echo in the hot window must be caught BEFORE the thinking beep starts
    and before the intent judge is called. This prevents:
    1. False beep on echo (user hears beep then nothing happens)
    2. Intent judge blocking the audio loop for seconds on echo
    3. Hot window extending indefinitely from repeated echo resets
    """

    @patch("builtins.print")
    def test_echo_in_hot_window_does_not_trigger_beep(self, _print):
        """Echo matching TTS output should not start the thinking beep."""
        listener, _ = _create_listener(echo_tolerance=0.02, hot_window_seconds=3.0)
        listener.cfg.tune_enabled = True
        tts_text = "Tbilisi is a must-see especially the colourful old town."

        listener.echo_detector.track_tts_start(tts_text)
        _simulate_tts_finish(listener)
        _wait_for_hot_window_active(listener)

        # Install intent judge that should NOT be called for echo
        judge = _install_intent_judge(listener, _make_judgment(
            directed=True, query="tbilisi is a must-see"))

        listener._process_transcript(
            "Tbilisi is a must-see especially the colourful old town",
            utterance_energy=0.01)

        # No beep should have started
        assert not _is_beeping(listener)
        # Echo should be rejected — no query accepted
        assert _accepted_query(listener) == ""
        listener.state_manager.stop()

    @patch("builtins.print")
    def test_echo_in_hot_window_skips_intent_judge(self, _print):
        """Echo caught early should not invoke the intent judge at all."""
        listener, _ = _create_listener(echo_tolerance=0.02, hot_window_seconds=3.0)
        tts_text = "For breathtaking scenery you should explore the mountainous regions."

        listener.echo_detector.track_tts_start(tts_text)
        _simulate_tts_finish(listener)
        _wait_for_hot_window_active(listener)

        judge = _install_intent_judge(listener, _make_judgment(
            directed=True, query="explore the mountainous regions"))

        listener._process_transcript(
            "For breathtaking scenery you should explore the mountainous regions like Steneti",
            utterance_energy=0.01)

        # Intent judge should not have been called
        judge.judge.assert_not_called()
        assert _accepted_query(listener) == ""
        listener.state_manager.stop()

    @patch("builtins.print")
    def test_echo_does_not_extend_hot_window(self, _print):
        """Echo rejection should NOT reset/extend the hot window timer.

        Previously, each echo chunk called reset_hot_window_expiry(), extending
        the window by another full duration. With multiple echo chunks, this
        created a window lasting 6+ seconds instead of 3, causing speech long
        after TTS to be treated as hot window input.
        """
        listener, _ = _create_listener(echo_tolerance=0.02, hot_window_seconds=0.10)
        tts_text = "The answer is sunny and warm."

        listener.echo_detector.track_tts_start(tts_text)
        _simulate_tts_finish(listener)
        _wait_for_hot_window_active(listener)

        # Record when hot window started
        original_start = listener.state_manager._hot_window_start_time

        # Process echo — should be caught early
        listener._process_transcript(
            "the answer is sunny and warm",
            utterance_energy=0.01)

        # Hot window start time should NOT have been reset
        assert listener.state_manager._hot_window_start_time == original_start

        # Wait for original window to expire
        time.sleep(0.15)
        assert not listener.state_manager.is_hot_window_active()
        listener.state_manager.stop()

    @patch("builtins.print")
    def test_non_echo_in_hot_window_still_triggers_beep(self, _print):
        """Non-echo speech in hot window should still get the early beep."""
        listener, _ = _create_listener(echo_tolerance=0.02, hot_window_seconds=3.0)
        listener.cfg.tune_enabled = True
        tts_text = "The weather is sunny today."

        listener.echo_detector.track_tts_start(tts_text)
        _simulate_tts_finish(listener)
        _wait_for_hot_window_active(listener)

        _install_intent_judge(listener, _make_judgment(
            directed=True, query="what about tomorrow"))

        listener._process_transcript("what about tomorrow", utterance_energy=0.01)

        assert _accepted_query(listener) == "what about tomorrow"
        listener.state_manager.stop()

    @patch("builtins.print")
    def test_multiple_echo_chunks_do_not_stack_window_extensions(self, _print):
        """Multiple echo chunks should not extend the hot window repeatedly.

        Real scenario: TTS response is split into 2+ Whisper chunks. Each
        previously reset the timer, creating a window of N*hot_window_seconds.
        Now echo is caught early without any timer reset.
        """
        listener, _ = _create_listener(echo_tolerance=0.02, hot_window_seconds=0.10)
        tts_text = "Tbilisi is a must-see. For breathtaking scenery explore Svaneti."

        listener.echo_detector.track_tts_start(tts_text)
        _simulate_tts_finish(listener)
        _wait_for_hot_window_active(listener)

        # First echo chunk
        listener._process_transcript(
            "Tbilisi is a must-see especially the colourful old town",
            utterance_energy=0.01)

        # Second echo chunk
        listener._process_transcript(
            "For breathtaking scenery you should explore Steneti",
            utterance_energy=0.01)

        # Both should be rejected
        assert _accepted_query(listener) == ""

        # Window should still expire on original schedule
        time.sleep(0.15)
        assert not listener.state_manager.is_hot_window_active()

        # Speech after expiry requires wake word
        _install_intent_judge(listener, _make_judgment(
            directed=True, query="what the hell"))
        listener._process_transcript("what the hell", utterance_energy=0.01)
        assert _accepted_query(listener) == ""
        listener.state_manager.stop()


# ---------------------------------------------------------------------------
# Tests: Speech without wake word outside hot window is ignored
# ---------------------------------------------------------------------------

@pytest.mark.unit
class TestSpeechIgnoredOutsideHotWindow:
    """When no hot window is active and no wake word is present, all speech
    should be completely ignored — no beep, no intent judge query, no action.
    This is the default idle state."""

    @patch("builtins.print")
    def test_complete_sentence_without_wake_word_ignored(self, _print):
        """A full sentence without wake word and no hot window is ignored."""
        listener, _ = _create_listener(echo_tolerance=0.02, hot_window_seconds=3.0)

        # Judge would accept if asked — but it shouldn't matter
        _install_intent_judge(listener, _make_judgment(
            directed=True, query="what is the meaning of life"))

        listener._process_transcript(
            "what is the meaning of life",
            utterance_energy=0.01,
        )

        assert _accepted_query(listener) == ""
        listener.state_manager.stop()

    @patch("builtins.print")
    def test_no_beep_no_intent_for_background_chatter(self, _print):
        """Background conversation without wake word triggers no beep and
        no intent judge invocation."""
        listener, _ = _create_listener(echo_tolerance=0.02, hot_window_seconds=3.0)
        listener.cfg.tune_enabled = True

        judge = _install_intent_judge(listener, _make_judgment(
            directed=True, query="pass the salt"))

        listener._process_transcript(
            "hey can you pass the salt please",
            utterance_energy=0.01,
        )

        assert _accepted_query(listener) == ""
        # Intent judge should still be called (it's the decision-maker),
        # but since it returns directed without wake word, it's rejected
        listener.state_manager.stop()

    @patch("builtins.print")
    def test_multiple_utterances_after_hot_window_all_ignored(self, _print):
        """Multiple consecutive utterances after hot window expires are all
        ignored if they lack a wake word. The system stays in wake word mode."""
        listener, _ = _create_listener(echo_tolerance=0.02, hot_window_seconds=3.0)

        listener.echo_detector.track_tts_start("The answer is 42.")
        _simulate_tts_finish(listener)
        _wait_for_hot_window_active(listener)

        # Expire hot window
        listener.state_manager.expire_hot_window()
        assert not listener.state_manager.is_hot_window_active()

        # Install judge that would accept everything
        _install_intent_judge(listener, _make_judgment(
            directed=True, query="first remark"))

        # First utterance — no wake word, no hot window
        listener._process_transcript("I think it might rain later", utterance_energy=0.01)
        assert _accepted_query(listener) == ""

        # Second utterance — still no wake word, still no hot window
        _install_intent_judge(listener, _make_judgment(
            directed=True, query="second remark"))
        listener._process_transcript("yeah the forecast said so", utterance_energy=0.01)
        assert _accepted_query(listener) == ""

        # Third utterance with wake word — THIS should work
        _install_intent_judge(listener, _make_judgment(
            directed=True, query="will it rain"))
        listener._process_transcript("jarvis will it rain today", utterance_energy=0.01)
        assert "rain" in _accepted_query(listener)
        listener.state_manager.stop()

    @patch("builtins.print")
    def test_speech_long_after_any_tts_ignored(self, _print):
        """Speech arriving long after any TTS activity is ignored without
        wake word, even if the intent judge says directed."""
        listener, _ = _create_listener(echo_tolerance=0.02, hot_window_seconds=3.0)

        # TTS happened ages ago, hot window long expired
        listener.echo_detector.track_tts_start("Old response.")
        listener.echo_detector.track_tts_finish()
        # No hot window scheduled — simulates a stale session

        _install_intent_judge(listener, _make_judgment(
            directed=True, query="hey what time is it"))

        # Speech with timestamps well after any TTS
        now = time.time()
        listener._process_transcript(
            "hey what time is it",
            utterance_energy=0.01,
            utterance_start_time=now,
            utterance_end_time=now + 1.0,
        )

        assert _accepted_query(listener) == ""
        listener.state_manager.stop()


# ---------------------------------------------------------------------------
# Tests: Stale wake timestamp must not leak across utterances
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestStaleWakeTimestampAcrossUtterances:
    """After the intent judge rejects a wake-worded utterance, the next
    utterance without a wake word must not be accepted just because the
    previous utterance had one.

    Real-world bug: user said "Jarvis, remember..." (rejected by judge),
    then said "Hey Google, TV off." The judge saw the previous "Jarvis"
    in its buffer and returned directed=true with query="tv off". The
    verification guard `_wake_timestamp is not None` short-circuited true
    because it was never cleared, so the unrelated "Hey Google" command
    was accepted.
    """

    @patch("builtins.print")
    def test_rejected_wake_utterance_does_not_vouch_for_next_utterance(self, _print):
        """A prior rejected wake-worded utterance must not authorise a later
        utterance that lacks a wake word."""
        listener, _ = _create_listener(echo_tolerance=0.3, hot_window_seconds=3.0)

        # First utterance: has "jarvis", judge rejects as not directed
        _install_intent_judge(listener, _make_judgment(
            directed=False, query="", confidence="high",
            reasoning="statement to self, not directed"))

        now = time.time()
        listener._process_transcript(
            "jarvis i want you to remember that my other office days are thursdays",
            utterance_energy=0.01,
            utterance_start_time=now,
            utterance_end_time=now + 2.0,
        )
        assert _accepted_query(listener) == ""

        # Second utterance: no wake word, judge hallucinates directed=true
        # (e.g. because the earlier "jarvis" is still in its context buffer)
        _install_intent_judge(listener, _make_judgment(
            directed=True, query="tv off", confidence="high",
            reasoning="synthesised from buffer"))

        listener._process_transcript(
            "hey google, tv off.",
            utterance_energy=0.01,
            utterance_start_time=now + 5.0,
            utterance_end_time=now + 6.0,
        )

        # Must be rejected — no wake word in this utterance, no hot window
        assert _accepted_query(listener) == "", (
            "Second utterance without wake word must not be accepted just "
            "because a prior utterance set _wake_timestamp")
        listener.state_manager.stop()


@pytest.mark.unit
class TestIntentJudgeGating:
    """The intent judge must not be called on pure ambient speech.

    Calling it on every utterance blocks the audio loop for up to
    `intent_judge_timeout_sec` on each background chatter, which can
    cascade into UI freezes when many utterances queue up during a slow
    or loaded Ollama. The judge adds value only when there's an
    engagement signal: wake word, hot window, or active TTS.
    """

    @patch("builtins.print")
    def test_judge_not_called_for_ambient_speech(self, _print):
        """Ambient speech with no wake word / hot window / TTS must not hit the judge."""
        listener, _ = _create_listener()

        mock_judge = _install_intent_judge(
            listener, _make_judgment(directed=False, query=""))

        # No hot window, no TTS, no wake word in the text
        listener._process_transcript(
            "random background chatter about the weather",
            utterance_energy=0.01,
        )

        assert mock_judge.judge.call_count == 0, (
            "Intent judge must be gated on an engagement signal; ambient "
            "speech should skip the judge to avoid blocking the audio loop")
        listener.state_manager.stop()

    @patch("builtins.print")
    def test_judge_called_when_wake_word_detected(self, _print):
        """Utterances containing the wake word do reach the judge."""
        listener, _ = _create_listener()

        mock_judge = _install_intent_judge(
            listener, _make_judgment(
                directed=True, query="what time is it"))

        listener._process_transcript(
            "jarvis what time is it", utterance_energy=0.01,
        )

        assert mock_judge.judge.call_count == 1
        listener.state_manager.stop()

    @patch("builtins.print")
    def test_judge_called_in_hot_window(self, _print):
        """Utterances during the hot window do reach the judge."""
        listener, _ = _create_listener(echo_tolerance=0.02, hot_window_seconds=3.0)

        listener.echo_detector.track_tts_start("Here you go.")
        _simulate_tts_finish(listener)
        _wait_for_hot_window_active(listener)

        mock_judge = _install_intent_judge(
            listener, _make_judgment(directed=True, query="thanks"))

        listener._process_transcript("thanks", utterance_energy=0.01)

        assert mock_judge.judge.call_count == 1
        listener.state_manager.stop()

    @patch("builtins.print")
    def test_judge_skipped_for_short_utterance_during_tts(self, _print):
        """Short utterances (<=3 words) during active TTS bypass the judge.

        The fast text-based stop-command check already handles short
        interruptions like "stop" / "shut up" while TTS is speaking. Sending
        these to the judge would block the audio loop for the judge's
        timeout on every short echo chunk during playback.
        """
        listener, mock_tts = _create_listener(tts_speaking=True)

        mock_judge = _install_intent_judge(
            listener, _make_judgment(directed=False, query=""))

        listener._process_transcript(
            "uh huh yeah", utterance_energy=0.01,
        )

        assert mock_judge.judge.call_count == 0, (
            "Short utterances during TTS must be handled by the stop-command "
            "path, not the judge, to avoid blocking the audio loop")
        listener.state_manager.stop()

    @patch("builtins.print")
    def test_judge_called_for_longer_utterance_during_tts(self, _print):
        """Longer utterances (>3 words) during TTS still reach the judge.

        Active TTS is itself an engagement signal — the user may be
        interrupting with a real follow-up or correction, and the judge
        needs to see it to catch intents the fast text-based stop-command
        check misses.
        """
        listener, mock_tts = _create_listener(tts_speaking=True)

        mock_judge = _install_intent_judge(
            listener, _make_judgment(
                directed=True, query="what about tomorrow's weather"))

        # >3 words, no stop-command keywords, not echo
        listener._process_transcript(
            "actually what about tomorrow's weather",
            utterance_energy=0.01,
        )

        assert mock_judge.judge.call_count == 1
        listener.state_manager.stop()
