"""Tests for BasePlatformAdapter._keep_typing timeout-per-tick behavior.

When the gateway is waiting on a long upstream provider response (e.g.
Anthropic/opus-4.7 first-token latency climbing during an upstream blip),
the model-call socket is blocked on the worker thread but the asyncio loop
is still running, and ``_keep_typing`` refreshes the platform typing
indicator every 2 seconds.

The bug: each ``send_typing`` call is an HTTP round-trip to the platform API
(Telegram/Discord). If the same network instability that's slowing the model
call also makes ``send_typing`` slow (5-30s response time), the refresh loop
stalls inside the ``await self.send_typing(...)`` call. Platform-side typing
expires at ~5s, so the bubble dies and doesn't come back until that stuck
call returns — exactly when the user most needs the "yes, still working"
signal.

The fix: bound each ``send_typing`` with ``asyncio.wait_for``. If a
send_typing takes longer than the per-tick budget (default 1.5s when
interval=2.0), abandon it and let the next scheduled tick fire a fresh
call. As long as any one of them succeeds within the ~5s platform window,
the bubble stays visible across provider stalls.
"""

import asyncio
from unittest.mock import MagicMock

import pytest

from gateway.platforms.base import (
    BasePlatformAdapter,
    Platform,
    PlatformConfig,
    SendResult,
)


class _StubAdapter(BasePlatformAdapter):
    def __init__(self):
        super().__init__(PlatformConfig(enabled=True, token="test"), Platform.TELEGRAM)

    async def connect(self, *, is_reconnect: bool = False) -> bool:
        return True

    async def disconnect(self) -> None:
        self._mark_disconnected()

    async def send(self, chat_id, content, reply_to=None, metadata=None):
        return SendResult(success=True, message_id="m1")

    async def get_chat_info(self, chat_id):
        return {"id": chat_id, "type": "dm"}


class TestKeepTypingTimeoutPerTick:
    @pytest.mark.asyncio
    async def test_slow_send_typing_does_not_block_cadence(self, monkeypatch):
        """A send_typing that hangs longer than the per-tick budget must be
        abandoned so the next scheduled tick can fire a fresh call."""
        adapter = _StubAdapter()
        call_events = []

        async def slow_send_typing(chat_id, metadata=None):
            # Simulate a stuck HTTP round-trip. If _keep_typing awaits this
            # unconditionally, the loop stalls for the full duration.
            call_events.append("start")
            try:
                await asyncio.sleep(10)
            finally:
                call_events.append("finish-or-cancel")

        monkeypatch.setattr(adapter, "send_typing", slow_send_typing)
        # Avoid stop_typing side-effects in the finally block.
        adapter.stop_typing = MagicMock(return_value=asyncio.sleep(0))

        stop_event = asyncio.Event()
        # Start the typing loop, let it run ~3s (should fire 2 ticks) then stop.
        task = asyncio.create_task(
            adapter._keep_typing(
                chat_id="123",
                interval=1.0,
                stop_event=stop_event,
            )
        )
        await asyncio.sleep(3.0)
        stop_event.set()
        try:
            await asyncio.wait_for(task, timeout=2.0)
        except asyncio.TimeoutError:
            task.cancel()
            pytest.fail(
                "_keep_typing did not exit within 2s of stop_event.set() — "
                "it is blocked on a slow send_typing call"
            )

        # With per-tick timeout, we should see MULTIPLE send_typing starts
        # despite each being slow (abandoned via TimeoutError).  Without the
        # fix there would be exactly 1 start (the one still stuck).
        starts = [e for e in call_events if e == "start"]
        assert len(starts) >= 2, (
            f"expected at least 2 send_typing ticks across 3s of slow "
            f"operation, got {len(starts)} — refresh cadence is stalled "
            f"on a slow send_typing"
        )


    @pytest.mark.asyncio
    async def test_send_typing_exception_does_not_kill_loop(self, monkeypatch):
        """A send_typing that raises (e.g. transient HTTP 500) must be
        caught so the loop continues refreshing on schedule."""
        adapter = _StubAdapter()
        tick_count = {"n": 0}

        async def flaky_send_typing(chat_id, metadata=None):
            tick_count["n"] += 1
            if tick_count["n"] == 1:
                raise RuntimeError("transient upstream error")
            # Subsequent calls succeed.

        monkeypatch.setattr(adapter, "send_typing", flaky_send_typing)
        adapter.stop_typing = MagicMock(return_value=asyncio.sleep(0))

        stop_event = asyncio.Event()
        task = asyncio.create_task(
            adapter._keep_typing(
                chat_id="789",
                interval=0.3,
                stop_event=stop_event,
            )
        )
        await asyncio.sleep(1.0)
        stop_event.set()
        await asyncio.wait_for(task, timeout=1.0)

        assert tick_count["n"] >= 2, (
            f"loop exited after first send_typing exception; expected it to "
            f"keep ticking (got {tick_count['n']} ticks)"
        )


    @pytest.mark.asyncio
    async def test_stop_typing_refresh_blocks_late_cancel_tick(self, monkeypatch):
        """Final cleanup must not let a cancelled refresh loop send typing again."""
        adapter = _StubAdapter()
        late_sends = []
        stop_calls = []

        async def send_typing(chat_id, metadata=None):
            late_sends.append(chat_id)

        async def stop_typing(chat_id):
            stop_calls.append((chat_id, chat_id in adapter._typing_paused))

        monkeypatch.setattr(adapter, "send_typing", send_typing)
        monkeypatch.setattr(adapter, "stop_typing", stop_typing)

        async def late_refresh_after_cancel():
            try:
                await asyncio.sleep(10)
            except asyncio.CancelledError:
                if "discord-chat" not in adapter._typing_paused:
                    await adapter.send_typing("discord-chat")
                raise

        task = asyncio.create_task(late_refresh_after_cancel())
        await asyncio.sleep(0)

        await adapter._stop_typing_refresh("discord-chat", task, timeout=1.0)

        assert late_sends == []
        assert stop_calls == [
            ("discord-chat", True),
            ("discord-chat", True),
        ]
        assert "discord-chat" not in adapter._typing_paused


