"""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
  - DM room management (/join, /leave)

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

import asyncio
import os
import sys
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock, MagicMock, patch

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"))

# Importing ``adapter`` puts the vendored dependencies on sys.path as a side
# effect, but import sorting may legally move that import after the chattolib
# and gateway ones — so bootstrap the vendor paths explicitly instead.
from vendor_path import setup_vendor_path

setup_vendor_path()

from chattolib.client import ChattoClient
from chattolib.realtime_types import ReactionPayload
from chattolib.types import (
    Asset,
    AssetUpload,
    AssetUrl,
    DirectoryMember,
    Message,
    MessageAttachment,
    PresenceStatus,
    Room,
    RoomKind,
    RoomViewerState,
    RoomWithViewerState,
    User,
)
from gateway.config import PlatformConfig
from gateway.platforms.base import (
    BasePlatformAdapter,
    CachedMedia,
    MessageType,
    ProcessingOutcome,
    SendResult,
    get_inbound_media_max_bytes,
)

from adapter import (
    ChattoAdapter,
    HermesChatType,
    _capabilities,
    chat_type_for_room_kind,
    register,
)
from adapter import (
    hermes_check_fn as check_requirements,
)
from adapter import (
    hermes_validate_config as validate_config,
)
from platform_config import ChattoConstants

_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 PlatformEntry, platform_registry

        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_TOKEN",
    "CHATTO_HOME_CHANNEL",
    "CHATTO_REQUIRE_MENTION",
    "CHATTO_ALLOWED_USERS",
    "CHATTO_ALLOW_ALL_USERS",
    "CHATTO_AUTO_THREAD",
    "CHATTO_REACTIONS",
    "CHATTO_RESPOND_ROOMS",
]


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"}
    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",
    created_at=None,
    updated_at=None,
):
    """Build a real chattolib Message, as fetch_message() would return."""
    return Message(
        id=message_id,
        room_id=room_id,
        created_at=created_at,
        actor_id="user-1",
        body=body,
        attachments=list(attachments or []),
        updated_at=updated_at,
    )


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()
    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"
        # Sent when a message addresses someone else — without the mapping the
        # raw emoji would go out as a shortcode and the server would reject it.
        assert _EMOJI_TO_SHORTCODE.get("🫥") == "dotted_line_face"


# -- 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_platform_hint_advertises_media_sending(self):
        """This hint is the only thing telling the model the channel can carry
        files — without it the agent has no idea it can deliver an image."""
        ctx = _MockPluginContext()
        register(ctx)
        assert "MEDIA:/absolute/path/to/file" in ctx.registered_kwargs["platform_hint"]

    def test_platform_hint_rules_out_markdown_for_local_files(self):
        """gateway's extract_images only matches https?:// — markdown pointing at
        a local file is never extracted and lands in the chat as literal text."""
        ctx = _MockPluginContext()
        register(ctx)
        hint = ctx.registered_kwargs["platform_hint"]
        assert "Do NOT use markdown image syntax for local files" in hint

    def test_startup_logs_the_capabilities(self, caplog):
        with caplog.at_level("INFO"):
            register(_MockPluginContext())
        logged = "\n".join(caplog.messages)
        assert "images" in logged
        assert "reactions" in logged

    def test_capabilities_skips_methods_we_do_not_override(self):
        """An inherited base fallback is not a capability — claiming it would
        promise the user something the adapter cannot actually do."""
        assert "video" in _capabilities()
        with patch.object(ChattoAdapter, "send_video", BasePlatformAdapter.send_video):
            assert "video" not in _capabilities()

    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()
        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"

    async def test_send_raw_response_stays_dict_shaped(self, adapter):
        # The cron scheduler calls .get() on SendResult.raw_response when a job
        # targets a thread; a chattolib Message there crashes delivery
        # bookkeeping after the send and duplicates the message standalone.
        adapter._chatto_client.post_message.return_value = _make_message(
            body="Hello world", message_id="msg-789"
        )
        result = await adapter.send("room-1", "Hello world")
        assert result.success is True
        assert not result.raw_response or isinstance(result.raw_response, dict)


# -- 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()
        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 TestUploadAsset:
    """Drives the real _upload_asset against real chattolib result types.

    The send_* tests stub _upload_asset out, so a wrong field name on the
    chattolib response was invisible to them until it hit a live server.
    """

    @pytest_asyncio.fixture
    def adapter(self, tmp_path):
        adapter = _make_adapter()
        self.path = tmp_path / "horse.jpg"
        self.path.write_bytes(b"\xff\xd8\xff" + b"x" * 100)
        upload = AssetUpload(upload_id="up-1", room_id="room-1")
        adapter._chatto_client.create_upload = AsyncMock(return_value=upload)
        adapter._chatto_client.upload_chunk = AsyncMock(return_value=upload)
        adapter._chatto_client.complete_upload = AsyncMock(
            return_value=(
                upload,
                Asset(
                    id="asset-9",
                    filename="horse.jpg",
                    content_type="image/jpeg",
                    size=103,
                ),
            )
        )
        return adapter

    async def test_returns_the_asset_id(self, adapter):
        assert await adapter._upload_asset("room-1", str(self.path)) == "asset-9"

    async def test_chunks_go_to_the_upload_id_from_create_upload(self, adapter):
        """AssetUpload calls it upload_id, not id — reading the wrong field made
        every upload fail with 'CreateUpload returned no upload ID'."""
        await adapter._upload_asset("room-1", str(self.path))
        assert (
            adapter._chatto_client.upload_chunk.await_args.kwargs["upload_id"] == "up-1"
        )

    async def test_missing_upload_id_is_reported(self, adapter):
        adapter._chatto_client.create_upload = AsyncMock(
            return_value=AssetUpload(upload_id="", room_id="room-1")
        )
        assert await adapter._upload_asset("room-1", str(self.path)) is None


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


# -- Batched image delivery --


class TestSendMultipleImages:
    """A batch of images belongs in ONE Chatto message."""

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

    async def test_bundles_into_single_message(self, adapter):
        await adapter.send_multiple_images(
            "room-1",
            [("/tmp/a.png", "first"), ("/tmp/b.png", "second")],
        )
        adapter._chatto_client.post_message.assert_called_once()
        call_kwargs = adapter._chatto_client.post_message.call_args.kwargs
        assert call_kwargs["attachment_asset_ids"] == ["asset-1", "asset-2"]
        assert call_kwargs["body"] == "first\nsecond"

    async def test_single_image_uses_base_path(self, adapter):
        """One image is not a batch — leave it to the base implementation.

        Also pins the send_image_file signature: the base class calls it with
        ``image_path=`` as a keyword, so a renamed parameter degrades every
        native image send to a text notice.
        """
        adapter.send_image_file = AsyncMock(return_value=SendResult(success=True))
        await adapter.send_multiple_images("room-1", [("file:///tmp/a.png", "only")])
        adapter._upload_asset.assert_not_called()
        adapter.send_image_file.assert_called_once()
        assert adapter.send_image_file.call_args.kwargs["image_path"] == "/tmp/a.png"

    async def test_partial_upload_failure_still_sends_the_rest(self, adapter):
        adapter._upload_asset = AsyncMock(side_effect=["asset-1", None])
        await adapter.send_multiple_images(
            "room-1",
            [("/tmp/a.png", "first"), ("/tmp/b.png", "second")],
        )
        call_kwargs = adapter._chatto_client.post_message.call_args.kwargs
        assert call_kwargs["attachment_asset_ids"] == ["asset-1"]

    async def test_file_uri_is_unquoted(self, adapter):
        await adapter.send_multiple_images(
            "room-1",
            [("file:///tmp/a%20b.png", ""), ("/tmp/c.png", "")],
        )
        first_path = adapter._upload_asset.call_args_list[0].args[1]
        assert first_path == "/tmp/a b.png"


# -- 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"))


# -- chat_type mapping --


class TestChatTypeMapping:
    """RoomKind -> the gateway's chat_type vocabulary."""

    def test_maps_known_kinds(self):
        assert chat_type_for_room_kind(RoomKind.DM) is HermesChatType.DM
        assert chat_type_for_room_kind(RoomKind.CHANNEL) is HermesChatType.CHANNEL

    def test_unknown_kind_is_group_never_dm(self):
        """'dm' drives session isolation — never guess it for an unknown kind."""
        assert chat_type_for_room_kind(RoomKind.UNSPECIFIED) is HermesChatType.GROUP
        assert chat_type_for_room_kind(None) is HermesChatType.GROUP

    def test_values_match_the_gateway_vocabulary(self):
        """session.py:161 declares exactly these strings; SessionSource.description
        and the PII-redacted context prompt branch on them."""
        assert [t.value for t in HermesChatType] == ["dm", "group", "channel", "thread"]

    def test_is_a_plain_str_at_call_sites(self):
        assert HermesChatType.CHANNEL == "channel"
        assert f"{HermesChatType.DM}" == "dm"

    async def test_get_chat_info_reports_channel(self):
        adapter = _make_adapter()
        adapter._room_names["room-1"] = "Team"
        adapter._room_kinds["room-1"] = RoomKind.CHANNEL
        info = await adapter.get_chat_info("room-1")
        assert info == {"name": "Team", "type": "channel"}

    async def test_get_chat_info_reports_dm(self):
        adapter = _make_adapter()
        adapter._room_kinds["dm-1"] = RoomKind.DM
        assert (await adapter.get_chat_info("dm-1"))["type"] == "dm"

    async def test_dispatch_stamps_the_mapped_chat_type(self):
        """The value reaching build_source decides how the agent is told where
        it is — a raw RoomKind lands in SessionSource.description's else-branch."""
        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.CHANNEL
        adapter._user_cache["user-1"] = _make_user("user-1", "alice")
        adapter.handle_message = AsyncMock()
        payload = _make_posted_payload()
        payload.fetch_message = AsyncMock(return_value=_make_message(body="hi"))

        await adapter._dispatch_message_posted(payload)

        event = adapter.handle_message.call_args.args[0]
        assert event.source.chat_type == "channel"


