"""Regression tests for tool-using response silent drop (issue #29346).

When the agent returns a non-empty response that the extract pipeline
(extract_media / extract_images / extract_local_files / inline directive
strips) happens to reduce to an empty string, the ``if text_content:`` guard
in ``BasePlatformAdapter._process_message_background`` previously bypassed
the send entirely. The symptom was a ``response ready`` log followed by
silence — no ``Sending response`` line, no error — and the final answer
never reaching the channel.

The fix (A2/A3 of the silent-response-loss plan) preserves the pre-extract
response and, when no native attachment was produced to deliver in its
place, sanitizes the original text and sends it as a fallback on ALL
platforms (a ``response_delivery_recovered`` WARNING marks the recovery so
the silent-drop pattern is observable).  When even the sanitized recovery
yields nothing deliverable, a ``response_delivery_dropped`` ERROR fires so a
genuinely-lost response is never silent.

Salvaged and de-scoped from the superseded Discord-only PR #33842.
"""

import asyncio
import logging

import pytest

from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import (
    BasePlatformAdapter,
    MessageEvent,
    SendResult,
)
from gateway.session import SessionSource, build_session_key


class _DummyAdapter(BasePlatformAdapter):
    """Minimal BasePlatformAdapter for dispatch tests on any platform."""

    def __init__(self, platform: Platform):
        super().__init__(PlatformConfig(enabled=True, token="fake-token"), platform)
        self.sent: list[dict] = []

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

    async def disconnect(self) -> None:
        return None

    async def send(self, chat_id, content, reply_to=None, metadata=None) -> SendResult:
        self.sent.append({"chat_id": chat_id, "content": content})
        return SendResult(success=True, message_id="msg-1")

    async def send_typing(self, chat_id: str, metadata=None) -> None:
        return None

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


def _make_event(platform: Platform, chat_id: str = "111", message_id: str = "m1") -> MessageEvent:
    return MessageEvent(
        text="hello",
        source=SessionSource(platform=platform, chat_id=chat_id, chat_type="dm"),
        message_id=message_id,
    )


async def _hold_typing(_chat_id, interval=2.0, metadata=None, stop_event=None):
    if stop_event is not None:
        await stop_event.wait()
    else:
        await asyncio.Event().wait()


def _strip_everything(adapter, monkeypatch):
    """Force the extract pipeline to reduce text_content to "" with no
    attachments — the exact failure mode that made the drop invisible."""
    monkeypatch.setattr(
        type(adapter), "extract_media", staticmethod(lambda content: ([], content))
    )
    monkeypatch.setattr(
        type(adapter), "extract_images", staticmethod(lambda content: ([], ""))
    )
    monkeypatch.setattr(
        type(adapter), "extract_local_files", staticmethod(lambda content: ([], ""))
    )


