"""
Tests for Telegram document handling in gateway/platforms/telegram.py.

Covers: document type detection, download/cache flow, size limits,
        text injection, error handling.

Note: python-telegram-bot may not be installed in the test environment.
We mock the telegram module at import time to avoid collection errors.
"""

import asyncio
import os
import sys
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from gateway.config import PlatformConfig
from gateway.platforms.base import (
    MessageEvent,
    MessageType,
    SendResult,
    SUPPORTED_VIDEO_TYPES,
)


# ---------------------------------------------------------------------------
# Mock the telegram package if it's not installed
# ---------------------------------------------------------------------------

def _ensure_telegram_mock():
    """Install mock telegram modules so TelegramAdapter can be imported."""
    if "telegram" in sys.modules and hasattr(sys.modules["telegram"], "__file__"):
        # Real library is installed — no mocking needed
        return

    telegram_mod = MagicMock()
    # ContextTypes needs DEFAULT_TYPE as an actual attribute for the annotation
    telegram_mod.ext.ContextTypes.DEFAULT_TYPE = type(None)
    telegram_mod.constants.ParseMode.MARKDOWN_V2 = "MarkdownV2"
    telegram_mod.constants.ChatType.GROUP = "group"
    telegram_mod.constants.ChatType.SUPERGROUP = "supergroup"
    telegram_mod.constants.ChatType.CHANNEL = "channel"
    telegram_mod.constants.ChatType.PRIVATE = "private"

    for name in ("telegram", "telegram.ext", "telegram.constants", "telegram.request"):
        sys.modules.setdefault(name, telegram_mod)


_ensure_telegram_mock()

# Now we can safely import
from plugins.platforms.telegram.adapter import TelegramAdapter  # noqa: E402


# ---------------------------------------------------------------------------
# Helpers to build mock Telegram objects
# ---------------------------------------------------------------------------

def _make_file_obj(data: bytes = b"hello"):
    """Create a mock Telegram File with download_as_bytearray."""
    f = AsyncMock()
    f.download_as_bytearray = AsyncMock(return_value=bytearray(data))
    f.file_path = "documents/file.pdf"
    return f


def _make_document(
    file_name="report.pdf",
    mime_type="application/pdf",
    file_size=1024,
    file_obj=None,
):
    """Create a mock Telegram Document object."""
    doc = MagicMock()
    doc.file_name = file_name
    doc.mime_type = mime_type
    doc.file_size = file_size
    doc.get_file = AsyncMock(return_value=file_obj or _make_file_obj())
    return doc


def _make_message(document=None, caption=None, media_group_id=None, photo=None):
    """Build a mock Telegram Message with the given document/photo."""
    msg = MagicMock()
    msg.message_id = 42
    msg.text = caption or ""
    msg.caption = caption
    msg.date = None
    # Media flags — all None except explicit payload
    msg.photo = photo
    msg.video = None
    msg.audio = None
    msg.voice = None
    msg.sticker = None
    msg.document = document
    msg.media_group_id = media_group_id
    # Chat / user
    msg.chat = MagicMock()
    msg.chat.id = 100
    msg.chat.type = "private"
    msg.chat.title = None
    msg.chat.full_name = "Test User"
    msg.from_user = MagicMock()
    msg.from_user.id = 1
    msg.from_user.full_name = "Test User"
    msg.message_thread_id = None
    msg.reply_text = AsyncMock()
    return msg


def _make_update(msg):
    """Wrap a message in a mock Update."""
    update = MagicMock()
    update.message = msg
    return update


def _make_video(file_obj=None):
    video = MagicMock()
    video.get_file = AsyncMock(return_value=file_obj or _make_file_obj(b"video-bytes"))
    return video


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------

@pytest.fixture()
def adapter():
    config = PlatformConfig(enabled=True, token="fake-token")
    a = TelegramAdapter(config)
    # Capture events instead of processing them
    a.handle_message = AsyncMock()
    # After PR #28494 made the empty-allowlist callback auth fail-closed
    # (and #28492 wired _is_callback_user_authorized into _should_process_message),
    # document-routing tests need to bypass the new gate so messages from fake
    # senders reach handle_message.
    a._is_callback_user_authorized = lambda user_id, **_kw: True
    return a


@pytest.fixture(autouse=True)
def _redirect_cache(tmp_path, monkeypatch):
    """Point document/video cache to tmp_path so tests don't touch ~/.hermes."""
    monkeypatch.setattr(
        "gateway.platforms.base.DOCUMENT_CACHE_DIR", tmp_path / "doc_cache"
    )
    monkeypatch.setattr(
        "gateway.platforms.base.VIDEO_CACHE_DIR", tmp_path / "video_cache"
    )