# -- Presence --


class TestPresence:
    """Presence is a server-side TTL: stop re-announcing and the bot goes offline."""

    def _adapter(self):
        adapter = _make_adapter()
        adapter._chatto_client.update_presence = AsyncMock()
        return adapter

    async def test_refresh_loop_keeps_reannouncing_online(self):
        """The bug: a single announce at connect lapses and never comes back."""
        adapter = self._adapter()
        adapter._closing = False
        with patch.object(ChattoConstants, "PRESENCE_REFRESH_INTERVAL", 0.01):
            task = asyncio.create_task(adapter._presence_refresh_loop())
            for _ in range(200):
                if adapter._chatto_client.update_presence.await_count >= 3:
                    break
                await asyncio.sleep(0.01)
            adapter._closing = True
            task.cancel()
            try:
                await task
            except asyncio.CancelledError:
                pass

        assert adapter._chatto_client.update_presence.await_count >= 3
        for call in adapter._chatto_client.update_presence.await_args_list:
            assert call.kwargs["status"] == PresenceStatus.ONLINE

    async def test_refresh_survives_a_failing_call(self):
        """One bad tick must not kill the loop and strand the bot offline."""
        adapter = self._adapter()
        adapter._closing = False
        adapter._chatto_client.update_presence = AsyncMock(
            side_effect=[RuntimeError("boom"), None, None]
        )
        with patch.object(ChattoConstants, "PRESENCE_REFRESH_INTERVAL", 0.01):
            task = asyncio.create_task(adapter._presence_refresh_loop())
            for _ in range(200):
                if adapter._chatto_client.update_presence.await_count >= 3:
                    break
                await asyncio.sleep(0.01)
            adapter._closing = True
            task.cancel()
            try:
                await task
            except asyncio.CancelledError:
                pass

        assert adapter._chatto_client.update_presence.await_count >= 3

    async def test_announce_online_reports_failure(self):
        adapter = self._adapter()
        adapter._chatto_client.update_presence = AsyncMock(
            side_effect=RuntimeError("nope")
        )
        assert await adapter._announce_online() is False

    async def test_disconnect_does_not_broadcast_offline(self):
        """chattolib raises ValueError on OFFLINE — going offline means stopping."""
        adapter = self._adapter()
        adapter._chatto_client.close = AsyncMock()
        client = adapter._chatto_client  # disconnect() drops the reference
        await adapter.disconnect()
        client.update_presence.assert_not_called()