class TestPreDeliveryTypingSuspend:
    """Regression guard for #117300: once delivery begins, the refresh loop
    must be suspended so a stalled final send (platform accepted it, HTTP ack
    lost) cannot keep ``send_typing`` firing while the agent is idle.

    The fix reuses the existing ``_typing_paused`` mechanism: the delivery path
    pauses the chat before the first send attempt, and ``_stop_typing_refresh``'s
    finally discards it so it never leaks into the next turn.
    """

    @pytest.mark.asyncio
    async def test_paused_before_delivery_stall_suppresses_refresh(self, monkeypatch):
        """Pause the chat the way the delivery path does; a refresh loop running
        during the stall must never call send_typing (0 ticks)."""
        adapter = _StubAdapter()
        ticks = []

        async def recording_send_typing(chat_id, metadata=None):
            ticks.append(chat_id)

        monkeypatch.setattr(adapter, "send_typing", recording_send_typing)
        adapter.stop_typing = MagicMock(return_value=asyncio.sleep(0))

        # The delivery path pauses typing before the first send attempt.
        adapter.pause_typing_for_chat("123")

        stop_event = asyncio.Event()
        task = asyncio.create_task(
            adapter._keep_typing(
                chat_id="123",
                interval=0.2,
                stop_event=stop_event,
            )
        )
        # Simulate the 9s stall from the issue's repro: the refresh loop is
        # still live but the chat is paused, so no tick should reach send_typing.
        await asyncio.sleep(0.8)
        stop_event.set()
        await asyncio.wait_for(task, timeout=1.0)

        assert ticks == [], (
            f"typing kept refreshing across a pre-delivery pause: {ticks}"
        )
        # Pausing the delivery path must not leave the chat permanently paused.
        await adapter._stop_typing_refresh("123", None, stop_attempts=1)
        assert "123" not in adapter._typing_paused

    @pytest.mark.asyncio
    async def test_unpaused_stall_still_ticks(self, monkeypatch):
        """Control case: without the pre-delivery pause, the refresh loop keeps
        ticking (the buggy pre-fix behavior), proving the test discriminates."""
        adapter = _StubAdapter()
        ticks = []

        async def recording_send_typing(chat_id, metadata=None):
            ticks.append(chat_id)

        monkeypatch.setattr(adapter, "send_typing", recording_send_typing)
        adapter.stop_typing = MagicMock(return_value=asyncio.sleep(0))

        stop_event = asyncio.Event()
        task = asyncio.create_task(
            adapter._keep_typing(
                chat_id="456",
                interval=0.2,
                stop_event=stop_event,
            )
        )
        await asyncio.sleep(0.8)
        stop_event.set()
        await asyncio.wait_for(task, timeout=1.0)

        assert len(ticks) >= 2, (
            f"expected the unpaused refresh loop to keep ticking, "
            f"got {len(ticks)}"
        )


@pytest.mark.asyncio
async def test_process_message_pauses_typing_before_a_stalled_final_send():
    """Production entry point (#117300): ``_process_message_background`` must pause the chat
    BEFORE awaiting the final send. With the send parked forever the turn's ``finally`` never
    runs, so only a pre-delivery pause keeps ``_keep_typing`` from refreshing an idle agent."""
    from gateway.platforms.event import MessageEvent, MessageType
    from gateway.session import SessionSource

    adapter = _StubAdapter()
    send_started = asyncio.Event()
    never = asyncio.Event()

    async def _stalled_final_send(*args, **kwargs):
        send_started.set()
        await never.wait()  # platform accepted the message; the HTTP ack never comes back

    async def _handler(evt):
        return "a long final answer the user can already read"

    adapter.set_message_handler(_handler)
    adapter._send_final_text = _stalled_final_send
    adapter._start_typing_refresh = MagicMock(return_value=None)  # loop shape irrelevant here
    event = MessageEvent(
        text="hi", message_id="msg-1", message_type=MessageType.TEXT,
        source=SessionSource(platform=Platform.TELEGRAM, chat_id="77", user_id="u-1"),
    )
    turn = asyncio.create_task(adapter._process_message_background(event, "agent:main:telegram:private:77"))
    try:
        await asyncio.wait_for(send_started.wait(), timeout=5)
        assert "77" in adapter._typing_paused, "typing not paused while the final send is stalled"
    finally:
        turn.cancel()
        with pytest.raises(asyncio.CancelledError):
            await turn
