"""Tests for the ODS Talk mobile portal API."""

import pytest


@pytest.fixture()
def signed_talk_cookie(monkeypatch):
    import session_signer

    monkeypatch.setenv("ODS_SESSION_SECRET", "test-secret-for-talk")
    session_signer._set_secret_for_tests("test-secret-for-talk")
    return session_signer.issue(ttl_seconds=3600)


@pytest.fixture()
def talk_client(test_client, signed_talk_cookie):
    test_client.cookies.set("ods-session", signed_talk_cookie)
    return test_client


def test_talk_rejects_api_key_without_session(test_client):
    resp = test_client.post(
        "/api/talk/message",
        json={"text": "hello"},
        headers=test_client.auth_headers,
    )
    assert resp.status_code == 401


def test_talk_status_requires_session(talk_client, monkeypatch):
    async def fake_state(service_id):
        return {"configured": True, "status": "healthy", "id": service_id}

    monkeypatch.setattr("routers.talk._service_state", fake_state)
    resp = talk_client.get("/api/talk/status")
    assert resp.status_code == 200, resp.text
    data = resp.json()
    assert data["capabilities"]["text_chat"] is True
    assert data["capabilities"]["tts"] is True
    assert data["capabilities"]["audio_message"] is True
    assert data["capabilities"]["live_mic_requires_secure_context"] is True


def test_talk_message_routes_through_hermes_bridge(talk_client, monkeypatch):
    from hermes_bridge import HermesReply

    calls = []

    async def fake_submit(session_key, text):
        calls.append((session_key, text))
        return HermesReply(session_id="sid-1", text="hello back")

    monkeypatch.setattr("hermes_bridge.submit_prompt", fake_submit)

    resp = talk_client.post("/api/talk/message", json={"text": "hello"})
    assert resp.status_code == 200, resp.text
    assert resp.json()["text"] == "hello back"
    assert calls and calls[0][1] == "hello"


def test_talk_audio_message_transcribes_and_routes(talk_client, monkeypatch):
    async def fake_transcribe(data, filename, content_type):
        assert data == b"fake audio"
        assert filename == "voice.webm"
        assert content_type == "audio/webm"
        return "what is running locally"

    async def fake_send(session_key, text):
        return {
            "session_id": "sid-2",
            "text": f"answer to {text}",
            "status": "ok",
            "warning": None,
        }

    monkeypatch.setattr("routers.talk._transcribe_bytes", fake_transcribe)
    monkeypatch.setattr("routers.talk._send_to_hermes", fake_send)

    resp = talk_client.post(
        "/api/talk/audio-message",
        files={"file": ("voice.webm", b"fake audio", "audio/webm")},
    )
    assert resp.status_code == 200, resp.text
    data = resp.json()
    assert data["transcript"] == "what is running locally"
    assert data["text"] == "answer to what is running locally"


def test_talk_speak_streams_audio(talk_client, monkeypatch):
    """The /api/talk/speak endpoint streams MP3 bytes from Kokoro as they
    arrive (instead of buffering the whole reply into a single Response).
    Verify the streamed bytes are concatenated correctly + the right
    headers are set so reverse proxies don't re-buffer the stream."""
    async def fake_stream(text):
        assert text == "read this"
        # Simulate Kokoro emitting two chunks as the audio synthesises.
        yield b"mp3 chunk 1 "
        yield b"mp3 chunk 2"

    monkeypatch.setattr("routers.talk._stream_speech", fake_stream)

    resp = talk_client.post("/api/talk/speak", data={"text": "read this"})
    assert resp.status_code == 200, resp.text
    assert resp.content == b"mp3 chunk 1 mp3 chunk 2"
    assert resp.headers["content-type"].startswith("audio/mpeg")
    # Critical for streaming through nginx / ods-proxy: without this
    # the reverse proxy would re-buffer the stream and undo our work.
    assert resp.headers.get("X-Accel-Buffering") == "no"


def test_talk_speak_handles_empty_stream(talk_client, monkeypatch):
    """If Kokoro errors before any audio is produced, the streaming
    response should close cleanly with empty body — the SPA's `if (!resp.ok
    || !resp.body)` short-circuit then suppresses playback without
    interrupting the text chat."""
    async def fake_stream(text):
        # Kokoro upstream failure: nothing to yield.
        if False:
            yield b""  # pragma: no cover

    monkeypatch.setattr("routers.talk._stream_speech", fake_stream)

    resp = talk_client.post("/api/talk/speak", data={"text": "silent"})
    assert resp.status_code == 200
    assert resp.content == b""


# ----------------------------------------------------------------------
# SSE streaming endpoint tests (/api/talk/message/stream)
# ----------------------------------------------------------------------


def _parse_sse_frames(body: bytes):
    """Split an SSE response body into one dict per frame."""
    import json as _json
    frames = []
    for chunk in body.decode("utf-8").split("\n\n"):
        chunk = chunk.strip()
        if not chunk:
            continue
        data_lines = [line[5:].lstrip() for line in chunk.splitlines() if line.startswith("data:")]
        if not data_lines:
            continue
        try:
            frames.append(_json.loads("\n".join(data_lines)))
        except _json.JSONDecodeError:
            pass
    return frames