# -- Mentions of other people --


class TestForeignMention:
    """With require_mention off the bot reads everything, so a message aimed at
    a named colleague would otherwise get an unsolicited answer. Acknowledge it
    with 🫥 and stay out of the conversation."""

    def _adapter(self, **overrides):
        adapter = _make_adapter()
        adapter.chatto_config.allow_all_users.value = True
        adapter.chatto_config.require_mention.value = False
        adapter.chatto_config.reactions.value = True
        for key, value in overrides.items():
            getattr(adapter.chatto_config, key).value = value
        adapter.me = _make_user("bot-user-id", "hermes_bot")
        adapter._user_cache["user-1"] = _make_user("user-1", "alice")
        adapter.handle_message = AsyncMock()
        adapter.add_reaction = AsyncMock(return_value=True)
        adapter._room_kinds["room-1"] = RoomKind.CHANNEL
        adapter._room_kinds["dm-1"] = RoomKind.DM
        # The directory knows bob and nobody else.
        adapter._chatto_client.get_user = AsyncMock(
            side_effect=lambda **kw: (
                DirectoryMember(user=_make_user("user-2", "bob"))
                if kw.get("login") == "bob"
                else None
            )
        )
        return adapter

    async def _dispatch(self, adapter, body, room_id="room-1"):
        payload = _make_posted_payload(room_id=room_id)
        payload.fetch_message = AsyncMock(
            return_value=_make_message(body=body, room_id=room_id)
        )
        await adapter._dispatch_message_posted(payload)

    async def test_message_for_someone_else_is_only_acknowledged(self):
        adapter = self._adapter()
        await self._dispatch(adapter, "@bob can you take a look?")
        adapter.handle_message.assert_not_called()
        adapter.add_reaction.assert_awaited_once()
        assert adapter.add_reaction.await_args.args[2] == "🫥"

    async def test_being_mentioned_alongside_someone_else_still_answers(self):
        adapter = self._adapter()
        await self._dispatch(adapter, "@bob and @hermes_bot, thoughts?")
        adapter.handle_message.assert_called_once()
        adapter.add_reaction.assert_not_awaited()

    async def test_several_people_addressed_and_none_of_them_us(self):
        adapter = self._adapter()
        adapter._chatto_client.get_user = AsyncMock(
            side_effect=lambda **kw: (
                DirectoryMember(user=_make_user("u", kw["login"]))
                if kw.get("login") in {"bob", "carol"}
                else None
            )
        )
        await self._dispatch(adapter, "@bob @carol schaut mal drüber")
        adapter.handle_message.assert_not_called()
        adapter.add_reaction.assert_awaited_once()

    async def test_a_real_handle_after_an_unknown_one_still_counts(self):
        """The scan must not stop at the first token it cannot resolve."""
        adapter = self._adapter()
        await self._dispatch(adapter, "@nonexistent @bob schaut mal drüber")
        adapter.handle_message.assert_not_called()
        adapter.add_reaction.assert_awaited_once()

    async def test_being_named_among_several_others_still_answers(self):
        adapter = self._adapter()
        await self._dispatch(adapter, "@bob @hermes_bot @carol — was meint ihr?")
        adapter.handle_message.assert_called_once()
        adapter.add_reaction.assert_not_awaited()

    async def test_broadcast_alongside_a_named_colleague_still_answers(self):
        """@here keeps the bot in the audience; naming bob as well does not
        remove it."""
        adapter = self._adapter()
        await self._dispatch(adapter, "@here @bob schaut mal drüber")
        adapter.handle_message.assert_called_once()
        adapter.add_reaction.assert_not_awaited()

    async def test_broadcast_mentions_address_the_bot_too(self):
        adapter = self._adapter()
        for body in ("@here standup in 5", "@channel heads up", "@everyone hi"):
            adapter.handle_message.reset_mock()
            await self._dispatch(adapter, body)
            adapter.handle_message.assert_called_once()

    async def test_talking_about_mentions_is_not_a_mention(self):
        """Verbatim from the field: the instruction to send a mention later must
        not read as a mention now. '@-mention' is not a handle anyone holds."""
        adapter = self._adapter()
        await self._dispatch(
            adapter,
            "Bitte schreibe um 8 Uhr Europe/Berlin per @-mention den "
            'Chatto-Nutzer "nickk" an und sage: Guten Morgen.',
        )
        adapter.handle_message.assert_called_once()
        adapter.add_reaction.assert_not_awaited()

    async def test_handle_nobody_holds_is_not_a_mention(self):
        """A plausible-looking @token that resolves to no user is not someone
        else — answering a false positive beats falling silent on one."""
        adapter = self._adapter()
        adapter._chatto_client.get_user = AsyncMock(return_value=None)
        await self._dispatch(adapter, "gilt das auch für @nonexistent_person?")
        adapter.handle_message.assert_called_once()
        adapter.add_reaction.assert_not_awaited()

    async def test_a_resolvable_handle_is_looked_up_once(self):
        adapter = self._adapter()
        await self._dispatch(adapter, "@bob ping")
        await self._dispatch(adapter, "@bob again")
        assert adapter._chatto_client.get_user.await_count == 1
        assert adapter.handle_message.await_count == 0

    async def test_an_email_address_is_not_a_mention(self):
        adapter = self._adapter()
        await self._dispatch(adapter, "schreib an bob@example.com")
        adapter.handle_message.assert_called_once()
        adapter._chatto_client.get_user.assert_not_awaited()

    async def test_plain_message_is_unaffected(self):
        adapter = self._adapter()
        await self._dispatch(adapter, "how do I reset the cache?")
        adapter.handle_message.assert_called_once()

    async def test_dms_are_answered_even_when_they_name_someone_else(self):
        adapter = self._adapter()
        await self._dispatch(adapter, "@bob said the build is red", room_id="dm-1")
        adapter.handle_message.assert_called_once()

    async def test_require_mention_keeps_discarding_without_a_reaction(self):
        """The older gate wins: it drops the message before we get here, and it
        deliberately says nothing at all."""
        adapter = self._adapter(require_mention=True)
        await self._dispatch(adapter, "@bob can you take a look?")
        adapter.handle_message.assert_not_called()
        adapter.add_reaction.assert_not_awaited()

    async def test_silence_holds_when_reactions_are_disabled(self):
        adapter = self._adapter(reactions=False)
        await self._dispatch(adapter, "@bob can you take a look?")
        adapter.handle_message.assert_not_called()
        adapter.add_reaction.assert_not_awaited()