# ---------------------------------------------------------------------------
# TestDocumentTypeDetection
# ---------------------------------------------------------------------------

class TestDocumentTypeDetection:
    @pytest.mark.asyncio
    async def test_document_detected_explicitly(self, adapter):
        doc = _make_document()
        msg = _make_message(document=doc)
        update = _make_update(msg)
        await adapter._handle_media_message(update, MagicMock())
        event = adapter.handle_message.call_args[0][0]
        assert event.message_type == MessageType.DOCUMENT

    @pytest.mark.asyncio
    async def test_fallback_is_document(self, adapter):
        """When no specific media attr is set, message_type defaults to DOCUMENT."""
        msg = _make_message()
        msg.document = None  # no media at all
        update = _make_update(msg)
        await adapter._handle_media_message(update, MagicMock())
        event = adapter.handle_message.call_args[0][0]
        assert event.message_type == MessageType.DOCUMENT


# ---------------------------------------------------------------------------
# TestDocumentDownloadBlock
# ---------------------------------------------------------------------------

def _make_photo(file_obj=None):
    photo = MagicMock()
    photo.get_file = AsyncMock(return_value=file_obj or _make_file_obj(b"photo-bytes"))
    return photo


class TestDocumentDownloadBlock:
    @pytest.mark.asyncio
    async def test_supported_pdf_is_cached(self, adapter):
        pdf_bytes = b"%PDF-1.4 fake"
        file_obj = _make_file_obj(pdf_bytes)
        doc = _make_document(file_name="report.pdf", file_size=1024, file_obj=file_obj)
        msg = _make_message(document=doc)
        update = _make_update(msg)

        await adapter._handle_media_message(update, MagicMock())
        event = adapter.handle_message.call_args[0][0]
        assert len(event.media_urls) == 1
        assert os.path.exists(event.media_urls[0])
        assert event.media_types == ["application/pdf"]

    @pytest.mark.asyncio
    async def test_supported_txt_injects_content(self, adapter):
        content = b"Hello from a text file"
        file_obj = _make_file_obj(content)
        doc = _make_document(
            file_name="notes.txt", mime_type="text/plain",
            file_size=len(content), file_obj=file_obj,
        )
        msg = _make_message(document=doc)
        update = _make_update(msg)

        await adapter._handle_media_message(update, MagicMock())
        event = adapter.handle_message.call_args[0][0]
        assert "Hello from a text file" in event.text
        assert "[Content of notes.txt]" in event.text

    @pytest.mark.asyncio
    async def test_supported_md_injects_content(self, adapter):
        content = b"# Title\nSome markdown"
        file_obj = _make_file_obj(content)
        doc = _make_document(
            file_name="readme.md", mime_type="text/markdown",
            file_size=len(content), file_obj=file_obj,
        )
        msg = _make_message(document=doc)
        update = _make_update(msg)

        await adapter._handle_media_message(update, MagicMock())
        event = adapter.handle_message.call_args[0][0]
        assert "# Title" in event.text

    @pytest.mark.asyncio
    async def test_caption_preserved_with_injection(self, adapter):
        content = b"file text"
        file_obj = _make_file_obj(content)
        doc = _make_document(
            file_name="doc.txt", mime_type="text/plain",
            file_size=len(content), file_obj=file_obj,
        )
        msg = _make_message(document=doc, caption="Please summarize")
        update = _make_update(msg)

        await adapter._handle_media_message(update, MagicMock())
        event = adapter.handle_message.call_args[0][0]
        assert "file text" in event.text
        assert "Please summarize" in event.text

    @pytest.mark.asyncio
    async def test_zip_document_cached(self, adapter):
        """A .zip upload should be cached as a supported document."""
        doc = _make_document(file_name="archive.zip", mime_type="application/zip", file_size=100)
        msg = _make_message(document=doc)
        update = _make_update(msg)

        await adapter._handle_media_message(update, MagicMock())
        event = adapter.handle_message.call_args[0][0]
        assert event.media_urls and event.media_urls[0].endswith("archive.zip")
        assert event.media_types == ["application/zip"]

    @pytest.mark.asyncio
    async def test_png_document_is_routed_as_image(self, adapter):
        """Telegram documents that are really PNGs should use the image path."""
        file_obj = _make_file_obj(b"\x89PNG\r\n\x1a\n" + b"\x00" * 16)
        doc = _make_document(file_name="screenshot.png", mime_type="image/png", file_size=9, file_obj=file_obj)
        msg = _make_message(document=doc)
        update = _make_update(msg)

        with patch.object(adapter, "_photo_batch_key", return_value="batch-1"), patch.object(
            adapter, "_enqueue_photo_event"
        ) as enqueue_mock:
            await adapter._handle_media_message(update, MagicMock())

        enqueue_mock.assert_called_once()
        event = enqueue_mock.call_args.args[1]
        assert event.message_type == MessageType.PHOTO
        assert event.media_urls and event.media_urls[0].endswith(".png")
        assert event.media_types == ["image/png"]
        assert adapter.handle_message.call_count == 0

    @pytest.mark.asyncio
    async def test_spoofed_png_document_falls_back_with_error(self, adapter):
        """A .png filename with non-image bytes should fail clearly, not disappear."""
        file_obj = _make_file_obj(b"not-a-real-image")
        doc = _make_document(file_name="spoofed.png", mime_type="image/png", file_size=16, file_obj=file_obj)
        msg = _make_message(document=doc)
        update = _make_update(msg)

        with patch.object(adapter, "_photo_batch_key", return_value="batch-2"), patch.object(
            adapter, "_enqueue_photo_event"
        ) as enqueue_mock:
            await adapter._handle_media_message(update, MagicMock())

        enqueue_mock.assert_not_called()
        event = adapter.handle_message.call_args[0][0]
        assert "could not be read as an image" in event.text

    @pytest.mark.asyncio
    async def test_oversized_file_rejected(self, adapter):
        doc = _make_document(file_name="huge.pdf", file_size=25 * 1024 * 1024)
        msg = _make_message(document=doc)
        update = _make_update(msg)

        await adapter._handle_media_message(update, MagicMock())
        event = adapter.handle_message.call_args[0][0]
        assert "too large" in event.text

    @pytest.mark.asyncio
    async def test_none_file_size_rejected(self, adapter):
        """Security fix: file_size=None must be rejected (not silently allowed)."""
        doc = _make_document(file_name="tricky.pdf", file_size=None)
        msg = _make_message(document=doc)
        update = _make_update(msg)

        await adapter._handle_media_message(update, MagicMock())
        event = adapter.handle_message.call_args[0][0]
        assert "too large" in event.text or "could not be verified" in event.text

    @pytest.mark.asyncio
    async def test_missing_filename_uses_mime_lookup(self, adapter):
        """No file_name but valid mime_type should resolve to extension."""
        content = b"some pdf bytes"
        file_obj = _make_file_obj(content)
        doc = _make_document(
            file_name=None, mime_type="application/pdf",
            file_size=len(content), file_obj=file_obj,
        )
        msg = _make_message(document=doc)
        update = _make_update(msg)

        await adapter._handle_media_message(update, MagicMock())
        event = adapter.handle_message.call_args[0][0]
        assert len(event.media_urls) == 1
        assert event.media_types == ["application/pdf"]

    @pytest.mark.asyncio
    async def test_missing_filename_and_mime_cached_as_octet_stream(self, adapter):
        """No filename and no mime: cached anyway as application/octet-stream.

        Authorization to message the agent is the gate, not the file type — an
        untyped upload is still surfaced to the agent as a cached path.
        """
        content = b"\x00\x01\x02 untyped payload"
        file_obj = _make_file_obj(content)
        doc = _make_document(
            file_name=None, mime_type=None, file_size=len(content), file_obj=file_obj,
        )
        msg = _make_message(document=doc)
        update = _make_update(msg)

        await adapter._handle_media_message(update, MagicMock())
        event = adapter.handle_message.call_args[0][0]
        assert len(event.media_urls) == 1
        assert event.media_types == ["application/octet-stream"]
        assert "Unsupported" not in (event.text or "")

    @pytest.mark.asyncio
    async def test_unicode_decode_error_handled(self, adapter):
        """Binary bytes that aren't valid UTF-8 in a .txt — content not injected but file still cached."""
        binary = bytes(range(128, 256))  # not valid UTF-8
        file_obj = _make_file_obj(binary)
        doc = _make_document(
            file_name="binary.txt", mime_type="text/plain",
            file_size=len(binary), file_obj=file_obj,
        )
        msg = _make_message(document=doc)
        update = _make_update(msg)

        await adapter._handle_media_message(update, MagicMock())
        event = adapter.handle_message.call_args[0][0]
        # File should still be cached
        assert len(event.media_urls) == 1
        assert os.path.exists(event.media_urls[0])
        # Content NOT injected — text should be empty (no caption set)
        assert "[Content of" not in (event.text or "")

    @pytest.mark.asyncio
    async def test_text_injection_capped(self, adapter):
        """A .txt file over 100 KB should NOT have its content injected."""
        large = b"x" * (200 * 1024)  # 200 KB
        file_obj = _make_file_obj(large)
        doc = _make_document(
            file_name="big.txt", mime_type="text/plain",
            file_size=len(large), file_obj=file_obj,
        )
        msg = _make_message(document=doc)
        update = _make_update(msg)

        await adapter._handle_media_message(update, MagicMock())
        event = adapter.handle_message.call_args[0][0]
        # File should be cached
        assert len(event.media_urls) == 1
        # Content should NOT be injected
        assert "[Content of" not in (event.text or "")

    @pytest.mark.asyncio
    async def test_download_exception_handled(self, adapter):
        """If get_file() raises, the handler surfaces the failure without crashing."""
        doc = _make_document(file_name="crash.pdf", file_size=100)
        doc.get_file = AsyncMock(side_effect=RuntimeError("Telegram API down"))
        msg = _make_message(document=doc)
        update = _make_update(msg)

        # Should not raise
        await adapter._handle_media_message(update, MagicMock())
        # handle_message should still be called (the handler catches the exception)
        # so the agent gets a turn — but now that turn carries a notice instead
        # of being an empty/content-less event.
        adapter.handle_message.assert_called_once()

    @pytest.mark.asyncio
    async def test_document_cache_failure_replies_and_signals_agent(self, adapter):
        """A failed document download must surface on BOTH ends, not silently.

        Regression for #23045 Bug 2: a CDN download/cache failure used to log a
        warning and fall through to an empty agent turn — user thinks the file
        arrived, agent sees nothing. Now the user gets a Telegram reply AND the
        agent's event.text carries an attempted-attachment notice.
        """
        doc = _make_document(file_name="notes.md", mime_type="text/markdown", file_size=100)
        doc.get_file = AsyncMock(side_effect=RuntimeError("Telegram CDN down"))
        msg = _make_message(document=doc)
        update = _make_update(msg)

        await adapter._handle_media_message(update, MagicMock())

        # 1. User is told the download failed, with the filename + exception type.
        msg.reply_text.assert_awaited_once()
        reply = msg.reply_text.await_args.args[0]
        assert "Couldn't download" in reply
        assert "notes.md" in reply
        assert "RuntimeError" in reply

        # 2. The agent still gets a turn, but event.text now carries a notice so
        #    it knows an attachment was attempted and failed (not a silent empty turn).
        adapter.handle_message.assert_called_once()
        event = adapter.handle_message.call_args[0][0]
        assert event.media_urls == []  # nothing cached
        assert "could not be downloaded" in (event.text or "")
        assert "notes.md" in (event.text or "")

    @pytest.mark.asyncio
    async def test_document_cache_failure_reply_error_is_nonfatal(self, adapter):
        """If even the user-reply fails, the agent notice is still appended."""
        doc = _make_document(file_name="x.bin", mime_type="application/octet-stream", file_size=100)
        doc.get_file = AsyncMock(side_effect=RuntimeError("CDN down"))
        msg = _make_message(document=doc)
        msg.reply_text = AsyncMock(side_effect=RuntimeError("reply failed too"))
        update = _make_update(msg)

        # Must not raise despite reply_text blowing up
        await adapter._handle_media_message(update, MagicMock())
        adapter.handle_message.assert_called_once()
        event = adapter.handle_message.call_args[0][0]
        assert "could not be downloaded" in (event.text or "")

    @pytest.mark.asyncio
    async def test_voice_cache_failure_replies_and_signals_agent(self, adapter):
        """Same fail-closed contract applies to the voice site (#23045 Bug 2 class)."""
        msg = _make_message()
        msg.voice = MagicMock()
        msg.voice.file_size = 100
        msg.voice.get_file = AsyncMock(side_effect=RuntimeError("CDN down"))
        update = _make_update(msg)

        await adapter._handle_media_message(update, MagicMock())

        msg.reply_text.assert_awaited_once()
        assert "voice message" in msg.reply_text.await_args.args[0]
        adapter.handle_message.assert_called_once()
        event = adapter.handle_message.call_args[0][0]
        assert "could not be downloaded" in (event.text or "")