def test_talk_message_stream_emits_session_then_deltas_then_complete(talk_client, monkeypatch):
    async def fake_stream(session_key, text):
        assert text == "hello"
        yield {"type": "session", "session_id": "sid-stream-1"}
        yield {"type": "delta", "text": "Hello"}
        yield {"type": "delta", "text": " world"}
        yield {"type": "complete", "session_id": "sid-stream-1", "text": "Hello world",
               "status": "ok", "warning": None}

    monkeypatch.setattr("hermes_bridge.stream_prompt", fake_stream)

    resp = talk_client.post("/api/talk/message/stream", json={"text": "hello"})
    assert resp.status_code == 200, resp.text
    assert resp.headers["content-type"].startswith("text/event-stream")
    frames = _parse_sse_frames(resp.content)
    types = [f["type"] for f in frames]
    # Required ordering: session → deltas → complete → done. Bridge errors
    # replace `complete` with `error`, but `done` is always last.
    assert types[0] == "session"
    assert frames[0]["session_id"] == "sid-stream-1"
    delta_texts = [f["text"] for f in frames if f["type"] == "delta"]
    assert delta_texts == ["Hello", " world"]
    assert any(f["type"] == "complete" and f["text"] == "Hello world" for f in frames)
    assert types[-1] == "done"


def test_talk_message_stream_emits_error_frame_and_done_on_bridge_failure(talk_client, monkeypatch):
    import hermes_bridge as bridge

    async def fake_stream(session_key, text):
        # Yield nothing — go straight to raising. The endpoint should still
        # emit an `error` SSE frame followed by `done` so the client knows the
        # stream closed cleanly.
        if False:
            yield  # pragma: no cover — needed to make this an async generator
        raise bridge.HermesBridgeError("upstream tripped")

    monkeypatch.setattr("hermes_bridge.stream_prompt", fake_stream)

    resp = talk_client.post("/api/talk/message/stream", json={"text": "hi"})
    assert resp.status_code == 200, resp.text
    frames = _parse_sse_frames(resp.content)
    types = [f["type"] for f in frames]
    assert "error" in types
    error_frame = next(f for f in frames if f["type"] == "error")
    assert error_frame["status_code"] == 502
    assert "upstream tripped" in error_frame["detail"]
    assert types[-1] == "done"


def test_talk_message_stream_emits_503_when_hermes_unavailable(talk_client, monkeypatch):
    import hermes_bridge as bridge

    async def fake_stream(session_key, text):
        if False:
            yield  # pragma: no cover
        raise bridge.HermesUnavailable("hermes is offline")

    monkeypatch.setattr("hermes_bridge.stream_prompt", fake_stream)

    resp = talk_client.post("/api/talk/message/stream", json={"text": "hi"})
    assert resp.status_code == 200
    frames = _parse_sse_frames(resp.content)
    error_frame = next(f for f in frames if f["type"] == "error")
    assert error_frame["status_code"] == 503


def test_talk_message_stream_requires_session(test_client):
    resp = test_client.post(
        "/api/talk/message/stream",
        json={"text": "hi"},
        headers=test_client.auth_headers,
    )
    assert resp.status_code == 401


def test_talk_message_stream_validates_input(talk_client):
    resp = talk_client.post("/api/talk/message/stream", json={"text": ""})
    assert resp.status_code == 422

    resp = talk_client.post("/api/talk/message/stream", json={"text": "x" * 8001})
    assert resp.status_code == 413


def test_talk_message_stream_emits_keepalive_during_silent_bridge(talk_client, monkeypatch):
    """If the bridge goes silent for longer than the keepalive interval (e.g.
    while llama-server is doing 30-60s of prompt processing with no events),
    the endpoint must emit ``: keepalive`` SSE comment frames. Without them
    iOS Safari and intermediate proxies close idle streams and the SPA
    stalls on a "thinking" spinner that will never resolve."""
    import asyncio as _asyncio
    monkeypatch.setattr("routers.talk._KEEPALIVE_INTERVAL", 0.05)

    async def slow_stream(session_key, text):
        yield {"type": "session", "session_id": "sid-kp"}
        # Simulate a real prompt-processing gap. With keepalive at 50ms,
        # this 200ms gap must produce >= 2 keepalive comments.
        await _asyncio.sleep(0.2)
        yield {"type": "delta", "text": "ok"}
        yield {"type": "complete", "session_id": "sid-kp", "text": "ok", "status": "ok", "warning": None}

    monkeypatch.setattr("hermes_bridge.stream_prompt", slow_stream)

    resp = talk_client.post("/api/talk/message/stream", json={"text": "hi"})
    assert resp.status_code == 200
    raw = resp.content.decode("utf-8")
    assert ": keepalive" in raw, "expected at least one keepalive comment frame in body"
    assert raw.count(": keepalive") >= 2, f"expected >=2 keepalive frames, got {raw.count(': keepalive')}"
    # And the real frames still come through.
    frames = _parse_sse_frames(resp.content)
    types = [f["type"] for f in frames]
    assert "complete" in types and types[-1] == "done"