# -- require_mention --


class TestRequireMention:
    """require_mention gates channels only — a DM is already addressed at the bot."""

    def _adapter(self):
        adapter = _make_adapter()
        adapter.chatto_config.allow_all_users.value = True
        adapter.chatto_config.require_mention.value = True
        adapter.me = _make_user("bot-user-id", "hermes_bot")
        adapter._user_cache["user-1"] = _make_user("user-1", "alice")
        adapter.handle_message = AsyncMock()
        return adapter

    async def _dispatch(self, adapter, room_id, body):
        payload = _make_posted_payload(room_id=room_id)
        payload.fetch_message = AsyncMock(
            return_value=_make_message(body=body, room_id=room_id)
        )
        await adapter._dispatch_message_posted(payload)

    async def test_channel_without_mention_is_discarded(self):
        adapter = self._adapter()
        adapter._room_kinds["room-1"] = RoomKind.CHANNEL
        await self._dispatch(adapter, "room-1", "hi there")
        adapter.handle_message.assert_not_called()

    async def test_channel_with_mention_is_answered(self):
        adapter = self._adapter()
        adapter._room_kinds["room-1"] = RoomKind.CHANNEL
        await self._dispatch(adapter, "room-1", "@hermes_bot hi there")
        adapter.handle_message.assert_called_once()

    async def test_dm_is_answered_without_a_mention(self):
        """The point of the room_kind check: require_mention must not mute DMs."""
        adapter = self._adapter()
        adapter._room_kinds["dm-1"] = RoomKind.DM
        await self._dispatch(adapter, "dm-1", "hi there")
        adapter.handle_message.assert_called_once()


# -- Inbound edits (edit-dispatch) --


def _make_edited_event(room_id="room-1", message_event_id="msg-1", actor_id="human-1"):
    """A message_edited envelope whose payload the caller stubs."""
    event = MagicMock()
    event.id = "evt-edit-1"
    event.kind = "message_edited"
    event.actor_id = actor_id
    payload = MagicMock()
    payload.room_id = room_id
    payload.message_event_id = message_event_id
    # RealtimeEvent.get() only yields the payload for its own kind.
    event.get = MagicMock(
        side_effect=lambda kind: payload if kind == "message_edited" else None
    )
    return event, payload