class TestVideoDownloadBlock:
    @pytest.mark.asyncio
    async def test_native_video_is_cached(self, adapter):
        file_obj = _make_file_obj(b"fake-mp4")
        file_obj.file_path = "videos/clip.mp4"
        msg = _make_message()
        msg.video = _make_video(file_obj)
        update = _make_update(msg)

        await adapter._handle_media_message(update, MagicMock())
        event = adapter.handle_message.call_args[0][0]
        assert event.message_type == MessageType.VIDEO
        assert len(event.media_urls) == 1
        assert os.path.exists(event.media_urls[0])
        assert event.media_types == [SUPPORTED_VIDEO_TYPES[".mp4"]]

    @pytest.mark.asyncio
    async def test_mp4_document_is_treated_as_video(self, adapter):
        file_obj = _make_file_obj(b"fake-mp4-doc")
        doc = _make_document(file_name="good.mp4", mime_type="video/mp4", file_size=1024, file_obj=file_obj)
        msg = _make_message(document=doc)
        update = _make_update(msg)

        await adapter._handle_media_message(update, MagicMock())
        event = adapter.handle_message.call_args[0][0]
        assert event.message_type == MessageType.VIDEO
        assert len(event.media_urls) == 1
        assert os.path.exists(event.media_urls[0])
        assert event.media_types == [SUPPORTED_VIDEO_TYPES[".mp4"]]