def test_talk_message_stream_cancels_upstream_on_client_disconnect(talk_client, monkeypatch):
    """If the client drops the connection mid-stream, the endpoint must stop
    pulling from the bridge so a slow upstream (llama-server slot) is freed
    instead of held for a response nobody will read.

    This is a unit-level test of the generator itself — we drive it directly
    so we can assert the bridge iterator gets ``aclose()``-style cancellation
    when ``request.is_disconnected()`` returns True.
    """
    import asyncio as _asyncio
    monkeypatch.setattr("routers.talk._KEEPALIVE_INTERVAL", 0.02)

    bridge_started = _asyncio.Event()
    bridge_cancelled = _asyncio.Event()

    async def hanging_stream(session_key, text):
        yield {"type": "session", "session_id": "sid-cancel"}
        bridge_started.set()
        try:
            # Hang until the consumer cancels us.
            await _asyncio.sleep(60)
            yield {"type": "complete", "session_id": "sid-cancel", "text": "never", "status": "ok", "warning": None}
        except _asyncio.CancelledError:
            bridge_cancelled.set()
            raise

    monkeypatch.setattr("hermes_bridge.stream_prompt", hanging_stream)

    # Build a stub Request that reports disconnected after the first poll.
    class StubRequest:
        def __init__(self):
            self.polls = 0

        async def is_disconnected(self):
            self.polls += 1
            # Stay connected once so the session frame can flush, then drop.
            return self.polls > 1

    from routers.talk import _stream_hermes_sse

    async def drive():
        gen = _stream_hermes_sse("k", "hi", StubRequest())
        collected = []
        # Iterate the SSE generator; the consumer side is what FastAPI does.
        async for chunk in gen:
            collected.append(chunk)
            if len(collected) > 10:
                break
        return collected

    chunks = _asyncio.new_event_loop().run_until_complete(drive())
    body = b"".join(chunks).decode("utf-8")
    # The session frame should have made it out.
    assert '"type":"session"' in body
    # The generator must have exited via the disconnect path without trying to
    # write a final frame to a dead response. Normal/error completions still
    # emit `done`.
    assert '"type":"done"' not in body
    # And the upstream bridge task must have been cancelled (no hang).
    assert bridge_started.is_set()
    assert bridge_cancelled.is_set()


def _run_with_one_loop(coro_factory):
    """Tests that exercise the bridge run several coroutines on the SAME
    event loop so the background sweeper task doesn't get orphaned across
    loop swaps (which would surface as "Task was destroyed but it is
    pending" warnings). Caller passes a *factory* — a 0-arg callable that
    returns a coroutine — because coroutines themselves can't be re-awaited.
    """
    import asyncio as _asyncio
    import hermes_bridge
    loop = _asyncio.new_event_loop()
    try:
        result = loop.run_until_complete(coro_factory())
        loop.run_until_complete(hermes_bridge.shutdown_pool())
        return result
    finally:
        loop.close()


def test_hermes_bridge_pool_serializes_same_key_parallels_different_keys(monkeypatch):
    """Connection-pool contract:

      * Two concurrent stream_prompt calls with the same session_key must run
        sequentially (the per-connection lock pins the WS to one prompt at a
        time — Hermes can't multiplex two prompt.submit on one session).
      * Two calls with DIFFERENT session_keys are NOT blocked by each other;
        they get separate pooled connections and run in parallel.

    Stubs the WS + recv layer so we don't need a real Hermes. The pool's
    behaviour is what's under test.
    """
    import asyncio as _asyncio
    import hermes_bridge

    # Clear any pool state from prior tests. Synchronous reset since we
    # haven't started a sweeper yet on this loop.
    hermes_bridge._CONNECTION_POOL.clear()
    hermes_bridge._SWEEPER_TASK = None

    enter_log: list[str] = []
    exit_log: list[str] = []
    create_counts: dict[str, int] = {}

    class FakeWS:
        closed = False
        async def send_str(self, _): pass
        async def close(self): self.closed = True

    class FakeHTTP:
        async def close(self): pass

    async def fake_open(session_key):
        create_counts[session_key] = create_counts.get(session_key, 0) + 1
        conn = hermes_bridge._HermesConnection(
            http_session=FakeHTTP(),
            ws=FakeWS(),
            session_id=f"sid-{session_key}",
        )
        return conn

    monkeypatch.setattr("hermes_bridge._open_connection", fake_open)

    async def fake_recv(_ws, _timeout):
        # Small delay so the test observes ordering, then deliver a
        # message.complete that ends the stream.
        await _asyncio.sleep(0.1)
        return {
            "method": "event",
            "params": {
                "type": "message.complete",
                "session_id": None,  # bridge accepts either matching or null
                "payload": {"text": "done", "status": "ok"},
            },
        }
    monkeypatch.setattr("hermes_bridge._recv_json", fake_recv)

    async def drive(key, tag):
        enter_log.append(tag)
        async for ev in hermes_bridge.stream_prompt(key, "x"):
            if ev["type"] == "complete":
                break
        exit_log.append(tag)

    async def main():
        # Same-key serialization + pool reuse.
        await _asyncio.gather(drive("k1", "A"), drive("k1", "B"))
        same_enter = list(enter_log)
        same_exit = list(exit_log)
        same_creates = dict(create_counts)

        # Reset for the different-key pass; keep same loop alive.
        enter_log.clear()
        exit_log.clear()
        create_counts.clear()
        await hermes_bridge.shutdown_pool()
        hermes_bridge._CONNECTION_POOL.clear()
        hermes_bridge._SWEEPER_TASK = None

        await _asyncio.gather(drive("phoneA", "A"), drive("phoneB", "B"))
        diff_exit = set(exit_log)
        diff_creates = dict(create_counts)
        return same_enter, same_exit, same_creates, diff_exit, diff_creates

    same_enter, same_exit, same_creates, diff_exit, diff_creates = _run_with_one_loop(main)
    assert same_enter == ["A", "B"]
    assert same_exit == ["A", "B"], f"same-key prompts must serialize, got {same_exit}"
    assert same_creates["k1"] == 1, f"expected pool reuse for k1, got {same_creates['k1']} creates"
    assert diff_exit == {"A", "B"}
    assert diff_creates == {"phoneA": 1, "phoneB": 1}