class TestEditDispatch:
    """message_edited events become corrections, late mentions or nothing."""

    def _adapter(self):
        adapter = _make_adapter()
        adapter.chatto_config.allow_all_users.value = True
        adapter.me = _make_user("bot-user-id", "hermes_bot")
        adapter._user_cache["user-1"] = _make_user("user-1", "alice")
        adapter.handle_message = AsyncMock()
        return adapter

    async def _edit(
        self,
        adapter,
        body,
        *,
        message_id="msg-1",
        room_id="room-1",
        created_at=None,
        updated_at=None,
    ):
        event, payload = _make_edited_event(
            room_id=room_id, message_event_id=message_id
        )
        payload.fetch_message = AsyncMock(
            return_value=_make_message(
                body=body,
                message_id=message_id,
                room_id=room_id,
                created_at=created_at,
                updated_at=updated_at,
            )
        )
        await adapter._handle_realtime_event(event)
        return payload

    async def _post_original(self, adapter, body, *, message_id, room_id):
        """Dispatch the original posted message so its turn exists."""
        payload = _make_posted_payload(room_id=room_id, message_event_id=message_id)
        payload.fetch_message = AsyncMock(
            return_value=_make_message(
                body=body, message_id=message_id, room_id=room_id
            )
        )
        await adapter._dispatch_message_posted(payload)

    def test_edit_dispatch_defaults_on_with_five_minute_window(self):
        adapter = self._adapter()
        assert adapter.chatto_config.edit_dispatch.value is True
        assert adapter.chatto_config.edit_window.value == 300

    async def test_own_edit_echo_is_ignored(self):
        """Our streaming edits echo back as message_edited — never inbound traffic."""
        adapter = self._adapter()
        event, payload = _make_edited_event(actor_id="bot-user-id")
        payload.fetch_message = AsyncMock()

        await adapter._handle_realtime_event(event)

        payload.fetch_message.assert_not_awaited()
        adapter.handle_message.assert_not_called()

    async def test_disabled_feature_drops_edits(self):
        adapter = self._adapter()
        adapter.chatto_config.edit_dispatch.value = False

        await self._edit(adapter, "corrected")

        adapter.handle_message.assert_not_called()

    async def test_read_only_room_costs_no_api_call(self):
        """Respond-room gating sits before fetch_message, like for posts."""
        adapter = self._adapter()
        adapter.chatto_config.respond_rooms.value = ["other-room"]

        fetch = await self._edit(adapter, "corrected")
        fetch.fetch_message.assert_not_awaited()  # type: ignore[union-attr]
        adapter.handle_message.assert_not_called()

    async def test_mention_added_by_edit_starts_a_turn(self):
        adapter = self._adapter()
        adapter._room_kinds["room-1"] = RoomKind.CHANNEL
        adapter.chatto_config.require_mention.value = True

        await self._edit(adapter, "@hermes_bot corrected text")

        adapter.handle_message.assert_called_once()
        assert (
            adapter.handle_message.call_args.args[0].text
            == "@hermes_bot corrected text"
        )
        assert "msg-1" in adapter._dispatched_ids

    async def test_edit_without_the_mention_stays_silent(self):
        """Gates run against the new body — no mention in, no answer out."""
        adapter = self._adapter()
        adapter._room_kinds["room-1"] = RoomKind.CHANNEL
        adapter.chatto_config.require_mention.value = True

        await self._edit(adapter, "still no mention")

        adapter.handle_message.assert_not_called()

    async def test_already_answered_message_stays_answered(self):
        """The retro-lock: editing an old answered message must not re-animate it."""
        adapter = self._adapter()
        adapter._dispatched_ids.append("msg-1")

        await self._edit(adapter, "late fix")

        adapter.handle_message.assert_not_called()

    async def test_deleted_message_is_ignored(self):
        adapter = self._adapter()
        now = datetime.now(UTC)
        event, payload = _make_edited_event()
        payload.fetch_message = AsyncMock(
            return_value=_make_message(body="gone", created_at=now, updated_at=now)
        )
        payload.fetch_message.return_value.deleted_at = now

        await adapter._handle_realtime_event(event)

        adapter.handle_message.assert_not_called()

    async def test_stale_edit_outside_the_window_is_ignored(self):
        adapter = self._adapter()
        now = datetime.now(UTC)

        await self._edit(
            adapter,
            "very late fix",
            created_at=now,
            updated_at=now + timedelta(seconds=400),
        )

        adapter.handle_message.assert_not_called()

    async def test_fresh_edit_within_the_window_passes_gates(self):
        adapter = self._adapter()
        adapter._room_kinds["dm-room"] = RoomKind.DM
        now = datetime.now(UTC)

        await self._edit(
            adapter,
            "typo fixed",
            room_id="dm-room",
            created_at=now,
            updated_at=now + timedelta(seconds=5),
        )

        adapter.handle_message.assert_called_once()

    async def test_midrun_edit_restarts_the_turn(self):
        adapter = self._adapter()
        adapter._room_kinds["dm-room"] = RoomKind.DM
        await self._post_original(
            adapter, "orignal text", message_id="msg-run", room_id="dm-room"
        )
        first_event = adapter.handle_message.call_args.args[0]
        await adapter.on_processing_start(first_event)
        adapter.cancel_session_processing = AsyncMock()

        await self._edit(
            adapter, "original text", message_id="msg-run", room_id="dm-room"
        )

        adapter.cancel_session_processing.assert_awaited_once()
        assert adapter.handle_message.await_count == 2
        assert adapter.handle_message.await_args.args[0].text == "original text"

    async def test_completion_hook_rearms_the_processing_map(self):
        """After any outcome (incl. CANCELLED) the session accepts new turns."""
        adapter = self._adapter()
        adapter._room_kinds["dm-room"] = RoomKind.DM
        await self._post_original(
            adapter, "hello", message_id="msg-run", room_id="dm-room"
        )
        event = adapter.handle_message.call_args.args[0]

        await adapter.on_processing_start(event)
        session_key = adapter._session_key_for(event.source)
        assert adapter._processing.get(session_key) == "msg-run"

        await adapter.on_processing_complete(event, ProcessingOutcome.CANCELLED)
        assert session_key not in adapter._processing

    async def test_queued_followup_gets_corrected_in_place(self):
        """A still-queued message answers later with its edited wording."""
        from types import SimpleNamespace

        adapter = self._adapter()
        adapter._room_kinds["dm-room"] = RoomKind.DM
        await self._post_original(
            adapter, "queue me", message_id="msg-other", room_id="dm-room"
        )
        other_event = adapter.handle_message.call_args.args[0]
        session_key = adapter._session_key_for(other_event.source)
        pending = SimpleNamespace(message_id="msg-run", text="stale wording")
        adapter._pending_messages[session_key] = pending
        adapter._dispatched_ids.extend(["msg-other", "msg-run"])
        adapter.cancel_session_processing = AsyncMock()

        await self._edit(
            adapter, "fixed wording", message_id="msg-run", room_id="dm-room"
        )

        assert pending.text == "fixed wording"
        adapter.cancel_session_processing.assert_not_awaited()
        # No second dispatch: the queued original carries the correction.
        assert adapter.handle_message.await_count == 1

    async def test_dm_command_edit_neither_reruns_nor_dispatches(self):
        """/join ran when posted; its edited copy must not run twice or leak."""
        adapter = self._adapter()
        adapter._room_kinds["dm-room"] = RoomKind.DM
        adapter._handle_dm_command = AsyncMock(return_value=True)

        await self._edit(adapter, "/leave", room_id="dm-room")

        adapter._handle_dm_command.assert_not_called()
        adapter.handle_message.assert_not_called()