# ---------------------------------------------------------------------------
# TestMediaGroups — media group (album) buffering
# ---------------------------------------------------------------------------

class TestMediaGroups:
    @pytest.mark.asyncio
    async def test_non_album_photo_burst_is_buffered_and_combined(self, adapter):
        first_photo = _make_photo(_make_file_obj(b"first"))
        second_photo = _make_photo(_make_file_obj(b"second"))

        msg1 = _make_message(caption="two images", photo=[first_photo])
        msg2 = _make_message(photo=[second_photo])

        with patch("plugins.platforms.telegram.adapter.cache_image_from_bytes", side_effect=["/tmp/burst-one.jpg", "/tmp/burst-two.jpg"]):
            await adapter._handle_media_message(_make_update(msg1), MagicMock())
            await adapter._handle_media_message(_make_update(msg2), MagicMock())
            assert adapter.handle_message.await_count == 0
            await asyncio.sleep(adapter.MEDIA_GROUP_WAIT_SECONDS + 0.05)

        adapter.handle_message.assert_awaited_once()
        event = adapter.handle_message.await_args.args[0]
        assert event.text == "two images"
        assert event.media_urls == ["/tmp/burst-one.jpg", "/tmp/burst-two.jpg"]
        assert len(event.media_types) == 2

    @pytest.mark.asyncio
    async def test_photo_album_is_buffered_and_combined(self, adapter):
        first_photo = _make_photo(_make_file_obj(b"first"))
        second_photo = _make_photo(_make_file_obj(b"second"))

        msg1 = _make_message(caption="two images", media_group_id="album-1", photo=[first_photo])
        msg2 = _make_message(media_group_id="album-1", photo=[second_photo])

        with patch("plugins.platforms.telegram.adapter.cache_image_from_bytes", side_effect=["/tmp/one.jpg", "/tmp/two.jpg"]):
            await adapter._handle_media_message(_make_update(msg1), MagicMock())
            await adapter._handle_media_message(_make_update(msg2), MagicMock())
            assert adapter.handle_message.await_count == 0
            await asyncio.sleep(adapter.MEDIA_GROUP_WAIT_SECONDS + 0.05)

        adapter.handle_message.assert_awaited_once()
        event = adapter.handle_message.call_args[0][0]
        assert event.text == "two images"
        assert event.media_urls == ["/tmp/one.jpg", "/tmp/two.jpg"]
        assert len(event.media_types) == 2

    @pytest.mark.asyncio
    async def test_disconnect_cancels_pending_media_group_flush(self, adapter):
        first_photo = _make_photo(_make_file_obj(b"first"))
        msg = _make_message(caption="two images", media_group_id="album-2", photo=[first_photo])

        with patch("plugins.platforms.telegram.adapter.cache_image_from_bytes", return_value="/tmp/one.jpg"):
            await adapter._handle_media_message(_make_update(msg), MagicMock())

        assert "album-2" in adapter._media_group_events
        assert "album-2" in adapter._media_group_tasks

        await adapter.disconnect()
        await asyncio.sleep(adapter.MEDIA_GROUP_WAIT_SECONDS + 0.05)

        assert adapter._media_group_events == {}
        assert adapter._media_group_tasks == {}
        adapter.handle_message.assert_not_awaited()