def test_hermes_bridge_transparent_retry_on_send_reset(monkeypatch):
    """Most insidious dead-WS pattern: ``ws.closed`` reports False, but the
    next ``send_str`` raises ClientConnectionResetError because the upstream
    Hermes closed the socket between checks (e.g. a Hermes restart that
    raced with the freshness check). The bridge must catch that, evict, and
    transparently retry once on a fresh connection — the user shouldn't see
    "load failed" just because Hermes restarted in the background."""
    import aiohttp
    import hermes_bridge

    hermes_bridge._CONNECTION_POOL.clear()
    hermes_bridge._SWEEPER_TASK = None

    # aiohttp.ClientConnectionResetError only exists in aiohttp >= 3.10;
    # requirements.txt allows >= 3.9, where send_str on a closing transport
    # raises the builtin ConnectionResetError instead. The bridge catches
    # both, so the test must raise whichever this aiohttp actually has.
    reset_error = getattr(aiohttp, "ClientConnectionResetError", ConnectionResetError)

    class DeadOnSendWS:
        closed = False
        async def send_str(self, _):
            raise reset_error("Cannot write to closing transport")
        async def close(self):
            self.closed = True

    class LiveWS:
        closed = False
        async def send_str(self, _): pass
        async def close(self): self.closed = True

    class FakeHTTP:
        async def close(self): pass

    open_calls = 0

    async def fake_open(session_key):
        nonlocal open_calls
        open_calls += 1
        # First connection has a WS that fails on send; second is healthy.
        ws = DeadOnSendWS() if open_calls == 1 else LiveWS()
        return hermes_bridge._HermesConnection(
            http_session=FakeHTTP(), ws=ws, session_id=f"sid-{open_calls}",
        )

    monkeypatch.setattr("hermes_bridge._open_connection", fake_open)

    async def fake_recv(_ws, _timeout):
        return {"method": "event", "params": {"type": "message.complete", "payload": {"text": "ok"}}}
    monkeypatch.setattr("hermes_bridge._recv_json", fake_recv)

    async def main():
        events = []
        async for ev in hermes_bridge.stream_prompt("retry-key", "hi"):
            events.append(ev["type"])
            if ev["type"] == "complete":
                break
        return events, open_calls

    events, opens = _run_with_one_loop(main)
    # User got a clean stream: session frame + complete frame, no error.
    assert "complete" in events, f"expected transparent retry to deliver complete, got {events}"
    # Two connection opens: the dead one + the retry's fresh one.
    assert opens == 2, f"expected 1 retry (opens == 2), got {opens}"


def test_hermes_bridge_does_not_retry_after_prompt_submit(monkeypatch):
    """A receive-side WS failure happens after prompt.submit was sent, so the
    bridge must not retry the same prompt. Retrying there can duplicate tool
    calls or append a second answer after partial streamed text. Only the
    pre-submit send failure is safe to retry transparently."""
    import hermes_bridge

    hermes_bridge._CONNECTION_POOL.clear()
    hermes_bridge._SWEEPER_TASK = None

    class FakeWS:
        closed = False
        async def send_str(self, _): pass
        async def close(self): self.closed = True

    class FakeHTTP:
        async def close(self): pass

    open_calls = 0

    async def fake_open(session_key):
        nonlocal open_calls
        open_calls += 1
        return hermes_bridge._HermesConnection(
            http_session=FakeHTTP(), ws=FakeWS(), session_id=f"sid-{open_calls}",
        )

    monkeypatch.setattr("hermes_bridge._open_connection", fake_open)

    async def fake_recv(_ws, _timeout):
        raise hermes_bridge.HermesUnavailable("closed after submit")

    monkeypatch.setattr("hermes_bridge._recv_json", fake_recv)

    async def main():
        with pytest.raises(hermes_bridge.HermesUnavailable):
            async for _event in hermes_bridge.stream_prompt("no-retry-key", "hi"):
                pass
        return open_calls, "no-retry-key" in hermes_bridge._CONNECTION_POOL

    opens, still_pooled = _run_with_one_loop(main)
    assert opens == 1, f"receive-side failure must not retry submitted prompt, got {opens} opens"
    assert not still_pooled, "failed connection should be evicted before surfacing the error"