# -- 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)
            ),
            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


# -- DM room management (/join, /leave) --


def _make_room_state(room, is_member):
    """Build a RoomWithViewerState the way list_rooms()/get_room() return it."""
    return RoomWithViewerState(
        room=room,
        viewer_state=RoomViewerState(is_member=is_member),
    )


class TestDmRoomCommands:
    """/join and /leave arrive over DMs, change server-side membership and
    must never reach the agent pipeline."""

    def _adapter(self):
        adapter = _make_adapter()
        adapter.chatto_config.allow_all_users.value = True
        adapter.me = _make_user("bot-user-id", "hermes_bot")
        adapter._user_cache["user-1"] = _make_user("user-1", "alice")
        client = adapter._chatto_client
        client.list_rooms = AsyncMock(return_value=[])
        client.get_room_events = AsyncMock(return_value=MagicMock(events=[]))
        client.join_room = AsyncMock()
        client.leave_room = AsyncMock(return_value=True)
        client.get_room = AsyncMock()
        adapter._room_kinds["dm-1"] = RoomKind.DM
        adapter.handle_message = AsyncMock()
        adapter.send = AsyncMock()
        return adapter

    async def _dispatch(self, adapter, body, room_id="dm-1"):
        payload = _make_posted_payload(room_id=room_id)
        payload.fetch_message = AsyncMock(
            return_value=_make_message(body=body, room_id=room_id)
        )
        await adapter._dispatch_message_posted(payload)

    def _reply(self, adapter):
        assert adapter.send.await_count == 1
        return adapter.send.await_args.kwargs["content"]

    async def test_join_by_name_joins_and_seeds(self):
        adapter = self._adapter()
        state = _make_room_state(
            _make_room("room-9", "Deploy", RoomKind.CHANNEL), False
        )
        adapter._chatto_client.list_rooms = AsyncMock(return_value=[state])
        adapter._chatto_client.join_room = AsyncMock(return_value=state.room)

        await self._dispatch(adapter, "/join #deploy")

        adapter._chatto_client.join_room.assert_awaited_once_with("room-9")
        assert adapter._joined_room_ids == ["room-9"]
        assert "Joined 'Deploy' (room-9)" in self._reply(adapter)

    async def test_join_skips_rpc_when_already_a_member(self):
        adapter = self._adapter()
        """Natively invited accounts hold membership already — they only need
        seeding into the joined list."""
        state = _make_room_state(_make_room("room-9", "Deploy", RoomKind.CHANNEL), True)
        adapter._chatto_client.list_rooms = AsyncMock(return_value=[state])

        await self._dispatch(adapter, "/join #deploy")

        adapter._chatto_client.join_room.assert_not_awaited()
        assert adapter._joined_room_ids == ["room-9"]
        assert "Already a member" in self._reply(adapter)

    async def test_join_unknown_name_reports_without_joining(self):
        adapter = self._adapter()
        adapter._chatto_client.list_rooms = AsyncMock(return_value=[])

        await self._dispatch(adapter, "/join #nope")

        adapter._chatto_client.join_room.assert_not_awaited()
        assert "No room named '#nope'" in self._reply(adapter)
        assert adapter._joined_room_ids == []

    async def test_ambiguous_name_offers_the_candidate_ids(self):
        adapter = self._adapter()
        matches = [
            _make_room_state(_make_room(f"r-{i}", "General", RoomKind.CHANNEL), False)
            for i in range(2)
        ]
        adapter._chatto_client.list_rooms = AsyncMock(return_value=matches)
        adapter._chatto_client.join_room = AsyncMock()

        await self._dispatch(adapter, "/join #general")

        adapter._chatto_client.join_room.assert_not_awaited()
        reply = self._reply(adapter)
        assert "r-0" in reply and "r-1" in reply

    async def test_join_by_room_id_verifies_via_get_room(self):
        adapter = self._adapter()
        state = _make_room_state(
            _make_room("room-9", "Deploy", RoomKind.CHANNEL), False
        )
        adapter._chatto_client.get_room = AsyncMock(return_value=state)
        adapter._chatto_client.join_room = AsyncMock(return_value=state.room)

        await self._dispatch(adapter, "/join room-9")

        adapter._chatto_client.get_room.assert_awaited_once_with("room-9")
        adapter._chatto_client.join_room.assert_awaited_once_with("room-9")
        assert adapter._joined_room_ids == ["room-9"]

    async def test_leave_drops_the_room_from_the_joined_list(self):
        adapter = self._adapter()
        adapter._joined_room_ids = ["room-7"]
        state = _make_room_state(_make_room("room-7", "Deploy", RoomKind.CHANNEL), True)
        adapter._chatto_client.get_room = AsyncMock(return_value=state)

        await self._dispatch(adapter, "/leave room-7")

        adapter._chatto_client.leave_room.assert_awaited_once_with("room-7")
        assert adapter._joined_room_ids == []
        assert "Left 'Deploy' (room-7)" in self._reply(adapter)

    async def test_leave_refuses_direct_messages(self):
        adapter = self._adapter()
        state = _make_room_state(_make_room("dm-2", "", RoomKind.DM), True)
        adapter._chatto_client.get_room = AsyncMock(return_value=state)

        await self._dispatch(adapter, "/leave dm-2")

        adapter._chatto_client.leave_room.assert_not_awaited()
        assert "Direct messages cannot be left" in self._reply(adapter)

    async def test_leave_refuses_home_channel(self):
        """Leaving CHATTO_HOME_CHANNEL would break cron/notification delivery."""
        adapter = self._adapter()
        adapter.chatto_config.home_channel.value = "room-7"
        adapter._joined_room_ids = ["room-7"]
        state = _make_room_state(_make_room("room-7", "Home", RoomKind.CHANNEL), True)
        adapter._chatto_client.get_room = AsyncMock(return_value=state)

        await self._dispatch(adapter, "/leave room-7")

        adapter._chatto_client.leave_room.assert_not_awaited()
        assert "home channel" in self._reply(adapter)

    async def test_commands_outside_dms_are_ignored(self):
        adapter = self._adapter()
        """In a channel the text is just a message — mention gating applies,
        no command runs, nothing is sent."""
        adapter.chatto_config.require_mention.value = True
        adapter._room_kinds["chan-1"] = RoomKind.CHANNEL

        await self._dispatch(adapter, "/leave room-7", room_id="chan-1")

        adapter._chatto_client.leave_room.assert_not_awaited()
        adapter.handle_message.assert_not_called()
        adapter.send.assert_not_called()

    async def test_non_command_dm_falls_through_to_pipeline(self):
        adapter = self._adapter()
        await self._dispatch(adapter, "/status all good")
        adapter.handle_message.assert_awaited_once()
        adapter.send.assert_not_called()

    async def test_missing_argument_gets_usage_reply(self):
        adapter = self._adapter()
        for body in ("/join", "/leave"):
            adapter.send.reset_mock()
            await self._dispatch(adapter, body)
            assert self._reply(adapter).startswith("Usage:")