# ---------------------------------------------------------------------------
# TestSendVoice — outbound audio delivery
# ---------------------------------------------------------------------------

class TestSendVoice:
    """Tests for TelegramAdapter.send_voice() routing across audio formats."""

    @pytest.fixture()
    def connected_adapter(self, adapter):
        """Adapter with a mock bot attached."""
        bot = AsyncMock()
        adapter._bot = bot
        return adapter

    @pytest.mark.asyncio
    async def test_flac_falls_back_to_document(self, connected_adapter, tmp_path):
        """Telegram sendAudio does not accept FLAC — must fall back to sendDocument."""
        audio_file = tmp_path / "clip.flac"
        audio_file.write_bytes(b"fLaC" + b"\x00" * 32)

        mock_msg = MagicMock()
        mock_msg.message_id = 101
        connected_adapter._bot.send_voice = AsyncMock()
        connected_adapter._bot.send_audio = AsyncMock()
        connected_adapter._bot.send_document = AsyncMock(return_value=mock_msg)

        result = await connected_adapter.send_voice(
            chat_id="12345",
            audio_path=str(audio_file),
            caption="Audio",
        )

        assert result.success is True
        assert result.message_id == "101"
        connected_adapter._bot.send_document.assert_awaited_once()
        connected_adapter._bot.send_audio.assert_not_awaited()
        connected_adapter._bot.send_voice.assert_not_awaited()

    @pytest.mark.asyncio
    async def test_wav_falls_back_to_document(self, connected_adapter, tmp_path):
        """Telegram sendAudio does not accept WAV — must fall back to sendDocument."""
        audio_file = tmp_path / "clip.wav"
        audio_file.write_bytes(b"RIFF" + b"\x00" * 32)

        mock_msg = MagicMock()
        mock_msg.message_id = 102
        connected_adapter._bot.send_voice = AsyncMock()
        connected_adapter._bot.send_audio = AsyncMock()
        connected_adapter._bot.send_document = AsyncMock(return_value=mock_msg)

        result = await connected_adapter.send_voice(
            chat_id="12345",
            audio_path=str(audio_file),
        )

        assert result.success is True
        connected_adapter._bot.send_document.assert_awaited_once()
        connected_adapter._bot.send_audio.assert_not_awaited()

    @pytest.mark.asyncio
    async def test_mp3_routes_to_send_audio(self, connected_adapter, tmp_path):
        """MP3 is Telegram-sendAudio-compatible."""
        audio_file = tmp_path / "clip.mp3"
        audio_file.write_bytes(b"ID3" + b"\x00" * 32)

        mock_msg = MagicMock()
        mock_msg.message_id = 103
        connected_adapter._bot.send_voice = AsyncMock()
        connected_adapter._bot.send_audio = AsyncMock(return_value=mock_msg)
        connected_adapter._bot.send_document = AsyncMock()

        result = await connected_adapter.send_voice(
            chat_id="12345",
            audio_path=str(audio_file),
        )

        assert result.success is True
        connected_adapter._bot.send_audio.assert_awaited_once()
        connected_adapter._bot.send_document.assert_not_awaited()


