"""Unit tests for the Chatto platform adapter.

Covers:
  - Emoji shortcode conversion
  - Adapter instantiation and properties
  - Registration and requirements
  - Basic functionality with chattolib
  - Message sending and reactions
  - User lookup (with caching)
  - Presence and custom status

All network calls are mocked — no real HTTP or WebSocket connections.
"""

import asyncio
import os
import sys
import tempfile
from unittest.mock import AsyncMock, MagicMock, patch, call
from collections import OrderedDict

import pytest
import pytest_asyncio

# Import chattolib types for tests - using vendored chattolib from adapter

# -- Path setup --
# The Hermes agent itself is not a dependency of this plugin; point HERMES_ROOT
# at a checkout to run these tests outside a deployed agent.
PLUGIN_ROOT = os.path.abspath(os.path.dirname(__file__))
sys.path.insert(0, PLUGIN_ROOT)
sys.path.insert(0, os.environ.get("HERMES_ROOT", "/opt/hermes"))
sys.path.insert(0, "/root/.hermes/plugins/platforms/chatto")

from adapter import (
    ChattoAdapter,
    hermes_check_fn as check_requirements,
    hermes_validate_config as validate_config,
    register,
)
from chattolib.realtime_types import ReactionPayload
from chattolib.types import (
    AssetUrl,
    Message,
    MessageAttachment,
    Room,
    RoomKind,
    User,
)
from platform_config import ChattoConstants
from gateway.config import PlatformConfig
from gateway.platforms.base import (
    CachedMedia,
    MessageEvent,
    MessageType,
    SendResult,
    get_inbound_media_max_bytes,
)

_EMOJI_TO_SHORTCODE = ChattoConstants.EMOJI_TO_SHORTCODE
_MAX_MESSAGE_LENGTH = ChattoConstants.MAX_MESSAGE_LENGTH
_SEEN_CAP = ChattoConstants.SEEN_CAP


# -- Helpers --

class _MockPluginContext:
    """Minimal mock for the plugin registration context."""

    def __init__(self):
        self.registered_names = []
        self.registered_kwargs = None

    def register_platform(self, **kwargs):
        from gateway.platform_registry import platform_registry, PlatformEntry
        entry = PlatformEntry(
            name=kwargs["name"],
            label=kwargs.get("label", kwargs["name"]),
            adapter_factory=kwargs.get("adapter_factory"),
            check_fn=kwargs.get("check_fn"),
            validate_config=kwargs.get("validate_config"),
            is_connected=kwargs.get("is_connected"),
            required_env=kwargs.get("required_env", []),
            source="plugin",
        )
        platform_registry.register(entry)
        self.registered_names.append(kwargs["name"])
        self.registered_kwargs = kwargs


def _ensure_chatto_registered():
    """Register the platform so Platform(PLATFORM_NAME) resolves."""
    from gateway.platform_registry import platform_registry
    if not platform_registry.is_registered(ChattoConstants.PLATFORM_NAME):
        ctx = _MockPluginContext()
        register(ctx)


_CHATTO_ENV_KEYS = [
    "CHATTO_BASE_URL", "CHATTO_LOGIN", "CHATTO_PASSWORD",
    "CHATTO_CHANNELS", "CHATTO_HOME_CHANNEL",
    "CHATTO_REQUIRE_MENTION", "CHATTO_ALLOWED_USERS",
    "CHATTO_ALLOW_ALL_USERS", "CHATTO_AUTO_THREAD",
    "CHATTO_REACTIONS",
]


def _clear_chatto_env(monkeypatch=None):
    """Remove all CHATTO_* env vars so tests start from a clean slate."""
    for key in _CHATTO_ENV_KEYS:
        if monkeypatch is not None:
            monkeypatch.delenv(key, raising=False)
        else:
            os.environ.pop(key, None)


def _make_config(**extra_overrides):
    """Create a minimal PlatformConfig for testing."""
    _ensure_chatto_registered()
    extra = {"base_url": "https://chat.example.com", "channels": ["room1"]}
    extra.update(extra_overrides)
    return PlatformConfig(enabled=True, extra=extra)


def _make_room(room_id, name, kind):
    """Build a real chattolib Room, as the client would return."""
    return Room(id=room_id, name=name, kind=kind, description="",
                archived=False, group_id="", universal=kind != RoomKind.DM)


