"""Smoke test: the mock pipeline must run end-to-end and route screen context
into the brain's replies."""

import asyncio

import pytest

from wsai.backends.mock import (
    MockBrain,
    MockFrameSource,
    MockSTT,
    MockTTS,
    MockVision,
)
from wsai.interfaces import Frame
from wsai.pipeline import Pipeline


def test_mock_pipeline_runs_and_replies(capsys):
    replies: list[str] = []

    class CapturingTTS(MockTTS):
        async def speak(self, reply):
            replies.append(reply.text)

    pipe = Pipeline(
        source=MockFrameSource(interval=0.05, limit=3),
        vision=MockVision(),
        brain=MockBrain(),
        stt=MockSTT(script=["화면에 뭐 보여?"], interval=0.1),
        tts=CapturingTTS(),
    )

    asyncio.run(asyncio.wait_for(pipe.run(), timeout=5))

    assert replies, "brain produced no reply"
    # The reply must embed the screen observation → context reached the brain.
    assert "화면:" in replies[0]


def test_voice_only_pipeline_runs_without_eyes():
    """Eyes-free config (no source/vision) still runs STT -> Brain -> TTS."""
    replies: list[str] = []

    class CapturingTTS(MockTTS):
        async def speak(self, reply):
            replies.append(reply.text)

    pipe = Pipeline(
        brain=MockBrain(),
        stt=MockSTT(script=["안녕", "잘 있어"], interval=0.05),
        tts=CapturingTTS(),
    )

    asyncio.run(asyncio.wait_for(pipe.run(), timeout=5))

    assert len(replies) == 2, "voice loop did not reply to every utterance"
    # No eyes → the brain must report it has not seen a screen.
    assert "아직 화면을 못 읽었어요" in replies[0]


def test_error_in_one_loop_cancels_siblings_and_closes():
    """If the conversation loop raises, the perception loop must be cancelled
    (not left running detached) and every source must still be closed — i.e. no
    close-during-use and no orphaned task."""
    closed = {"source": False, "stt": False}

    class ForeverSource:
        async def frames(self):
            while True:
                await asyncio.sleep(0.01)
                yield Frame(data=b"", width=1, height=1, ts=0.0)

        async def aclose(self):
            closed["source"] = True

    class BoomSTT(MockSTT):
        async def aclose(self):
            closed["stt"] = True

    class BoomBrain(MockBrain):
        async def respond(self, user_text, screen, history):
            raise RuntimeError("boom")

    pipe = Pipeline(
        source=ForeverSource(),
        vision=MockVision(),
        brain=BoomBrain(),
        stt=BoomSTT(script=["hi"], interval=0.01),
        tts=MockTTS(),
    )

    with pytest.raises(BaseException):  # TaskGroup raises an ExceptionGroup
        asyncio.run(asyncio.wait_for(pipe.run(), timeout=5))

    assert closed["source"] is True, "perception source was not closed (orphaned loop)"
    assert closed["stt"] is True, "stt was not closed"


def test_history_is_bounded():
    pipe = Pipeline(
        source=MockFrameSource(limit=0),
        vision=MockVision(),
        brain=MockBrain(),
        history_turns=3,
    )
    for i in range(10):
        pipe._remember(f"u{i}", f"a{i}")
    assert len(pipe._history) == 3
    assert pipe._history[-1] == ("u9", "a9")


def test_prewarm_warms_backends_and_survives_failure():
    """Backends exposing warmup() are preloaded at startup; a warmup that
    raises is logged but must not abort the run (first-utterance latency is an
    optimization, not a hard requirement)."""
    warmed: list[str] = []

    class WarmTTS(MockTTS):
        async def warmup(self):
            warmed.append("tts")

    class BoomWarmSTT(MockSTT):
        async def warmup(self):
            warmed.append("stt")
            raise RuntimeError("model unavailable")

    pipe = Pipeline(
        brain=MockBrain(),
        stt=BoomWarmSTT(script=["안녕"], interval=0.01),
        tts=WarmTTS(),
    )

    asyncio.run(asyncio.wait_for(pipe.run(), timeout=5))

    assert "tts" in warmed and "stt" in warmed, "warmup() not called on backends"