# ---------------------------------------------------------------------------
# TestSendDocument — outbound file attachment delivery
# ---------------------------------------------------------------------------

class TestSendDocument:
    """Tests for TelegramAdapter.send_document() — sending files to users."""

    @pytest.fixture()
    def connected_adapter(self, adapter):
        """Adapter with a mock bot attached."""
        bot = AsyncMock()
        adapter._bot = bot
        return adapter

    @pytest.mark.asyncio
    async def test_send_document_success(self, connected_adapter, tmp_path):
        """A local file is sent via bot.send_document and returns success."""
        # Create a real temp file
        test_file = tmp_path / "report.pdf"
        test_file.write_bytes(b"%PDF-1.4 fake content")

        mock_msg = MagicMock()
        mock_msg.message_id = 99
        connected_adapter._bot.send_document = AsyncMock(return_value=mock_msg)

        result = await connected_adapter.send_document(
            chat_id="12345",
            file_path=str(test_file),
            caption="Here's the report",
        )

        assert result.success is True
        assert result.message_id == "99"
        connected_adapter._bot.send_document.assert_called_once()
        call_kwargs = connected_adapter._bot.send_document.call_args[1]
        assert call_kwargs["chat_id"] == 12345
        assert call_kwargs["filename"] == "report.pdf"
        assert call_kwargs["caption"] == "Here's the report"

    @pytest.mark.asyncio
    async def test_send_document_custom_filename(self, connected_adapter, tmp_path):
        """The file_name parameter overrides the basename for display."""
        test_file = tmp_path / "doc_abc123_ugly.csv"
        test_file.write_bytes(b"a,b,c\n1,2,3")

        mock_msg = MagicMock()
        mock_msg.message_id = 100
        connected_adapter._bot.send_document = AsyncMock(return_value=mock_msg)

        result = await connected_adapter.send_document(
            chat_id="12345",
            file_path=str(test_file),
            file_name="clean_data.csv",
        )

        assert result.success is True
        call_kwargs = connected_adapter._bot.send_document.call_args[1]
        assert call_kwargs["filename"] == "clean_data.csv"

    @pytest.mark.asyncio
    async def test_send_document_file_not_found(self, connected_adapter):
        """Missing file returns error without calling Telegram API."""
        result = await connected_adapter.send_document(
            chat_id="12345",
            file_path="/nonexistent/file.pdf",
        )

        assert result.success is False
        assert "not found" in result.error.lower()
        connected_adapter._bot.send_document.assert_not_called()

    @pytest.mark.asyncio
    async def test_send_document_workspace_path_has_docker_hint(self, connected_adapter):
        """Container-local-looking paths get a more actionable Docker hint."""
        result = await connected_adapter.send_document(
            chat_id="12345",
            file_path="/workspace/report.txt",
        )

        assert result.success is False
        assert "docker sandbox" in result.error.lower()
        assert "host-visible path" in result.error.lower()
        connected_adapter._bot.send_document.assert_not_called()

    @pytest.mark.asyncio
    async def test_send_document_outputs_path_has_docker_hint(self, connected_adapter):
        """Legacy /outputs paths also get the Docker hint."""
        result = await connected_adapter.send_document(
            chat_id="12345",
            file_path="/outputs/report.txt",
        )

        assert result.success is False
        assert "docker sandbox" in result.error.lower()
        assert "host-visible path" in result.error.lower()
        connected_adapter._bot.send_document.assert_not_called()

    @pytest.mark.asyncio
    async def test_send_document_not_connected(self, adapter):
        """If bot is None, returns not connected error."""
        result = await adapter.send_document(
            chat_id="12345",
            file_path="/some/file.pdf",
        )

        assert result.success is False
        assert "Not connected" in result.error

    @pytest.mark.asyncio
    async def test_send_document_caption_truncated(self, connected_adapter, tmp_path):
        """Captions longer than 1024 chars are truncated."""
        test_file = tmp_path / "data.json"
        test_file.write_bytes(b"{}")

        mock_msg = MagicMock()
        mock_msg.message_id = 101
        connected_adapter._bot.send_document = AsyncMock(return_value=mock_msg)

        long_caption = "x" * 2000
        await connected_adapter.send_document(
            chat_id="12345",
            file_path=str(test_file),
            caption=long_caption,
        )

        call_kwargs = connected_adapter._bot.send_document.call_args[1]
        assert len(call_kwargs["caption"]) == 1024

    @pytest.mark.asyncio
    async def test_send_document_api_error_falls_back(self, connected_adapter, tmp_path):
        """If Telegram API raises, falls back to base class text message."""
        test_file = tmp_path / "file.pdf"
        test_file.write_bytes(b"data")

        connected_adapter._bot.send_document = AsyncMock(
            side_effect=RuntimeError("Telegram API error")
        )

        # The base fallback calls self.send() which is also on _bot, so mock it
        # to avoid cascading errors.
        connected_adapter.send = AsyncMock(
            return_value=SendResult(success=True, message_id="fallback")
        )

        result = await connected_adapter.send_document(
            chat_id="12345",
            file_path=str(test_file),
        )

        # Should have fallen back to base class
        assert result.success is True
        assert result.message_id == "fallback"

    @pytest.mark.asyncio
    async def test_send_document_reply_to(self, connected_adapter, tmp_path):
        """reply_to parameter is forwarded as reply_to_message_id."""
        test_file = tmp_path / "spec.md"
        test_file.write_bytes(b"# Spec")

        mock_msg = MagicMock()
        mock_msg.message_id = 102
        connected_adapter._bot.send_document = AsyncMock(return_value=mock_msg)

        await connected_adapter.send_document(
            chat_id="12345",
            file_path=str(test_file),
            reply_to="50",
        )

        call_kwargs = connected_adapter._bot.send_document.call_args[1]
        assert call_kwargs["reply_to_message_id"] == 50

    @pytest.mark.asyncio
    async def test_send_document_thread_id(self, connected_adapter, tmp_path):
        """metadata thread_id is forwarded as message_thread_id (required for Telegram forum groups)."""
        test_file = tmp_path / "report.pdf"
        test_file.write_bytes(b"%PDF-1.4 data")

        mock_msg = MagicMock()
        mock_msg.message_id = 103
        connected_adapter._bot.send_document = AsyncMock(return_value=mock_msg)

        await connected_adapter.send_document(
            chat_id="12345",
            file_path=str(test_file),
            metadata={"thread_id": "789"},
        )

        call_kwargs = connected_adapter._bot.send_document.call_args[1]
        assert call_kwargs["message_thread_id"] == 789