def test_hermes_bridge_pool_evicts_and_recreates_on_dead_ws(monkeypatch):
    """If the cached WS has closed (e.g. Hermes container restarted between
    messages), the pool must evict it and open a fresh connection on the
    next ``stream_prompt`` call rather than silently failing forever."""
    import hermes_bridge

    hermes_bridge._CONNECTION_POOL.clear()
    hermes_bridge._SWEEPER_TASK = None

    class FakeWS:
        def __init__(self): self.closed = False
        async def send_str(self, _): pass
        async def close(self): self.closed = True

    class FakeHTTP:
        async def close(self): pass

    create_count = 0

    async def fake_open(session_key):
        nonlocal create_count
        create_count += 1
        return hermes_bridge._HermesConnection(
            http_session=FakeHTTP(),
            ws=FakeWS(),
            session_id=f"sid-{create_count}",
        )

    monkeypatch.setattr("hermes_bridge._open_connection", fake_open)

    async def fake_recv(_ws, _timeout):
        return {"method": "event", "params": {"type": "message.complete", "payload": {"text": "ok"}}}
    monkeypatch.setattr("hermes_bridge._recv_json", fake_recv)

    async def main():
        # First call creates connection #1.
        async for ev in hermes_bridge.stream_prompt("p1", "hi"):
            if ev["type"] == "complete":
                break
        cached = hermes_bridge._CONNECTION_POOL["p1"]

        # Simulate Hermes restart: mark the WS as closed.
        cached.ws.closed = True

        # Next call must NOT reuse the dead connection; must open a fresh one.
        async for ev in hermes_bridge.stream_prompt("p1", "hi again"):
            if ev["type"] == "complete":
                break

        return create_count, hermes_bridge._CONNECTION_POOL["p1"].session_id

    creates, sid = _run_with_one_loop(main)
    assert creates == 2, f"expected 2 connection opens (initial + after dead WS), got {creates}"
    assert sid == "sid-2", f"pool should hold the new connection, has {sid}"


def test_hermes_bridge_pool_sweeper_skips_active_connections(monkeypatch):
    """A prompt can run longer than the idle timeout on a slow first turn.
    The sweeper must not close a connection while its per-connection lock is
    held, even if last_used is old."""
    import asyncio as _asyncio
    import hermes_bridge

    hermes_bridge._CONNECTION_POOL.clear()
    hermes_bridge._SWEEPER_TASK = None

    monkeypatch.setattr("hermes_bridge._IDLE_EXPIRY_SECONDS", 0.05)
    monkeypatch.setattr("hermes_bridge._IDLE_SWEEP_INTERVAL", 0.02)

    closed_calls: list[str] = []

    class FakeWS:
        def __init__(self): self.closed = False
        async def close(self): self.closed = True

    class FakeHTTP:
        async def close(self): pass

    async def fake_open(session_key):
        conn = hermes_bridge._HermesConnection(http_session=FakeHTTP(), ws=FakeWS(), session_id="sid")
        original_close = conn.aclose
        async def _tracked_close():
            closed_calls.append(session_key)
            await original_close()
        conn.aclose = _tracked_close
        return conn

    monkeypatch.setattr("hermes_bridge._open_connection", fake_open)

    async def main():
        conn = await hermes_bridge._get_connection("active-key")
        await conn.lock.acquire()
        try:
            conn.last_used -= 100
            hermes_bridge._ensure_sweeper_running()
            await _asyncio.sleep(0.15)
            return "active-key" in hermes_bridge._CONNECTION_POOL, list(closed_calls)
        finally:
            conn.lock.release()

    still_in_pool, closed = _run_with_one_loop(main)
    assert still_in_pool, "active connection should not be swept while its lock is held"
    assert closed == [], f"sweeper should not close active connection, got {closed}"