def _make_user(user_id, login):
    """Build a real chattolib User, as the member directory would return."""
    return User(id=user_id, login=login, display_name=login.replace("_", " ").title())


def _make_attachment(filename, content_type, url="https://cdn.example.com/a"):
    """Build a real MessageAttachment carrying a (pre-signed) asset URL."""
    return MessageAttachment(
        id="asset-" + filename,
        filename=filename,
        content_type=content_type,
        asset_url=AssetUrl(url=url),
    )


def _make_message(body="hi", attachments=None, message_id="msg-1", room_id="room-1"):
    """Build a real chattolib Message, as fetch_message() would return."""
    return Message(
        id=message_id,
        room_id=room_id,
        created_at=None,
        actor_id="user-1",
        body=body,
        attachments=list(attachments or []),
    )


def _make_posted_payload(room_id="room-1", message_event_id="msg-1"):
    """A message_posted payload whose fetch_message() the caller stubs."""
    payload = MagicMock()
    payload.room_id = room_id
    payload.message_event_id = message_event_id
    payload.thread_root_event_id = None
    return payload


def _cached(path, media_type, kind):
    """The CachedMedia that cache_media_bytes() would return for an attachment."""
    return CachedMedia(path=path, media_type=media_type, kind=kind, display_name="f")


def _make_adapter(**extra_overrides):
    """Create a ChattoAdapter with mocked config."""
    _clear_chatto_env()
    cfg = _make_config(**extra_overrides)
    adapter = ChattoAdapter(cfg)
    adapter._chatto_client = MagicMock()
    adapter._token = "test-token"
    adapter._user_id = "bot-user-id"
    adapter._user_login = "hermes_bot"
    adapter._user_display = "Hermes Bot"
    return adapter


# -- Emoji shortcode conversion --

class TestEmojiShortcode:
    """Test emoji to shortcode mapping."""

    def test_emoji_to_shortcode_exists(self):
        assert isinstance(_EMOJI_TO_SHORTCODE, dict)
        assert len(_EMOJI_TO_SHORTCODE) > 0

    def test_emoji_to_shortcode_common_emojis(self):
        assert _EMOJI_TO_SHORTCODE.get("👍") == "thumbsup"
        assert _EMOJI_TO_SHORTCODE.get("👎") == "thumbsdown"
        assert _EMOJI_TO_SHORTCODE.get("❤️") == "heart"
        assert _EMOJI_TO_SHORTCODE.get("❤") == "heart"
        assert _EMOJI_TO_SHORTCODE.get("✅") == "white_check_mark"
        assert _EMOJI_TO_SHORTCODE.get("❌") == "x"


# -- Adapter instantiation and properties --

class TestAdapterInstantiation:
    """Test ChattoAdapter creation and basic properties."""

    def test_adapter_creation(self):
        cfg = _make_config()
        adapter = ChattoAdapter(cfg)
        assert adapter is not None
        # Platform members created dynamically from a plugin name carry the
        # name upper-cased; the registered identity is the value.
        assert adapter.platform.value == ChattoConstants.PLATFORM_NAME

    def test_adapter_max_message_length(self):
        """The framework chunks via max_message_length_for_chat(), which reads
        the adapter-scalar MAX_MESSAGE_LENGTH and silently falls back to 4096
        when it is missing."""
        cfg = _make_config()
        adapter = ChattoAdapter(cfg)
        assert adapter.MAX_MESSAGE_LENGTH == _MAX_MESSAGE_LENGTH
        assert adapter.max_message_length_for_chat("room-1") == _MAX_MESSAGE_LENGTH

    def test_adapter_splits_long_messages(self):
        cfg = _make_config()
        adapter = ChattoAdapter(cfg)
        assert adapter.splits_long_messages is True

    def test_adapter_threads_enabled_by_default(self):
        """There is no capability flag for threads — Chatto threading is driven
        by the auto_thread setting, which defaults to on."""
        cfg = _make_config()
        adapter = ChattoAdapter(cfg)
        assert adapter.chatto_config.auto_thread.value is True


# -- Registration and requirements --