class TestJoinedRoomsRefresh:
    """_refresh_rooms mirrors the joined list against server-side membership."""

    def _adapter(self):
        adapter = _make_adapter()
        client = adapter._chatto_client
        client.list_rooms = AsyncMock(return_value=[])
        return adapter

    async def test_drops_rooms_no_longer_joined(self):
        adapter = self._adapter()
        adapter._joined_room_ids = ["gone-1", "kept"]
        kept = _make_room_state(_make_room("kept", "Kept", RoomKind.CHANNEL), True)
        adapter._chatto_client.list_rooms = AsyncMock(return_value=[kept])

        await adapter._refresh_rooms()

        assert adapter._joined_room_ids == ["kept"]

    async def test_warns_once_when_home_channel_is_not_joined(self):
        adapter = self._adapter()
        adapter.chatto_config.home_channel.value = "home-x"
        other = _make_room_state(_make_room("other", "Other", RoomKind.CHANNEL), True)
        adapter._chatto_client.list_rooms = AsyncMock(return_value=[other])

        await adapter._refresh_rooms()
        assert adapter._home_warning_logged

        await adapter._refresh_rooms()
        assert adapter._home_warning_logged

    async def test_no_warning_while_home_channel_is_member(self):
        adapter = self._adapter()
        adapter.chatto_config.home_channel.value = "home-x"
        home = _make_room_state(_make_room("home-x", "Home", RoomKind.CHANNEL), True)
        adapter._chatto_client.list_rooms = AsyncMock(return_value=[home])

        await adapter._refresh_rooms()
        assert not adapter._home_warning_logged