def test_hermes_bridge_pool_sweeper_evicts_idle_connections(monkeypatch):
    """Connections idle longer than _IDLE_EXPIRY_SECONDS must be closed
    by the background sweeper so a fleet of one-time visitors doesn't
    pin Hermes resources forever."""
    import asyncio as _asyncio
    import hermes_bridge

    hermes_bridge._CONNECTION_POOL.clear()
    hermes_bridge._SWEEPER_TASK = None

    # Tight timings so the test is fast.
    monkeypatch.setattr("hermes_bridge._IDLE_EXPIRY_SECONDS", 0.05)
    monkeypatch.setattr("hermes_bridge._IDLE_SWEEP_INTERVAL", 0.02)

    closed_calls: list[str] = []

    class FakeWS:
        def __init__(self): self.closed = False
        async def close(self): self.closed = True

    class FakeHTTP:
        async def close(self): pass

    async def fake_open(session_key):
        ws = FakeWS()
        conn = hermes_bridge._HermesConnection(http_session=FakeHTTP(), ws=ws, session_id="sid")
        original_close = conn.aclose
        async def _tracked_close():
            closed_calls.append(session_key)
            await original_close()
        conn.aclose = _tracked_close
        return conn

    monkeypatch.setattr("hermes_bridge._open_connection", fake_open)

    async def main():
        # Plant a connection manually (skip stream_prompt, simpler).
        conn = await hermes_bridge._get_connection("idle-key")
        assert "idle-key" in hermes_bridge._CONNECTION_POOL
        # Backdate last_used so it's clearly past expiry.
        conn.last_used -= 100
        hermes_bridge._ensure_sweeper_running()
        # Wait long enough for the sweep loop to fire at least once.
        await _asyncio.sleep(0.15)
        return "idle-key" in hermes_bridge._CONNECTION_POOL, list(closed_calls)

    still_in_pool, closed = _run_with_one_loop(main)
    assert not still_in_pool, "sweeper should have evicted the idle connection"
    assert "idle-key" in closed, f"sweeper should have called aclose(), got {closed}"


def test_talk_message_stream_forwards_hermes_status_updates(talk_client, monkeypatch):
    """Hermes (pinned image) emits status.update events with pre-formatted
    human-readable text and a `kind` category, e.g.
    ``{"kind": "lifecycle", "text": "⏳ Retrying in 5.1s (attempt 2/3)..."}``.
    The bridge forwards them as ``status`` events and the SSE layer relays
    them verbatim — upstream owns the wording, we just route it to the SPA.
    Regression guard for the user-visible "spinner caption is dynamic" UX."""
    async def fake_stream(session_key, text):
        yield {"type": "session", "session_id": "sid"}
        yield {"type": "status", "label": "⏳ Retrying in 5.1s (attempt 2/3)...", "kind": "lifecycle"}
        yield {"type": "status", "label": "🔎 Searching the web…", "kind": "tool"}
        yield {"type": "delta", "text": "OK"}
        yield {"type": "complete", "session_id": "sid", "text": "OK", "status": "ok", "warning": None}

    monkeypatch.setattr("hermes_bridge.stream_prompt", fake_stream)

    resp = talk_client.post("/api/talk/message/stream", json={"text": "hi"})
    assert resp.status_code == 200
    frames = _parse_sse_frames(resp.content)
    status_frames = [f for f in frames if f["type"] == "status"]
    # Both status updates make it through, verbatim text + kind preserved.
    assert len(status_frames) == 2
    assert status_frames[0]["label"] == "⏳ Retrying in 5.1s (attempt 2/3)..."
    assert status_frames[0]["kind"] == "lifecycle"
    assert status_frames[1]["label"] == "🔎 Searching the web…"
    assert status_frames[1]["kind"] == "tool"


def test_talk_message_stream_translates_tool_events_to_status_frames(talk_client, monkeypatch):
    """Hermes emits tool.start / tool.complete events while the agent is
    working. The bridge surfaces those as ``tool_start`` / ``tool_complete``
    events, and the SSE layer translates them into friendly ``status``
    frames the SPA renders as the spinner caption. This is the user-facing
    "what's the agent doing?" visibility added per the operator's feedback —
    a phone shouldn't sit on a plain "Thinking" spinner for 30s while
    web_search is mid-flight."""
    async def fake_stream(session_key, text):
        yield {"type": "session", "session_id": "sid"}
        yield {"type": "tool_start", "tool": "web_search", "detail": "weather in San Francisco"}
        yield {"type": "tool_complete", "tool": "web_search", "duration_s": 1.2, "summary": "Did 1 search"}
        yield {"type": "delta", "text": "Sunny, 64°F."}
        yield {"type": "complete", "session_id": "sid", "text": "Sunny, 64°F.", "status": "ok", "warning": None}

    monkeypatch.setattr("hermes_bridge.stream_prompt", fake_stream)

    resp = talk_client.post("/api/talk/message/stream", json={"text": "what is the weather?"})
    assert resp.status_code == 200
    frames = _parse_sse_frames(resp.content)
    types = [f["type"] for f in frames]
    # Required ordering: session → status(active) → status(cleared) → delta → complete → done
    assert types[0] == "session"
    status_frames = [f for f in frames if f["type"] == "status"]
    assert len(status_frames) == 2, f"expected one status-active + one status-clear, got {status_frames}"
    # First status: friendly label + tool name + detail string preserved.
    assert status_frames[0]["label"] == "Searching the web…"
    assert status_frames[0]["tool"] == "web_search"
    assert status_frames[0]["detail"] == "weather in San Francisco"
    # Second status: label=None signals SPA to drop the caption (default
    # "Thinking…" until next status or delta).
    assert status_frames[1]["label"] is None
    assert status_frames[1]["tool"] is None
    # And the actual content + complete frame still come through unchanged.
    assert any(f["type"] == "delta" and f["text"] == "Sunny, 64°F." for f in frames)
    assert any(f["type"] == "complete" for f in frames)