@pytest.mark.parametrize("platform", [Platform.DISCORD, Platform.TELEGRAM])
class TestExtractStripRecoveryAllPlatforms:
    """A non-empty response stripped to empty must be recovered on EVERY
    platform (the fix de-scopes the recovery from Discord-only)."""

    @pytest.mark.asyncio
    async def test_response_reduced_to_empty_is_recovered_and_sent(
        self, platform, monkeypatch, caplog
    ):
        adapter = _DummyAdapter(platform)
        adapter._keep_typing = _hold_typing

        tool_response = (
            "Based on my search, the cheapest TPE-PAR flight on Dec 14 is $632 "
            "via Saudia. Here are the top options sorted by price... "
        ) * 5
        assert len(tool_response) > 500

        async def handler(_event):
            return tool_response

        adapter.set_message_handler(handler)
        _strip_everything(adapter, monkeypatch)

        event = _make_event(platform)
        with caplog.at_level(logging.WARNING, logger="gateway.platforms.base"):
            await adapter._process_message_background(
                event, build_session_key(event.source)
            )

        # The response WAS delivered, not silently dropped.
        assert len(adapter.sent) == 1, f"expected 1 send, got {adapter.sent}"
        assert adapter.sent[0]["content"] == tool_response.strip()
        # And the recovery is observable via the stable event key.
        assert any(
            "response_delivery_recovered" in r.getMessage()
            for r in caplog.records
        ), [r.getMessage() for r in caplog.records]

    @pytest.mark.asyncio
    async def test_directives_stripped_from_fallback_text(self, platform, monkeypatch):
        adapter = _DummyAdapter(platform)
        adapter._keep_typing = _hold_typing

        raw = (
            "[[audio_as_voice]]\n[[as_document]]\nMEDIA: /tmp/nope.ogg\n"
            "The real answer the user should see."
        )

        async def handler(_event):
            return raw

        adapter.set_message_handler(handler)
        _strip_everything(adapter, monkeypatch)

        event = _make_event(platform)
        await adapter._process_message_background(event, build_session_key(event.source))

        assert len(adapter.sent) == 1
        delivered = adapter.sent[0]["content"]
        assert "[[audio_as_voice]]" not in delivered
        assert "[[as_document]]" not in delivered
        assert "MEDIA:" not in delivered
        assert "The real answer the user should see." in delivered

    @pytest.mark.asyncio
    async def test_no_fallback_when_attachment_produced(self, platform, monkeypatch):
        """When an image attachment IS extracted, the empty text_content is
        intentional — recovery must NOT re-send the original markdown and
        duplicate the attachment's content."""
        adapter = _DummyAdapter(platform)
        adapter._keep_typing = _hold_typing

        async def handler(_event):
            return "![chart](https://example.com/chart.png)"

        adapter.set_message_handler(handler)
        monkeypatch.setattr(
            type(adapter), "extract_media", staticmethod(lambda content: ([], content))
        )
        monkeypatch.setattr(
            type(adapter), "extract_images",
            staticmethod(lambda content: ([("https://example.com/chart.png", "chart")], "")),
        )
        monkeypatch.setattr(
            type(adapter), "extract_local_files", staticmethod(lambda content: ([], ""))
        )
        adapter.send_multiple_images = lambda *a, **kw: asyncio.sleep(0, result=None)

        event = _make_event(platform)
        await adapter._process_message_background(event, build_session_key(event.source))

        assert adapter.sent == [], f"expected no text echo, got {adapter.sent}"


class TestRecoveryDoesNotLeakMediaFragments:
    """The A2 recovery must not leak fragments of a MEDIA: path to the user.

    extract_media's real regex matches paths WITH SPACES; if the recovery
    sanitizes the raw pre-extract snapshot with a weaker MEDIA regex (one that
    stops at the first space), a spaced path whose file gets filtered out leaks
    a fragment like 'vacation photo.png'.  The recovery must instead use the
    post-extract_media `response`, which the strong regex already cleaned.
    """

    @pytest.mark.asyncio
    async def test_spaced_media_path_does_not_leak_fragment(self, monkeypatch, caplog):
        adapter = _DummyAdapter(Platform.DISCORD)
        adapter._keep_typing = _hold_typing

        async def handler(_event):
            # Spaced path with a valid extension — matched in full by the real
            # extract_media regex, then removed from the body.
            return "MEDIA: /tmp/nope_dir_zzz/my vacation photo.png"

        adapter.set_message_handler(handler)
        # Use the REAL extract_media (so the strong regex cleans `response`),
        # but force the path to be filtered out (unsafe/nonexistent) so we hit
        # the empty-text + no-attachment recovery branch deterministically.
        monkeypatch.setattr(
            type(adapter), "filter_media_delivery_paths", staticmethod(lambda m: [])
        )

        event = _make_event(Platform.DISCORD)
        with caplog.at_level(logging.ERROR, logger="gateway.platforms.base"):
            await adapter._process_message_background(
                event, build_session_key(event.source)
            )

        # No fragment of the media path may reach the user.
        leaked = [
            s for s in adapter.sent
            if "vacation" in s["content"] or "photo" in s["content"] or "MEDIA" in s["content"]
        ]
        assert leaked == [], f"media-path fragment leaked to user: {leaked}"
        # The genuinely-undeliverable response is logged loudly, not silent.
        assert any(
            "response_delivery_dropped" in r.getMessage()
            for r in caplog.records if r.levelno == logging.ERROR
        ), [r.getMessage() for r in caplog.records]


class TestUnrecoverableDropIsLoud:
    """A non-empty response that produces NOTHING deliverable (sanitizes to
    empty, no attachment) must log a response_delivery_dropped ERROR rather
    than vanishing silently."""

    @pytest.mark.asyncio
    async def test_directive_only_response_logs_dropped(self, monkeypatch, caplog):
        adapter = _DummyAdapter(Platform.DISCORD)
        adapter._keep_typing = _hold_typing

        async def handler(_event):
            return "[[audio_as_voice]]\nMEDIA: /tmp/missing.ogg"  # only directives

        adapter.set_message_handler(handler)
        # Extraction strips to empty AND the media path filtered out (no file).
        _strip_everything(adapter, monkeypatch)

        event = _make_event(Platform.DISCORD)
        with caplog.at_level(logging.ERROR, logger="gateway.platforms.base"):
            await adapter._process_message_background(
                event, build_session_key(event.source)
            )

        assert adapter.sent == []
        assert any(
            "response_delivery_dropped" in r.getMessage()
            for r in caplog.records if r.levelno == logging.ERROR
        ), [r.getMessage() for r in caplog.records]