class TestRespondRooms:
    """CHATTO_RESPOND_ROOMS gates inbound messages: positive list, DMs exempt."""

    def _dropping_adapter(self, respond_rooms):
        """An adapter whose every API call explodes — the gate must exit first."""
        adapter = _make_adapter()
        adapter.chatto_config.respond_rooms.value = respond_rooms
        adapter.me = _make_user("bot-user-id", "hermes_bot")
        adapter.handle_message = AsyncMock()
        adapter._get_chatto_client = AsyncMock(return_value=None)
        return adapter

    async def test_unlisted_room_is_dropped_before_any_api_call(self):
        adapter = self._dropping_adapter(["listed-1"])
        adapter._room_kinds["room-1"] = RoomKind.CHANNEL
        payload = _make_posted_payload(room_id="room-1")
        payload.fetch_message = AsyncMock()

        await adapter._dispatch_message_posted(payload)

        payload.fetch_message.assert_not_awaited()
        adapter.handle_message.assert_not_called()

    async def test_unknown_room_kind_fails_closed(self):
        """No cached kind: the allowlist assumes the worst and drops."""
        adapter = self._dropping_adapter(["listed-1"])
        payload = _make_posted_payload(room_id="mystery-room")
        payload.fetch_message = AsyncMock()

        await adapter._dispatch_message_posted(payload)

        payload.fetch_message.assert_not_awaited()
        adapter.handle_message.assert_not_called()

    async def test_listed_room_reaches_the_pipeline(self):
        adapter = self._dropping_adapter(["room-1"])
        adapter.chatto_config.allow_all_users.value = True
        adapter._get_chatto_client = AsyncMock(return_value=adapter._chatto_client)
        adapter._room_kinds["room-1"] = RoomKind.CHANNEL
        adapter._user_cache["user-1"] = _make_user("user-1", "alice")
        payload = _make_posted_payload(room_id="room-1")
        payload.fetch_message = AsyncMock(
            return_value=_make_message(body="hi", room_id="room-1")
        )

        await adapter._dispatch_message_posted(payload)

        adapter.handle_message.assert_called_once()

    async def test_dm_outside_the_list_still_answers(self):
        """DMs stay respond rooms so /join remains reachable."""
        adapter = self._dropping_adapter(["listed-1"])
        adapter.chatto_config.allow_all_users.value = True
        adapter._get_chatto_client = AsyncMock(return_value=adapter._chatto_client)
        adapter._room_kinds["dm-1"] = RoomKind.DM
        adapter._user_cache["user-1"] = _make_user("user-1", "alice")
        payload = _make_posted_payload(room_id="dm-1")
        payload.fetch_message = AsyncMock(
            return_value=_make_message(body="hi", room_id="dm-1")
        )

        await adapter._dispatch_message_posted(payload)

        adapter.handle_message.assert_called_once()

    async def test_empty_list_answers_everywhere(self):
        """Unset list keeps the pre-existing behaviour: every room responds."""
        adapter = self._dropping_adapter([])
        adapter.chatto_config.allow_all_users.value = True
        adapter._get_chatto_client = AsyncMock(return_value=adapter._chatto_client)
        adapter._room_kinds["any-room"] = RoomKind.CHANNEL
        adapter._user_cache["user-1"] = _make_user("user-1", "alice")
        payload = _make_posted_payload(room_id="any-room")
        payload.fetch_message = AsyncMock(
            return_value=_make_message(body="hi", room_id="any-room")
        )

        await adapter._dispatch_message_posted(payload)

        adapter.handle_message.assert_called_once()


class TestRespondRoomRefresh:
    """_refresh_rooms keeps read-only rooms joined but skips seeding them."""

    def _adapter(self, respond_rooms):
        adapter = _make_adapter()
        adapter.chatto_config.respond_rooms.value = respond_rooms
        client = adapter._chatto_client
        client.list_rooms = AsyncMock(return_value=[])
        client.join_room = AsyncMock()
        adapter._seed_room = AsyncMock()
        return adapter

    async def test_read_only_rooms_are_joined_but_not_seeded(self):
        adapter = self._adapter(["team-1"])
        news = _make_room_state(_make_room("news-1", "News", RoomKind.CHANNEL), True)
        team = _make_room_state(_make_room("team-1", "Team", RoomKind.CHANNEL), True)
        adapter._chatto_client.list_rooms = AsyncMock(return_value=[news, team])

        await adapter._refresh_rooms()

        assert sorted(adapter._joined_room_ids) == ["news-1", "team-1"]
        adapter._seed_room.assert_awaited_once_with("team-1")

    async def test_join_log_tags_read_only_and_universal(self, caplog):
        adapter = self._adapter(["team-1"])
        news = Room(id="news-1", name="News", kind=RoomKind.CHANNEL, universal=True)
        team = Room(id="team-1", name="Team", kind=RoomKind.CHANNEL, universal=False)
        adapter._chatto_client.list_rooms = AsyncMock(
            return_value=[_make_room_state(news, True), _make_room_state(team, True)]
        )

        with caplog.at_level("INFO"):
            await adapter._refresh_rooms()

        joined = [m for m in caplog.messages if "currently joined" in m]
        assert joined, "expected the joined-rooms summary log line"
        assert "[read-only]" in joined[-1]
        assert "[universal]" in joined[-1]

    async def test_warns_when_respond_list_names_unjoined_rooms(self, caplog):
        adapter = self._adapter(["ghost-id"])
        kept = _make_room_state(_make_room("kept", "Kept", RoomKind.CHANNEL), True)
        adapter._chatto_client.list_rooms = AsyncMock(return_value=[kept])

        with caplog.at_level("WARNING"):
            await adapter._refresh_rooms()

        assert any("ghost-id" in message for message in caplog.messages)


# -- 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


# -- Client creation credentials --


class TestOpenClientCredentials:
    """Token wins; nothing configured raises as a normal creation failure."""

    def _adapter(self):
        return _make_adapter()

    async def test_token_only_connects_without_login(self):
        """The documented CHATTO_TOKEN path must not demand a login."""
        adapter = self._adapter()
        client = await adapter._open_client(
            base_url="https://chat.example.com", login="", password="", token="t"
        )
        assert isinstance(client, ChattoClient)

    async def test_no_credentials_raises_value_error(self):
        """Misconfiguration reads as a logged creation failure, not a crash."""
        adapter = self._adapter()
        with pytest.raises(ValueError, match="neither token nor login"):
            await adapter._open_client(
                base_url="https://chat.example.com", login="", password=""
            )

    async def test_login_password_path_is_used_without_token(self):
        adapter = self._adapter()
        with patch("adapter.ChattoClient") as client_cls:
            client_cls.login = AsyncMock(return_value=MagicMock())

            await adapter._open_client(
                base_url="https://chat.example.com", login="u", password="p"
            )

            client_cls.login.assert_awaited_once_with(
                "u", "p", base_url="https://chat.example.com"
            )