def test_talk_message_stream_status_label_for_unknown_tool(talk_client, monkeypatch):
    """Unknown tools should still produce a status frame — falling back to
    a literal "Using `<name>`…" caption so the operator can map the tool in
    a future _TOOL_LABELS pass instead of staring at a blank spinner."""
    async def fake_stream(session_key, text):
        yield {"type": "session", "session_id": "sid"}
        yield {"type": "tool_start", "tool": "some_new_skill_2026", "detail": None}
        yield {"type": "complete", "session_id": "sid", "text": "ok", "status": "ok", "warning": None}

    monkeypatch.setattr("hermes_bridge.stream_prompt", fake_stream)

    resp = talk_client.post("/api/talk/message/stream", json={"text": "hi"})
    assert resp.status_code == 200
    frames = _parse_sse_frames(resp.content)
    status_frames = [f for f in frames if f["type"] == "status"]
    assert len(status_frames) == 1
    # Honest fallback — literal name appears in the caption so an operator
    # who sees "Using `foo`…" on the phone knows exactly what to map.
    assert "some_new_skill_2026" in status_frames[0]["label"]


def test_talk_message_stream_status_label_handles_browser_prefix(monkeypatch):
    """Tools we don't enumerate explicitly but share a known prefix get a
    sensible group caption (browser_* → "Browsing…")."""
    from routers.talk import _label_for_tool
    assert _label_for_tool("browser_navigate") == "Browsing…"
    assert _label_for_tool("browser_console") == "Browsing…"
    assert _label_for_tool("memory_save") == "Checking memory…"
    assert _label_for_tool("skill_view") == "Loading a skill…"
    # Honest fallback for genuinely unknown:
    assert _label_for_tool("random_unknown_tool") == "Using `random_unknown_tool`…"


def test_talk_attachment_text_file_routes_through_hermes_with_inlined_content(talk_client, monkeypatch):
    """Text-like attachments (.txt/.md/.json/code/etc.) get their content
    decoded and prepended to the user's caption, then routed through the
    existing Hermes bridge — so the agent retains tools, memory, and the
    same SSE event shape as a regular text message."""
    captured_prompt = None

    async def fake_stream(session_key, text):
        nonlocal captured_prompt
        captured_prompt = text
        yield {"type": "session", "session_id": "sid"}
        yield {"type": "complete", "session_id": "sid", "text": "Reviewed.", "status": "ok", "warning": None}

    monkeypatch.setattr("hermes_bridge.stream_prompt", fake_stream)

    resp = talk_client.post(
        "/api/talk/attachment",
        files={"file": ("notes.md", b"# Hello\nFoo bar", "text/markdown")},
        data={"text": "what's in this?"},
    )
    assert resp.status_code == 200
    assert "Hello" in captured_prompt
    assert "Foo bar" in captured_prompt
    assert "what's in this?" in captured_prompt
    assert "notes.md" in captured_prompt


def test_talk_attachment_unknown_filetype_returns_415(talk_client):
    """Unsupported file types (zip/exe/etc.) should be rejected at the
    attachment surface, not silently passed through. v1 is image-and-text-
    only by design."""
    resp = talk_client.post(
        "/api/talk/attachment",
        files={"file": ("archive.zip", b"PK\x03\x04...", "application/zip")},
        data={"text": ""},
    )
    assert resp.status_code == 415


def test_talk_attachment_image_routes_to_vision_endpoint_bypassing_hermes(talk_client, monkeypatch):
    """Images go to litellm directly (not through Hermes) because Hermes's
    prompt.submit accepts only text — multimodal content arrays are a
    litellm/llama-server-level concept. Regression guard that we don't
    accidentally re-route images through the text-only Hermes path."""
    hermes_stream_called = False

    async def fake_hermes_stream(session_key, text):
        nonlocal hermes_stream_called
        hermes_stream_called = True
        yield {"type": "session", "session_id": "sid"}
        yield {"type": "complete", "session_id": "sid", "text": "wrong-path", "status": "ok", "warning": None}

    async def fake_vision_stream(image_bytes, content_type, prompt_text):
        from routers.talk import _sse_event
        yield _sse_event("session", {"session_id": "vision"})
        yield _sse_event("delta", {"text": "Red."})
        yield _sse_event("complete", {"session_id": "vision", "text": "Red.", "status": "ok", "warning": None})
        yield _sse_event("done", {})

    monkeypatch.setattr("hermes_bridge.stream_prompt", fake_hermes_stream)
    monkeypatch.setattr("routers.talk._stream_vision_chat", fake_vision_stream)

    # Minimal valid PNG bytes (PNG signature + IHDR).
    png_bytes = b"\x89PNG\r\n\x1a\n" + b"\x00" * 32
    resp = talk_client.post(
        "/api/talk/attachment",
        files={"file": ("photo.png", png_bytes, "image/png")},
        data={"text": "what color?"},
    )
    assert resp.status_code == 200
    frames = _parse_sse_frames(resp.content)
    assert any(f.get("type") == "complete" and f.get("text") == "Red." for f in frames)
    # Hermes was NOT called for an image — image flow bypasses the text
    # agent loop.
    assert not hermes_stream_called