class TestRegistration:
    """Test plugin registration."""

    def test_register_called(self):
        ctx = _MockPluginContext()
        register(ctx)
        assert ChattoConstants.PLATFORM_NAME in ctx.registered_names
        assert ctx.registered_kwargs["name"] == ChattoConstants.PLATFORM_NAME
        assert ctx.registered_kwargs["label"] == ChattoConstants.PLATFORM_LABEL
        assert ctx.registered_kwargs["max_message_length"] == _MAX_MESSAGE_LENGTH

    def test_check_requirements(self):
        assert check_requirements() is True

    def test_check_requirements_missing(self):
        with patch("builtins.__import__", side_effect=ImportError("no chattolib")):
            assert check_requirements() is False

    def test_validate_config(self):
        _clear_chatto_env()
        os.environ["CHATTO_BASE_URL"] = "https://chat.test"
        os.environ["CHATTO_LOGIN"] = "user"
        os.environ["CHATTO_PASSWORD"] = "pass"
        cfg = PlatformConfig(enabled=True, extra={"base_url": "https://chat.test"})
        assert validate_config(cfg) is True
        _clear_chatto_env()


# -- Send functionality --

class TestSend:
    """Test message sending functionality."""

    @pytest_asyncio.fixture
    def adapter(self):
        _clear_chatto_env()
        cfg = _make_config()
        adapter = ChattoAdapter(cfg)
        adapter._chatto_client = MagicMock()
        adapter._chatto_client.post_message = AsyncMock()
        adapter._token = "test-token"
        adapter._user_id = "bot-user-id"
        return adapter

    async def test_send_calls_post_message(self, adapter):
        mock_msg = MagicMock()
        mock_msg.id = "msg-123"
        adapter._chatto_client.post_message.return_value = mock_msg
        result = await adapter.send("room-1", "Hello world")
        assert result.success is True
        assert result.message_id == "msg-123"
        adapter._chatto_client.post_message.assert_called_once()

    async def test_send_with_thread(self, adapter):
        mock_msg = MagicMock()
        mock_msg.id = "msg-456"
        adapter._chatto_client.post_message.return_value = mock_msg
        result = await adapter.send("room-1", "Hello", reply_to="thread-123")
        assert result.success is True
        call_kwargs = adapter._chatto_client.post_message.call_args.kwargs
        assert call_kwargs["thread_root_event_id"] == "thread-123"


# -- Reactions --

class TestReactions:
    """Test reaction functionality."""

    @pytest_asyncio.fixture
    def adapter(self):
        _clear_chatto_env()
        cfg = _make_config()
        adapter = ChattoAdapter(cfg)
        adapter._chatto_client = MagicMock()
        adapter._chatto_client.add_reaction = AsyncMock()
        adapter._chatto_client.remove_reaction = AsyncMock()
        adapter._token = "test-token"
        return adapter

    async def test_send_reaction(self, adapter):
        await adapter.add_reaction("room-1", "msg-1", "👍")
        adapter._chatto_client.add_reaction.assert_called_once()

    async def test_remove_reaction(self, adapter):
        await adapter.remove_reaction("room-1", "msg-1", "👍")
        adapter._chatto_client.remove_reaction.assert_called_once()

    async def test_on_processing_start_adds_eyes_reaction(self, adapter):
        """on_processing_start should call add_reaction with 👀."""
        event = MagicMock()
        event.message_id = "msg-1"
        event.source.chat_id = "room-1"
        await adapter.on_processing_start(event)
        adapter._chatto_client.add_reaction.assert_called_once()
        call_kwargs = adapter._chatto_client.add_reaction.call_args.kwargs
        assert call_kwargs["message_event_id"] == "msg-1"
        assert call_kwargs["room_id"] == "room-1"
        assert call_kwargs["emoji"] == "eyes"

    async def test_on_processing_start_empty_message_id(self, adapter):
        """on_processing_start should skip reaction when message_id is empty."""
        event = MagicMock()
        event.message_id = None
        event.source.chat_id = "room-1"
        await adapter.on_processing_start(event)
        adapter._chatto_client.add_reaction.assert_not_called()

    async def test_on_processing_start_reactions_disabled(self, adapter):
        """on_processing_start should skip when reactions config is False."""
        adapter.chatto_config.reactions.value = False
        event = MagicMock()
        event.message_id = "msg-1"
        event.source.chat_id = "room-1"
        await adapter.on_processing_start(event)
        adapter._chatto_client.add_reaction.assert_not_called()