# ===========================================================================
# Issue #44212: post-/stop stale interrupt silently swallows the next message
# ===========================================================================

class TestPostStopInterruptSwallow:
    """A `/stop` sets ``_interrupt_requested`` on the session's cached agent,
    but the flag is only cleared by the turn finalizer.  When the stopped run
    is hung or still draining, the flag survives the lock release and the
    session's NEXT message is killed at the top of the tool loop —
    ``interrupted=True, api_calls=0, final_response=""`` — which
    ``_normalize_empty_agent_response`` used to pass through as pure silence.

    Two-layer fix: ``_interrupt_and_clear_session`` evicts the cached agent
    (root cause), and the normalizer surfaces a notice for interrupted runs
    that never made an API call (a swallowed user turn, not a drain)."""

    def test_interrupted_zero_api_calls_surfaces_notice(self):
        """Interrupted before the first API call → the user's message was
        never processed; silence here swallows it (the #44212 malign shape:
        ``response ready ... api_calls=0 response=0 chars``)."""
        from gateway.run import _normalize_empty_agent_response

        agent_result = {
            "final_response": None,
            "api_calls": 0,
            "partial": False,
            "interrupted": True,
        }

        response = _normalize_empty_agent_response(agent_result, "", history_len=10)

        assert response != "", "A turn killed before doing any work must not be silent"
        assert "send it again" in response.lower()

    def test_interrupted_after_work_stays_silent(self):
        """Interrupted mid-work → this is the drain of a run the user
        deliberately stopped/steered; its silence is intentional (any
        queued/interrupting message is delivered by the recursive drain
        inside _run_agent)."""
        from gateway.run import _normalize_empty_agent_response

        agent_result = {
            "final_response": None,
            "api_calls": 3,
            "partial": False,
            "interrupted": True,
        }

        response = _normalize_empty_agent_response(agent_result, "", history_len=10)

        assert response == ""

    def test_uninterrupted_zero_api_calls_surfaces_retry_hint(self):
        """No interrupt and no work — #31884 (landed after this PR was
        written) surfaces a retry hint instead of silence for the
        generation-race drop."""
        from gateway.run import _normalize_empty_agent_response

        agent_result = {
            "final_response": None,
            "api_calls": 0,
            "partial": False,
            "interrupted": False,
        }

        response = _normalize_empty_agent_response(agent_result, "", history_len=10)

        assert "send it again" in response

    @pytest.mark.asyncio
    async def test_interrupt_and_clear_session_evicts_cached_agent(self):
        """The control-interrupt path must evict the session's cached agent
        so its ``_interrupt_requested`` flag cannot leak into the next turn."""
        import threading

        from gateway.run import GatewayRunner, _INTERRUPT_REASON_STOP

        class _RecordingAgent:
            def __init__(self):
                self.interrupt_reasons = []

            def interrupt(self, reason=None):
                self.interrupt_reasons.append(reason)

        agent = _RecordingAgent()
        session_key = "agent:main:telegram:dm:12345"
        source = SessionSource(
            platform=Platform.TELEGRAM, chat_id="12345", chat_type="dm"
        )

        runner = object.__new__(GatewayRunner)
        runner._running_agents = {session_key: agent}
        runner._agent_cache = {session_key: (agent, "config-sig")}
        runner._agent_cache_lock = threading.Lock()
        runner.adapters = {}
        runner._pending_messages = {}

        invalidated = []
        runner._invalidate_session_run_generation = (
            lambda key, reason=None: invalidated.append((key, reason))
        )
        released = []
        runner._release_running_agent_state = (
            lambda key, **kw: released.append(key)
        )

        await runner._interrupt_and_clear_session(
            session_key,
            source,
            interrupt_reason=_INTERRUPT_REASON_STOP,
            invalidation_reason="stop_command",
        )

        assert agent.interrupt_reasons == [_INTERRUPT_REASON_STOP]
        assert released == [session_key]
        assert session_key not in runner._agent_cache, (
            "Cached agent with a set interrupt flag must be evicted on /stop "
            "so the flag cannot kill the session's next message (#44212)"
        )