def test_talk_attachment_image_uses_filename_when_mobile_uploads_octet_stream(talk_client, monkeypatch):
    """Mobile browsers sometimes send captured images as octet-stream.
    Filename fallback keeps phone uploads on the image path and gives the
    vision model a real image MIME in the data URL."""
    captured_content_type = None

    async def fake_vision_stream(image_bytes, content_type, prompt_text):
        nonlocal captured_content_type
        from routers.talk import _sse_event
        captured_content_type = content_type
        yield _sse_event("session", {"session_id": "vision"})
        yield _sse_event("complete", {"session_id": "vision", "text": "Red.", "status": "ok", "warning": None})
        yield _sse_event("done", {})

    monkeypatch.setattr("routers.talk._stream_vision_chat", fake_vision_stream)

    png_bytes = b"\x89PNG\r\n\x1a\n" + b"\x00" * 32
    resp = talk_client.post(
        "/api/talk/attachment",
        files={"file": ("phone-capture.png", png_bytes, "application/octet-stream")},
        data={"text": "what color?"},
    )
    assert resp.status_code == 200
    assert captured_content_type == "image/png"
    frames = _parse_sse_frames(resp.content)
    assert any(f.get("type") == "complete" and f.get("text") == "Red." for f in frames)


def test_talk_attachment_image_too_large_returns_413(talk_client):
    """Image size cap is enforced at the multipart boundary."""
    big = b"\x89PNG\r\n\x1a\n" + b"x" * (11 * 1024 * 1024)
    resp = talk_client.post(
        "/api/talk/attachment",
        files={"file": ("huge.png", big, "image/png")},
    )
    assert resp.status_code == 413


def test_talk_vision_url_defaults_to_openai_v1(monkeypatch):
    """A root inference URL should be normalized to an OpenAI-compatible base."""
    from routers.talk import _vision_chat_completions_url

    monkeypatch.delenv("ODS_TALK_VISION_URL", raising=False)
    monkeypatch.setenv("LLM_API_URL", "http://llama-server:8080")
    monkeypatch.setenv("LLM_API_BASE_PATH", "/v1")

    assert _vision_chat_completions_url() == "http://llama-server:8080/v1/chat/completions"


def test_talk_vision_url_does_not_duplicate_v1(monkeypatch):
    """Operators may pass a full OpenAI base URL; do not append /v1 twice."""
    from routers.talk import _vision_chat_completions_url

    monkeypatch.setenv("ODS_TALK_VISION_URL", "http://vision.local:8080/v1")
    monkeypatch.setenv("LLM_API_BASE_PATH", "/v1")

    assert _vision_chat_completions_url() == "http://vision.local:8080/v1/chat/completions"


def test_talk_vision_url_preserves_lemonade_api_v1(monkeypatch):
    """Lemonade deployments can use /api/v1 as their OpenAI-compatible base."""
    from routers.talk import _vision_chat_completions_url

    monkeypatch.setenv("ODS_TALK_VISION_URL", "http://host.docker.internal:8080/api/v1")
    monkeypatch.setenv("LLM_API_BASE_PATH", "/v1")

    assert _vision_chat_completions_url() == "http://host.docker.internal:8080/api/v1/chat/completions"


def test_talk_attachment_requires_session(test_client):
    """No cookie ⇒ 401 even with a valid file."""
    resp = test_client.post(
        "/api/talk/attachment",
        files={"file": ("notes.md", b"hi", "text/markdown")},
        headers=test_client.auth_headers,
    )
    assert resp.status_code == 401


def test_talk_message_stream_sets_unbuffered_headers(talk_client, monkeypatch):
    """nginx upstream needs ``X-Accel-Buffering: no`` + ``Cache-Control: no-cache``
    so each SSE frame is forwarded immediately. Regression guard for the SSE
    path: if either header is dropped, the dashboard nginx proxy will buffer
    the response and the phone will see the full reply only at the end."""
    async def fake_stream(session_key, text):
        yield {"type": "session", "session_id": "sid-h"}
        yield {"type": "complete", "session_id": "sid-h", "text": "ok", "status": "ok", "warning": None}

    monkeypatch.setattr("hermes_bridge.stream_prompt", fake_stream)

    resp = talk_client.post("/api/talk/message/stream", json={"text": "hi"})
    assert resp.status_code == 200
    assert resp.headers.get("x-accel-buffering") == "no"
    assert "no-cache" in resp.headers.get("cache-control", "").lower()


def test_ods_talk_hermes_timeout_is_env_configurable(monkeypatch):
    """Lemonade-backed full models can take longer than the generic Talk
    default before producing the first useful event. The compose overlays
    set this env var for those modes; the bridge must honor it."""
    import hermes_bridge

    monkeypatch.setenv("ODS_TALK_HERMES_TIMEOUT", "900")
    assert hermes_bridge._request_timeout() == 900

    monkeypatch.setenv("ODS_TALK_HERMES_TIMEOUT", "5")
    assert hermes_bridge._request_timeout() == 10