# -- Edit and Delete Messages --

class TestMessageEditing:
    """Test message editing and deletion."""

    @pytest_asyncio.fixture
    def adapter(self):
        _clear_chatto_env()
        cfg = _make_config()
        adapter = ChattoAdapter(cfg)
        adapter._chatto_client = MagicMock()
        adapter._chatto_client.update_message = AsyncMock()
        adapter._chatto_client.delete_message = AsyncMock(return_value=True)
        adapter._token = "test-token"
        return adapter

    async def test_edit_message(self, adapter):
        result = await adapter.edit_message("room-1", "msg-1", "New content")
        assert result.success is True
        adapter._chatto_client.update_message.assert_called_once()
        call_kwargs = adapter._chatto_client.update_message.call_args.kwargs
        assert call_kwargs["room_id"] == "room-1"
        assert call_kwargs["event_id"] == "msg-1"
        assert call_kwargs["body"] == "New content"

    async def test_edit_message_marks_own_edit_seen(self, adapter):
        """The edit echoes back as message_edited — it must not look inbound."""
        mock_msg = MagicMock()
        mock_msg.id = "msg-1"
        adapter._chatto_client.update_message.return_value = mock_msg
        await adapter.edit_message("room-1", "msg-1", "New content")
        assert adapter._is_seen("msg-1") is True

    async def test_edit_message_too_long_refuses(self, adapter):
        """Overlong content must fall back to send() (which splits), not be
        silently truncated into a lossy edit."""
        result = await adapter.edit_message(
            "room-1", "msg-1", "x" * (_MAX_MESSAGE_LENGTH + 1),
        )
        assert result.success is False
        adapter._chatto_client.update_message.assert_not_called()

    async def test_edit_message_empty_content(self, adapter):
        result = await adapter.edit_message("room-1", "msg-1", "")
        assert result.success is False
        adapter._chatto_client.update_message.assert_not_called()

    async def test_edit_message_error_is_retryable(self, adapter):
        from chattolib.exceptions import ChattoError

        adapter._chatto_client.update_message.side_effect = ChattoError("boom")
        result = await adapter.edit_message("room-1", "msg-1", "New content")
        assert result.success is False
        assert result.retryable is True

    async def test_delete_message(self, adapter):
        result = await adapter.delete_message("room-1", "msg-1")
        assert result is True
        adapter._chatto_client.delete_message.assert_called_once()
        call_kwargs = adapter._chatto_client.delete_message.call_args.kwargs
        assert call_kwargs["room_id"] == "room-1"
        assert call_kwargs["event_id"] == "msg-1"

    async def test_delete_message_missing_ids(self, adapter):
        assert await adapter.delete_message("", "msg-1") is False
        assert await adapter.delete_message("room-1", "") is False
        adapter._chatto_client.delete_message.assert_not_called()

    async def test_delete_message_error_returns_false(self, adapter):
        from chattolib.exceptions import ChattoError

        adapter._chatto_client.delete_message.side_effect = ChattoError("nope")
        assert await adapter.delete_message("room-1", "msg-1") is False


# -- Outgoing text formatting --

class TestFormatMessage:
    """format_message() only fixes what renders wrong in Chatto."""

    def test_normalises_crlf(self):
        adapter = _make_adapter()
        assert adapter.format_message("a\r\nb\rc") == "a\nb\nc"

    def test_collapses_excess_blank_lines(self):
        adapter = _make_adapter()
        assert adapter.format_message("a\n\n\n\n\n\nb") == "a\n\n\nb"

    def test_leaves_markdown_untouched(self):
        adapter = _make_adapter()
        text = "**bold** `code`\n\n```py\nx = 1\n```\n- item"
        assert adapter.format_message(text) == text

    def test_empty_content(self):
        adapter = _make_adapter()
        assert adapter.format_message("") == ""


# -- Handoff threads --