class TestTelegramPhotoBatching:
    @pytest.mark.asyncio
    async def test_flush_photo_batch_does_not_drop_newer_scheduled_task(self, adapter):
        old_task = MagicMock()
        new_task = MagicMock()
        batch_key = "session:photo-burst"
        adapter._pending_photo_batch_tasks[batch_key] = new_task
        adapter._pending_photo_batches[batch_key] = MessageEvent(
            text="",
            message_type=MessageType.PHOTO,
            source=SimpleNamespace(channel_id="chat-1"),
            media_urls=["/tmp/a.jpg"],
            media_types=["image/jpeg"],
        )

        with (
            patch("plugins.platforms.telegram.adapter.asyncio.current_task", return_value=old_task),
            patch("plugins.platforms.telegram.adapter.asyncio.sleep", new=AsyncMock()),
        ):
            await adapter._flush_photo_batch(batch_key)

        assert adapter._pending_photo_batch_tasks[batch_key] is new_task

    @pytest.mark.asyncio
    async def test_disconnect_cancels_pending_photo_batch_tasks(self, adapter):
        task = MagicMock()
        task.done.return_value = False
        adapter._pending_photo_batch_tasks["session:photo-burst"] = task
        adapter._pending_photo_batches["session:photo-burst"] = MessageEvent(
            text="",
            message_type=MessageType.PHOTO,
            source=SimpleNamespace(channel_id="chat-1"),
        )
        adapter._app = MagicMock()
        adapter._app.updater.stop = AsyncMock()
        adapter._app.stop = AsyncMock()
        adapter._app.shutdown = AsyncMock()

        await adapter.disconnect()

        task.cancel.assert_called_once()
        assert adapter._pending_photo_batch_tasks == {}
        assert adapter._pending_photo_batches == {}


# ---------------------------------------------------------------------------
# TestSendVideo — outbound video delivery
# ---------------------------------------------------------------------------

class TestSendVideo:
    """Tests for TelegramAdapter.send_video() — sending videos to users."""

    @pytest.fixture()
    def connected_adapter(self, adapter):
        bot = AsyncMock()
        adapter._bot = bot
        return adapter

    @pytest.mark.asyncio
    async def test_send_video_success(self, connected_adapter, tmp_path):
        test_file = tmp_path / "clip.mp4"
        test_file.write_bytes(b"\x00\x00\x00\x1c" + b"ftyp" + b"\x00" * 100)

        mock_msg = MagicMock()
        mock_msg.message_id = 200
        connected_adapter._bot.send_video = AsyncMock(return_value=mock_msg)

        result = await connected_adapter.send_video(
            chat_id="12345",
            video_path=str(test_file),
            caption="Check this out",
        )

        assert result.success is True
        assert result.message_id == "200"
        connected_adapter._bot.send_video.assert_called_once()

    @pytest.mark.asyncio
    async def test_send_video_file_not_found(self, connected_adapter):
        result = await connected_adapter.send_video(
            chat_id="12345",
            video_path="/nonexistent/video.mp4",
        )

        assert result.success is False
        assert "not found" in result.error.lower()

    @pytest.mark.asyncio
    async def test_send_video_workspace_path_has_docker_hint(self, connected_adapter):
        result = await connected_adapter.send_video(
            chat_id="12345",
            video_path="/workspace/video.mp4",
        )

        assert result.success is False
        assert "docker sandbox" in result.error.lower()
        assert "host-visible path" in result.error.lower()

    @pytest.mark.asyncio
    async def test_send_video_not_connected(self, adapter):
        result = await adapter.send_video(
            chat_id="12345",
            video_path="/some/video.mp4",
        )

        assert result.success is False
        assert "Not connected" in result.error

    @pytest.mark.asyncio
    async def test_send_video_thread_id(self, connected_adapter, tmp_path):
        """metadata thread_id is forwarded as message_thread_id (required for Telegram forum groups)."""
        test_file = tmp_path / "clip.mp4"
        test_file.write_bytes(b"\x00\x00\x00\x1c" + b"ftyp" + b"\x00" * 100)

        mock_msg = MagicMock()
        mock_msg.message_id = 201
        connected_adapter._bot.send_video = AsyncMock(return_value=mock_msg)

        await connected_adapter.send_video(
            chat_id="12345",
            video_path=str(test_file),
            metadata={"thread_id": "789"},
        )

        call_kwargs = connected_adapter._bot.send_video.call_args[1]
        assert call_kwargs["message_thread_id"] == 789