class TestHandoffThread:
    """create_handoff_thread() anchors a handoff on a seed message."""

    @pytest_asyncio.fixture
    def adapter(self):
        adapter = _make_adapter()
        adapter._chatto_client.post_message = AsyncMock()
        adapter._chatto_client.follow_thread = AsyncMock()
        return adapter

    async def test_returns_seed_message_id(self, adapter):
        mock_msg = MagicMock()
        mock_msg.id = "seed-1"
        adapter._chatto_client.post_message.return_value = mock_msg
        adapter._room_kinds["room-1"] = RoomKind.CHANNEL

        result = await adapter.create_handoff_thread("room-1", "Refactor run")

        assert result == "seed-1"
        assert adapter._chatto_client.post_message.call_args.kwargs["room_id"] == "room-1"
        adapter._chatto_client.follow_thread.assert_called_once_with("room-1", "seed-1")
        # Our own seed must not come back in as inbound traffic.
        assert adapter._is_seen("seed-1") is True

    async def test_dm_has_no_threads(self, adapter):
        adapter._room_kinds["dm-1"] = RoomKind.DM
        assert await adapter.create_handoff_thread("dm-1", "x") is None
        adapter._chatto_client.post_message.assert_not_called()

    async def test_seed_post_failure(self, adapter):
        adapter._chatto_client.post_message.side_effect = RuntimeError("down")
        adapter._room_kinds["room-1"] = RoomKind.CHANNEL
        assert await adapter.create_handoff_thread("room-1", "x") is None


# -- Native file / video / audio delivery --

class TestNativeSends:
    """send_document/_video/_voice upload instead of apologising in text."""

    @pytest_asyncio.fixture
    def adapter(self):
        adapter = _make_adapter()
        adapter._chatto_client.post_message = AsyncMock()
        adapter._upload_asset = AsyncMock(return_value="asset-1")
        adapter.validate_media_delivery_path = MagicMock(side_effect=lambda p: p)
        mock_msg = MagicMock()
        mock_msg.id = "msg-1"
        adapter._chatto_client.post_message.return_value = mock_msg
        return adapter

    @pytest.mark.parametrize(
        "method,arg_name",
        [
            ("send_document", "file_path"),
            ("send_video", "video_path"),
            ("send_voice", "audio_path"),
            ("send_image_file", "image_path"),
        ],
    )
    async def test_uploads_and_attaches(self, adapter, method, arg_name):
        result = await getattr(adapter, method)(
            "room-1", **{arg_name: "/tmp/thing.bin"}, caption="here you go",
        )
        assert result.success is True
        adapter._upload_asset.assert_called_once_with("room-1", "/tmp/thing.bin")
        call_kwargs = adapter._chatto_client.post_message.call_args.kwargs
        assert call_kwargs["attachment_asset_ids"] == ["asset-1"]
        assert call_kwargs["body"] == "here you go"

    async def test_unsafe_path_falls_back_to_notice(self, adapter):
        adapter.validate_media_delivery_path = MagicMock(return_value=None)
        adapter.send = AsyncMock(return_value=SendResult(success=True))
        await adapter.send_document("room-1", "/etc/shadow")
        adapter._upload_asset.assert_not_called()
        # Never echo the host path into chat.
        sent_text = adapter.send.call_args.args[1]
        assert "/etc/shadow" not in sent_text

    async def test_upload_failure_falls_back_to_notice(self, adapter):
        adapter._upload_asset = AsyncMock(return_value=None)
        adapter.send = AsyncMock(return_value=SendResult(success=True))
        await adapter.send_video("room-1", "/tmp/clip.mp4", caption="a clip")
        sent_text = adapter.send.call_args.args[1]
        assert sent_text.startswith("a clip\n")
        assert "/tmp/clip.mp4" not in sent_text


# -- Reaction event forwarding --

class TestReactionForwarding:
    """Human reactions reach the gateway's reaction hook surface."""

    @pytest_asyncio.fixture
    def adapter(self):
        adapter = _make_adapter()
        adapter.me = _make_user("bot-user-id", "hermes_bot")
        return adapter

    def _event(self, kind, actor_id="human-1"):
        event = MagicMock()
        event.id = "evt-1"
        event.kind = kind
        event.actor_id = actor_id
        payload = ReactionPayload(
            room_id="room-1", message_event_id="msg-1", emoji="thumbsup",
        )
        # RealtimeEvent.get() only yields the payload for its own kind.
        event.get = MagicMock(side_effect=lambda k: payload if k == kind else None)
        return event

    async def test_forwards_added_reaction(self, adapter):
        handler = AsyncMock()
        adapter.set_reaction_handler(handler)

        await adapter._handle_realtime_event(self._event("reaction_added"))

        handler.assert_called_once()
        payload = handler.call_args.args[0]
        assert payload["event_name"] == "reaction:added"
        assert payload["reaction"] == "thumbsup"
        assert payload["channel_id"] == "room-1"
        assert payload["message_ts"] == "msg-1"
        assert payload["user_id"] == "human-1"
        assert payload["item_type"] == "message"

    async def test_forwards_removed_reaction(self, adapter):
        handler = AsyncMock()
        adapter.set_reaction_handler(handler)
        await adapter._handle_realtime_event(self._event("reaction_removed"))
        assert handler.call_args.args[0]["event_name"] == "reaction:removed"

    async def test_ignores_own_lifecycle_reactions(self, adapter):
        """👀/✅/❌ are ours — forwarding them would feed the agent its own markers."""
        handler = AsyncMock()
        adapter.set_reaction_handler(handler)
        await adapter._handle_realtime_event(
            self._event("reaction_added", actor_id="bot-user-id"),
        )
        handler.assert_not_called()

    async def test_no_handler_registered_is_harmless(self, adapter):
        await adapter._handle_realtime_event(self._event("reaction_added"))

    async def test_handler_exception_does_not_propagate(self, adapter):
        adapter.set_reaction_handler(AsyncMock(side_effect=RuntimeError("hook boom")))
        await adapter._handle_realtime_event(self._event("reaction_added"))


# -- Inbound attachments --

class TestInboundAttachments:
    """Messages carrying files must reach the agent, body or not."""

    @pytest_asyncio.fixture
    def adapter(self):
        adapter = _make_adapter()
        adapter.chatto_config.allow_all_users.value = True
        adapter.me = _make_user("bot-user-id", "hermes_bot")
        adapter._room_kinds["room-1"] = RoomKind.DM
        adapter._user_cache["user-1"] = _make_user("user-1", "alice")
        adapter.handle_message = AsyncMock()
        adapter._download_attachment_bytes = AsyncMock(return_value=b"\x89PNG\r\n\x1a\nrest")
        return adapter

    async def test_image_attachment_becomes_media_url(self, adapter):
        payload = _make_posted_payload()
        adapter._chatto_client.get_room = AsyncMock()
        message = _make_message(
            body="look at this",
            attachments=[_make_attachment("shot.png", "image/png")],
        )
        payload.fetch_message = AsyncMock(return_value=message)

        with patch("adapter.cache_media_bytes", return_value=_cached("/cache/shot.png", "image/png", "image")):
            await adapter._dispatch_message_posted(payload)

        event = adapter.handle_message.call_args.args[0]
        assert event.media_urls == ["/cache/shot.png"]
        assert event.media_types == ["image/png"]
        assert event.message_type == MessageType.PHOTO

    async def test_attachment_only_message_is_not_dropped(self, adapter):
        """The empty-body early return is what silently ate file uploads."""
        payload = _make_posted_payload()
        message = _make_message(
            body="", attachments=[_make_attachment("report.pdf", "application/pdf")],
        )
        payload.fetch_message = AsyncMock(return_value=message)

        with patch("adapter.cache_media_bytes", return_value=_cached("/cache/report.pdf", "application/pdf", "document")):
            await adapter._dispatch_message_posted(payload)

        adapter.handle_message.assert_called_once()
        event = adapter.handle_message.call_args.args[0]
        assert event.message_type == MessageType.DOCUMENT
        assert event.media_urls == ["/cache/report.pdf"]

    async def test_empty_message_without_attachments_is_dropped(self, adapter):
        payload = _make_posted_payload()
        payload.fetch_message = AsyncMock(return_value=_make_message(body=""))
        await adapter._dispatch_message_posted(payload)
        adapter.handle_message.assert_not_called()

    async def test_download_failure_still_delivers_the_text(self, adapter):
        adapter._download_attachment_bytes = AsyncMock(side_effect=RuntimeError("404"))
        payload = _make_posted_payload()
        payload.fetch_message = AsyncMock(return_value=_make_message(
            body="see attached", attachments=[_make_attachment("a.png", "image/png")],
        ))

        await adapter._dispatch_message_posted(payload)

        event = adapter.handle_message.call_args.args[0]
        assert event.text == "see attached"
        assert event.media_urls == []
        assert event.message_type == MessageType.TEXT

    async def test_attachment_without_asset_url_is_skipped(self, adapter):
        """Videos are announced before transcoding finishes."""
        payload = _make_posted_payload()
        att = _make_attachment("clip.mp4", "video/mp4")
        att.asset_url = None
        payload.fetch_message = AsyncMock(return_value=_make_message(
            body="clip", attachments=[att],
        ))

        await adapter._dispatch_message_posted(payload)

        event = adapter.handle_message.call_args.args[0]
        assert event.media_urls == []
        adapter._download_attachment_bytes.assert_not_called()

    async def test_document_wins_over_image(self, adapter):
        """Mixed batches classify as DOCUMENT — that gates context injection."""
        assert adapter._message_type_for_media_kinds(["image", "document"]) is MessageType.DOCUMENT
        assert adapter._message_type_for_media_kinds(["image"]) is MessageType.PHOTO
        assert adapter._message_type_for_media_kinds(["video"]) is MessageType.VIDEO
        assert adapter._message_type_for_media_kinds(["audio"]) is MessageType.AUDIO
        assert adapter._message_type_for_media_kinds([]) is MessageType.TEXT

    async def test_oversized_attachment_is_rejected(self, adapter):
        """The gateway media cap must bound what a hostile upload can buffer."""
        import httpx

        big = get_inbound_media_max_bytes() + 1
        transport = httpx.MockTransport(lambda request: httpx.Response(
            200, headers={"content-length": str(big)}, content=b"x",
        ))
        real_adapter = _make_adapter()
        real_client_cls = httpx.AsyncClient

        with patch("httpx.AsyncClient", lambda **kw: real_client_cls(transport=transport)):
            with pytest.raises(ValueError):
                await real_adapter._download_attachment_bytes("https://chat.example.com/a.png")


# NOTE: there are deliberately no tests for get_user(), set_presence() or
# set_custom_status() on the adapter. Those are not adapter responsibilities —
# callers use the chattolib client directly, which exposes them (client.get_user,
# client.update_presence, client.update_custom_status). The adapter only touches
# presence in connect()/disconnect().


# -- Room operations --

class TestRoomOperations:
    """Test room creation and DM initiation."""

    @pytest_asyncio.fixture
    def adapter(self):
        _clear_chatto_env()
        cfg = _make_config()
        adapter = ChattoAdapter(cfg)
        adapter._chatto_client = MagicMock()
        # AsyncMock, not MagicMock: the adapter awaits these, and awaiting a
        # plain MagicMock raises TypeError, which create_room()/start_dm()
        # swallow into a None return.
        adapter._chatto_client.create_room = AsyncMock()
        adapter._chatto_client.start_dm = AsyncMock()
        adapter._token = "test-token"
        adapter._room_names = {}
        adapter._room_kinds = {}
        return adapter

    async def test_create_room(self, adapter):
        adapter._chatto_client.create_room.return_value = _make_room(
            "room-123", "Test Room", RoomKind.CHANNEL,
        )
        result = await adapter.create_room("Test Room", "A test room")
        assert result == "room-123"
        adapter._chatto_client.create_room.assert_called_once()
        assert adapter._room_names["room-123"] == "Test Room"

    async def test_start_dm(self, adapter):
        adapter._chatto_client.start_dm.return_value = _make_room(
            "dm-123", "DM with user", RoomKind.DM,
        )
        result = await adapter.start_dm("user-123")
        assert result == "dm-123"
        adapter._chatto_client.start_dm.assert_called_once()
        assert adapter._room_kinds["dm-123"] == RoomKind.DM


# -- Constants --

class TestConstants:
    """Test that constants are properly defined."""

    def test_max_message_length(self):
        assert _MAX_MESSAGE_LENGTH == 10000

    def test_seen_cap(self):
        assert _SEEN_CAP == 500
