# ruff: noqa: E402

import asyncio
import sys
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
from urllib.parse import unquote

import pytest

pytest.importorskip("nio")
pytest.importorskip("nh3")
pytest.importorskip("mistune")
from nio import JoinResponse, RoomSendResponse, SyncError

import nanobot.channels.matrix.runtime as matrix_module
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.matrix.runtime import (
    MATRIX_HTML_FORMAT,
    TYPING_NOTICE_TIMEOUT_MS,
    MatrixChannel,
    MatrixConfig,
    _build_matrix_text_content,
)

_ROOM_SEND_UNSET = object()


def test_default_e2ee_matches_platform_support() -> None:
    assert MatrixConfig().e2ee_enabled is (sys.platform != "win32")


class _DummyTask:
    def __init__(self) -> None:
        self.cancelled = False

    def cancel(self) -> None:
        self.cancelled = True

    def __await__(self):
        async def _done():
            return None

        return _done().__await__()


class _FakeAsyncClient:
    def __init__(self, homeserver, user, store_path, config) -> None:
        self.homeserver = homeserver
        self.user = user
        self.store_path = store_path
        self.config = config
        self.user_id: str | None = None
        self.access_token: str | None = None
        self.device_id: str | None = None
        self.load_store_called = False
        self.stop_sync_forever_called = False
        self.join_calls: list[str] = []
        self.callbacks: list[tuple[object, object]] = []
        self.to_device_callbacks: list[tuple[object, object]] = []
        self.response_callbacks: list[tuple[object, object]] = []
        self.key_verifications: dict[str, object] = {}
        self.operation_calls: list[str] = []
        self.accept_key_verification_calls: list[str] = []
        self.confirm_short_auth_string_calls: list[str] = []
        self.send_to_device_messages_calls = 0
        self.to_device_calls: list[object] = []
        self.accept_key_verification_response: object | None = None
        self.confirm_short_auth_string_response: object | None = None
        self.send_to_device_messages_response: list[object] = []
        self.to_device_response: object | None = None
        self.rooms: dict[str, object] = {}
        self.room_send_calls: list[dict[str, object]] = []
        self.typing_calls: list[tuple[str, bool, int]] = []
        self.download_calls: list[dict[str, object]] = []
        self.upload_calls: list[dict[str, object]] = []
        self.download_response: object | None = None
        self.download_bytes: bytes = b"media"
        self.download_content_type: str = "application/octet-stream"
        self.download_filename: str | None = None
        self.upload_response: object | None = None
        self.content_repository_config_response: object = SimpleNamespace(upload_size=None)
        self.raise_on_send = False
        self.raise_on_typing = False
        self.raise_on_upload = False
        self.room_send_response: RoomSendResponse | None = RoomSendResponse(event_id="", room_id="")

    def add_event_callback(self, callback, event_type) -> None:
        self.callbacks.append((callback, event_type))

    def add_to_device_callback(self, callback, event_type) -> None:
        self.to_device_callbacks.append((callback, event_type))

    def add_response_callback(self, callback, response_type) -> None:
        self.response_callbacks.append((callback, response_type))

    def load_store(self) -> None:
        self.load_store_called = True

    def stop_sync_forever(self) -> None:
        self.stop_sync_forever_called = True

    async def join(self, room_id: str) -> None:
        self.join_calls.append(room_id)

    async def _send(self, response_class, method, path, data=None, **kwargs):
        """Minimal mock for nio's ``_send`` used by ``_join_room_safe``."""
        if response_class is JoinResponse and method == "POST" and "/join/" in path:
            encoded = path.split("/join/")[1].split("?")[0]
            room_id = unquote(encoded)
            self.join_calls.append(room_id)
            return JoinResponse(room_id=room_id)
        return response_class()

    async def accept_key_verification(self, transaction_id: str):
        self.operation_calls.append(f"accept:{transaction_id}")
        self.accept_key_verification_calls.append(transaction_id)
        return self.accept_key_verification_response

    async def confirm_short_auth_string(self, transaction_id: str):
        self.operation_calls.append(f"confirm:{transaction_id}")
        self.confirm_short_auth_string_calls.append(transaction_id)
        return self.confirm_short_auth_string_response

    async def send_to_device_messages(self):
        self.operation_calls.append("send_pending")
        self.send_to_device_messages_calls += 1
        return self.send_to_device_messages_response

    async def to_device(self, message):
        self.operation_calls.append("to_device")
        self.to_device_calls.append(message)
        return self.to_device_response

    async def room_send(
        self,
        room_id: str,
        message_type: str,
        content: dict[str, object],
        ignore_unverified_devices: object = _ROOM_SEND_UNSET,
    ) -> RoomSendResponse:
        call: dict[str, object] = {
            "room_id": room_id,
            "message_type": message_type,
            "content": content,
        }
        if ignore_unverified_devices is not _ROOM_SEND_UNSET:
            call["ignore_unverified_devices"] = ignore_unverified_devices
        self.room_send_calls.append(call)
        if self.raise_on_send:
            raise RuntimeError("send failed")
        return self.room_send_response

    async def room_typing(
        self,
        room_id: str,
        typing_state: bool = True,
        timeout: int = 30_000,
    ) -> None:
        self.typing_calls.append((room_id, typing_state, timeout))
        if self.raise_on_typing:
            raise RuntimeError("typing failed")

    async def download(self, **kwargs):
        self.download_calls.append(kwargs)
        if self.download_response is not None:
            return self.download_response
        return matrix_module.MemoryDownloadResponse(
            body=self.download_bytes,
            content_type=self.download_content_type,
            filename=self.download_filename,
        )

    async def upload(
        self,
        data_provider,
        content_type: str | None = None,
        filename: str | None = None,
        filesize: int | None = None,
        encrypt: bool = False,
    ):
        if self.raise_on_upload:
            raise RuntimeError("upload failed")
        if isinstance(data_provider, (bytes, bytearray)):
            raise TypeError(
                f"data_provider type {type(data_provider)!r} is not of a usable type "
                "(Callable, IOBase)"
            )
        self.upload_calls.append(
            {
                "data_provider": data_provider,
                "content_type": content_type,
                "filename": filename,
                "filesize": filesize,
                "encrypt": encrypt,
            }
        )
        if self.upload_response is not None:
            return self.upload_response
        if encrypt:
            return (
                SimpleNamespace(content_uri="mxc://example.org/uploaded"),
                {
                    "v": "v2",
                    "iv": "iv",
                    "hashes": {"sha256": "hash"},
                    "key": {"alg": "A256CTR", "k": "key"},
                },
            )
        return SimpleNamespace(content_uri="mxc://example.org/uploaded"), None

    async def content_repository_config(self):
        return self.content_repository_config_response

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


class _FakeSas:
    def __init__(self, *, verified: bool = False) -> None:
        self.share_key_called = False
        self.get_mac_called = False
        self.verified = verified

    def share_key(self):
        self.share_key_called = True
        return {"type": "share_key"}

    def get_mac(self):
        self.get_mac_called = True
        return {"type": "mac"}


class _FakeKeyVerificationStart:
    def __init__(
        self,
        *,
        sender: str = "@alice:matrix.org",
        transaction_id: str = "tx1",
        short_authentication_string: list[str] | None = None,
    ) -> None:
        self.sender = sender
        self.transaction_id = transaction_id
        self.short_authentication_string = short_authentication_string or ["emoji"]


class _FakeKeyVerificationKey:
    def __init__(
        self,
        *,
        sender: str = "@alice:matrix.org",
        transaction_id: str = "tx1",
    ) -> None:
        self.sender = sender
        self.transaction_id = transaction_id


class _FakeKeyVerificationMac:
    def __init__(
        self,
        *,
        sender: str = "@alice:matrix.org",
        transaction_id: str = "tx1",
    ) -> None:
        self.sender = sender
        self.transaction_id = transaction_id


def _patch_key_verification_events(monkeypatch) -> None:
    monkeypatch.setattr(matrix_module, "KeyVerificationStart", _FakeKeyVerificationStart)
    monkeypatch.setattr(matrix_module, "KeyVerificationKey", _FakeKeyVerificationKey)
    monkeypatch.setattr(matrix_module, "KeyVerificationMac", _FakeKeyVerificationMac)


def _make_config(**kwargs) -> MatrixConfig:
    kwargs.setdefault("allow_from", ["*"])
    return MatrixConfig(
        enabled=True,
        homeserver="https://matrix.org",
        access_token="token",
        user_id="@bot:matrix.org",
        **kwargs,
    )


@pytest.mark.asyncio
async def test_start_skips_load_store_when_device_id_missing(
    monkeypatch, tmp_path
) -> None:
    clients: list[_FakeAsyncClient] = []

    def _fake_client(*args, **kwargs) -> _FakeAsyncClient:
        client = _FakeAsyncClient(*args, **kwargs)
        clients.append(client)
        return client

    def _fake_create_task(coro):
        coro.close()
        return _DummyTask()

    monkeypatch.setattr("nanobot.channels.matrix.runtime.get_data_dir", lambda: tmp_path)
    monkeypatch.setattr(
        "nanobot.channels.matrix.runtime.AsyncClientConfig",
        lambda **kwargs: SimpleNamespace(**kwargs),
    )
    monkeypatch.setattr("nanobot.channels.matrix.runtime.AsyncClient", _fake_client)
    monkeypatch.setattr(
        "nanobot.channels.matrix.runtime.asyncio.create_task", _fake_create_task
    )

    channel = MatrixChannel(_make_config(device_id="", e2ee_enabled=True), MessageBus())
    await channel.start()

    assert len(clients) == 1
    assert clients[0].config.encryption_enabled is True
    assert clients[0].load_store_called is False
    assert len(clients[0].callbacks) == 3
    assert clients[0].to_device_callbacks == []
    assert len(clients[0].response_callbacks) == 4

    await channel.stop()


@pytest.mark.asyncio
async def test_register_event_callbacks_uses_media_base_filter() -> None:
    channel = MatrixChannel(_make_config(), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client

    channel._register_event_callbacks()

    assert len(client.callbacks) == 3
    assert client.callbacks[1][0] == channel._on_media_message
    assert client.callbacks[1][1] == matrix_module.MATRIX_MEDIA_EVENT_FILTER


def test_register_to_device_callbacks_when_sas_verification_enabled() -> None:
    channel = MatrixChannel(_make_config(e2ee_enabled=True, sas_verification=True), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client

    channel._register_to_device_callbacks()

    assert client.to_device_callbacks == [
        (channel._on_key_verification_event, (matrix_module.KeyVerificationEvent,))
    ]


def test_register_to_device_callbacks_skips_when_e2ee_disabled() -> None:
    channel = MatrixChannel(
        _make_config(e2ee_enabled=False, sas_verification=True),
        MessageBus(),
    )
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client

    channel._register_to_device_callbacks()

    assert client.to_device_callbacks == []


@pytest.mark.asyncio
async def test_sas_verification_start_accepts_allowed_sender(monkeypatch) -> None:
    _patch_key_verification_events(monkeypatch)
    channel = MatrixChannel(
        _make_config(
            allow_from=["@alice:matrix.org"],
            e2ee_enabled=True,
            sas_verification=True,
        ),
        MessageBus(),
    )
    client = _FakeAsyncClient("", "", "", None)
    sas = _FakeSas()
    client.key_verifications["tx1"] = sas
    channel.client = client

    await channel._handle_key_verification_event(_FakeKeyVerificationStart())

    assert client.accept_key_verification_calls == ["tx1"]
    assert sas.share_key_called is False
    assert client.to_device_calls == []


@pytest.mark.asyncio
async def test_sas_verification_ignores_denied_sender(monkeypatch) -> None:
    _patch_key_verification_events(monkeypatch)
    channel = MatrixChannel(
        _make_config(
            allow_from=["@alice:matrix.org"],
            e2ee_enabled=True,
            sas_verification=True,
        ),
        MessageBus(),
    )
    client = _FakeAsyncClient("", "", "", None)
    client.key_verifications["tx1"] = _FakeSas()
    channel.client = client

    await channel._handle_key_verification_event(
        _FakeKeyVerificationStart(sender="@mallory:matrix.org")
    )

    assert client.accept_key_verification_calls == []
    assert client.to_device_calls == []


@pytest.mark.asyncio
async def test_sas_verification_ignores_when_disabled(monkeypatch) -> None:
    _patch_key_verification_events(monkeypatch)
    channel = MatrixChannel(
        _make_config(allow_from=["@alice:matrix.org"], sas_verification=False),
        MessageBus(),
    )
    client = _FakeAsyncClient("", "", "", None)
    client.key_verifications["tx1"] = _FakeSas()
    channel.client = client

    await channel._handle_key_verification_event(_FakeKeyVerificationStart())

    assert client.accept_key_verification_calls == []
    assert client.to_device_calls == []


@pytest.mark.asyncio
async def test_sas_verification_key_confirms_allowed_sender(monkeypatch) -> None:
    _patch_key_verification_events(monkeypatch)
    channel = MatrixChannel(
        _make_config(
            allow_from=["@alice:matrix.org"],
            e2ee_enabled=True,
            sas_verification=True,
        ),
        MessageBus(),
    )
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client

    await channel._handle_key_verification_event(_FakeKeyVerificationKey())

    assert client.send_to_device_messages_calls == 1
    assert client.confirm_short_auth_string_calls == ["tx1"]
    assert client.operation_calls == ["send_pending", "confirm:tx1"]


@pytest.mark.asyncio
async def test_sas_verification_mac_does_not_resend_mac(monkeypatch) -> None:
    _patch_key_verification_events(monkeypatch)
    channel = MatrixChannel(
        _make_config(allow_from=["@alice:matrix.org"], sas_verification=True),
        MessageBus(),
    )
    client = _FakeAsyncClient("", "", "", None)
    sas = _FakeSas(verified=True)
    client.key_verifications["tx1"] = sas
    channel.client = client

    await channel._handle_key_verification_event(_FakeKeyVerificationMac())

    assert sas.get_mac_called is False
    assert client.to_device_calls == []


def test_media_event_filter_does_not_match_text_events() -> None:
    assert not issubclass(matrix_module.RoomMessageText, matrix_module.MATRIX_MEDIA_EVENT_FILTER)


@pytest.mark.asyncio
async def test_start_disables_e2ee_when_configured(
    monkeypatch, tmp_path
) -> None:
    clients: list[_FakeAsyncClient] = []

    def _fake_client(*args, **kwargs) -> _FakeAsyncClient:
        client = _FakeAsyncClient(*args, **kwargs)
        clients.append(client)
        return client

    def _fake_create_task(coro):
        coro.close()
        return _DummyTask()

    monkeypatch.setattr("nanobot.channels.matrix.runtime.get_data_dir", lambda: tmp_path)
    monkeypatch.setattr(
        "nanobot.channels.matrix.runtime.AsyncClientConfig",
        lambda **kwargs: SimpleNamespace(**kwargs),
    )
    monkeypatch.setattr("nanobot.channels.matrix.runtime.AsyncClient", _fake_client)
    monkeypatch.setattr(
        "nanobot.channels.matrix.runtime.asyncio.create_task", _fake_create_task
    )

    channel = MatrixChannel(_make_config(device_id="", e2ee_enabled=False), MessageBus())
    await channel.start()

    assert len(clients) == 1
    assert clients[0].config.encryption_enabled is False

    await channel.stop()


@pytest.mark.asyncio
async def test_on_sync_error_stops_loop_on_unknown_token() -> None:
    channel = MatrixChannel(_make_config(), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client
    channel._running = True

    await channel._on_sync_error(SyncError(message="bad", status_code="M_UNKNOWN_TOKEN"))

    assert channel._running is False
    assert client.stop_sync_forever_called is True


@pytest.mark.asyncio
async def test_on_sync_error_keeps_running_on_transient_error() -> None:
    channel = MatrixChannel(_make_config(), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client
    channel._running = True

    await channel._on_sync_error(SyncError(message="oops", status_code="M_LIMIT_EXCEEDED"))

    assert channel._running is True
    assert client.stop_sync_forever_called is False


@pytest.mark.asyncio
async def test_sync_loop_backs_off_on_repeated_errors(monkeypatch) -> None:
    channel = MatrixChannel(_make_config(), MessageBus())

    sleeps: list[float] = []

    async def _fake_sleep(delay: float) -> None:
        sleeps.append(delay)

    monkeypatch.setattr(matrix_module.asyncio, "sleep", _fake_sleep)

    call_count = {"n": 0}

    class _BoomClient:
        async def sync_forever(self, **_kwargs) -> None:
            call_count["n"] += 1
            if call_count["n"] > 4:
                channel._running = False
                return
            raise RuntimeError("boom")

    channel.client = _BoomClient()
    channel._running = True

    await channel._sync_loop()

    assert sleeps == [2.0, 4.0, 8.0, 16.0]


@pytest.mark.asyncio
async def test_stop_stops_sync_forever_before_close(monkeypatch) -> None:
    channel = MatrixChannel(_make_config(device_id="DEVICE"), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    task = _DummyTask()

    channel.client = client
    channel._sync_task = task
    channel._running = True

    await channel.stop()

    assert channel._running is False
    assert client.stop_sync_forever_called is True
    assert task.cancelled is False


@pytest.mark.asyncio
async def test_room_invite_ignores_when_allow_list_is_empty() -> None:
    channel = MatrixChannel(_make_config(allow_from=[]), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client

    room = SimpleNamespace(room_id="!room:matrix.org")
    event = SimpleNamespace(sender="@alice:matrix.org")

    await channel._on_room_invite(room, event)

    assert client.join_calls == []


@pytest.mark.asyncio
async def test_room_invite_joins_when_sender_allowed() -> None:
    channel = MatrixChannel(_make_config(allow_from=["@alice:matrix.org"]), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client

    room = SimpleNamespace(room_id="!room:matrix.org")
    event = SimpleNamespace(sender="@alice:matrix.org")

    await channel._on_room_invite(room, event)

    assert client.join_calls == ["!room:matrix.org"]


@pytest.mark.asyncio
async def test_room_invite_respects_allow_list_when_configured() -> None:
    channel = MatrixChannel(_make_config(allow_from=["@bob:matrix.org"]), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client

    room = SimpleNamespace(room_id="!room:matrix.org")
    event = SimpleNamespace(sender="@alice:matrix.org")

    await channel._on_room_invite(room, event)

    assert client.join_calls == []


@pytest.mark.asyncio
async def test_on_sync_invite_fallback_joins_pending_invites() -> None:
    """_on_sync_invite_fallback joins rooms from sync invite_state for allowed senders."""
    channel = MatrixChannel(
        _make_config(allow_from=["@alice:matrix.org"]), MessageBus()
    )
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client

    invite_event = SimpleNamespace(sender="@alice:matrix.org")
    invite_info = SimpleNamespace(invite_state=[invite_event])
    rooms = SimpleNamespace(invite={"!room:matrix.org": invite_info})
    response = SimpleNamespace(rooms=rooms)

    await channel._on_sync_invite_fallback(response)

    assert client.join_calls == ["!room:matrix.org"]


@pytest.mark.asyncio
async def test_on_sync_invite_fallback_skips_when_no_invites() -> None:
    """_on_sync_invite_fallback is a no-op when sync has no invites."""
    channel = MatrixChannel(
        _make_config(allow_from=["@alice:matrix.org"]), MessageBus()
    )
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client

    rooms = SimpleNamespace(invite={})
    response = SimpleNamespace(rooms=rooms)

    await channel._on_sync_invite_fallback(response)

    assert client.join_calls == []


@pytest.mark.asyncio
async def test_on_sync_invite_fallback_skips_denied_sender() -> None:
    """_on_sync_invite_fallback respects the allow list."""
    channel = MatrixChannel(
        _make_config(allow_from=["@bob:matrix.org"]), MessageBus()
    )
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client

    invite_event = SimpleNamespace(sender="@alice:matrix.org")
    invite_info = SimpleNamespace(invite_state=[invite_event])
    rooms = SimpleNamespace(invite={"!room:matrix.org": invite_info})
    response = SimpleNamespace(rooms=rooms)

    await channel._on_sync_invite_fallback(response)

    assert client.join_calls == []


@pytest.mark.asyncio
async def test_on_message_sets_typing_for_allowed_sender() -> None:
    channel = MatrixChannel(_make_config(), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client

    handled: list[str] = []

    async def _fake_handle_message(**kwargs) -> None:
        handled.append(kwargs["sender_id"])

    channel._handle_message = _fake_handle_message  # type: ignore[method-assign]

    room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room")
    event = SimpleNamespace(sender="@alice:matrix.org", body="Hello", source={})

    await channel._on_message(room, event)

    assert handled == ["@alice:matrix.org"]
    assert client.typing_calls == [
        ("!room:matrix.org", True, TYPING_NOTICE_TIMEOUT_MS),
    ]


@pytest.mark.asyncio
async def test_typing_keepalive_refreshes_periodically(monkeypatch) -> None:
    channel = MatrixChannel(_make_config(), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client
    channel._running = True

    monkeypatch.setattr(matrix_module, "TYPING_KEEPALIVE_INTERVAL_MS", 10)

    await channel._start_typing_keepalive("!room:matrix.org")
    await asyncio.sleep(0.03)
    await channel._stop_typing_keepalive("!room:matrix.org", clear_typing=True)

    true_updates = [call for call in client.typing_calls if call[1] is True]
    assert len(true_updates) >= 2
    assert client.typing_calls[-1] == ("!room:matrix.org", False, TYPING_NOTICE_TIMEOUT_MS)


@pytest.mark.asyncio
async def test_on_message_skips_typing_for_self_message() -> None:
    channel = MatrixChannel(_make_config(), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client

    room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room")
    event = SimpleNamespace(sender="@bot:matrix.org", body="Hello", source={})

    await channel._on_message(room, event)

    assert client.typing_calls == []


@pytest.mark.asyncio
async def test_on_message_skips_pre_startup_event() -> None:
    channel = MatrixChannel(_make_config(), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client
    channel._started_at_ms = 1_000_000

    handled: list[str] = []

    async def _fake_handle_message(**kwargs) -> None:
        handled.append(kwargs["sender_id"])

    channel._handle_message = _fake_handle_message  # type: ignore[method-assign]

    room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room")
    old_event = SimpleNamespace(
        sender="@alice:matrix.org", body="old", source={}, server_timestamp=999_999
    )
    fresh_event = SimpleNamespace(
        sender="@alice:matrix.org", body="fresh", source={}, server_timestamp=1_000_001
    )

    await channel._on_message(room, old_event)
    await channel._on_message(room, fresh_event)

    assert handled == ["@alice:matrix.org"]
    assert client.typing_calls == [
        ("!room:matrix.org", True, TYPING_NOTICE_TIMEOUT_MS),
    ]


@pytest.mark.asyncio
async def test_on_media_message_skips_pre_startup_event() -> None:
    channel = MatrixChannel(_make_config(), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client
    channel._started_at_ms = 1_000_000

    handled: list[str] = []

    async def _fake_handle_message(**kwargs) -> None:
        handled.append(kwargs["sender_id"])

    channel._handle_message = _fake_handle_message  # type: ignore[method-assign]

    room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room")
    old_event = SimpleNamespace(
        sender="@alice:matrix.org", body="old", source={}, server_timestamp=999_999
    )

    await channel._on_media_message(room, old_event)

    assert handled == []
    assert client.typing_calls == []


@pytest.mark.asyncio
async def test_on_message_skips_typing_for_denied_sender() -> None:
    channel = MatrixChannel(_make_config(allow_from=["@bob:matrix.org"]), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client

    handled: list[str] = []

    async def _fake_handle_message(**kwargs) -> None:
        handled.append(kwargs["sender_id"])

    channel._handle_message = _fake_handle_message  # type: ignore[method-assign]

    room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room")
    event = SimpleNamespace(sender="@alice:matrix.org", body="Hello", source={})

    await channel._on_message(room, event)

    assert handled == []
    assert client.typing_calls == []


@pytest.mark.asyncio
async def test_on_message_mention_policy_requires_mx_mentions() -> None:
    channel = MatrixChannel(_make_config(group_policy="mention"), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client

    handled: list[str] = []

    async def _fake_handle_message(**kwargs) -> None:
        handled.append(kwargs["sender_id"])

    channel._handle_message = _fake_handle_message  # type: ignore[method-assign]

    room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room", member_count=3)
    event = SimpleNamespace(sender="@alice:matrix.org", body="Hello", source={"content": {}})

    await channel._on_message(room, event)

    assert handled == []
    assert client.typing_calls == []


@pytest.mark.asyncio
async def test_on_message_mention_policy_accepts_bot_user_mentions() -> None:
    channel = MatrixChannel(_make_config(group_policy="mention"), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client

    handled: list[str] = []

    async def _fake_handle_message(**kwargs) -> None:
        handled.append(kwargs["sender_id"])

    channel._handle_message = _fake_handle_message  # type: ignore[method-assign]

    room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room", member_count=3)
    event = SimpleNamespace(
        sender="@alice:matrix.org",
        body="Hello",
        source={"content": {"m.mentions": {"user_ids": ["@bot:matrix.org"]}}},
    )

    await channel._on_message(room, event)

    assert handled == ["@alice:matrix.org"]
    assert client.typing_calls == [("!room:matrix.org", True, TYPING_NOTICE_TIMEOUT_MS)]


@pytest.mark.asyncio
async def test_on_message_mention_policy_allows_direct_room_without_mentions() -> None:
    channel = MatrixChannel(_make_config(group_policy="mention"), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client

    handled: list[str] = []

    async def _fake_handle_message(**kwargs) -> None:
        handled.append(kwargs["sender_id"])

    channel._handle_message = _fake_handle_message  # type: ignore[method-assign]

    room = SimpleNamespace(room_id="!dm:matrix.org", display_name="DM", member_count=2)
    event = SimpleNamespace(sender="@alice:matrix.org", body="Hello", source={"content": {}})

    await channel._on_message(room, event)

    assert handled == ["@alice:matrix.org"]
    assert client.typing_calls == [("!dm:matrix.org", True, TYPING_NOTICE_TIMEOUT_MS)]


@pytest.mark.asyncio
async def test_on_message_allowlist_policy_requires_room_id() -> None:
    channel = MatrixChannel(
        _make_config(group_policy="allowlist", group_allow_from=["!allowed:matrix.org"]),
        MessageBus(),
    )
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client

    handled: list[str] = []

    async def _fake_handle_message(**kwargs) -> None:
        handled.append(kwargs["chat_id"])

    channel._handle_message = _fake_handle_message  # type: ignore[method-assign]

    denied_room = SimpleNamespace(room_id="!denied:matrix.org", display_name="Denied", member_count=3)
    event = SimpleNamespace(sender="@alice:matrix.org", body="Hello", source={"content": {}})
    await channel._on_message(denied_room, event)

    allowed_room = SimpleNamespace(
        room_id="!allowed:matrix.org",
        display_name="Allowed",
        member_count=3,
    )
    await channel._on_message(allowed_room, event)

    assert handled == ["!allowed:matrix.org"]
    assert client.typing_calls == [("!allowed:matrix.org", True, TYPING_NOTICE_TIMEOUT_MS)]


@pytest.mark.asyncio
async def test_on_message_room_mention_requires_opt_in() -> None:
    channel = MatrixChannel(_make_config(group_policy="mention"), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client

    handled: list[str] = []

    async def _fake_handle_message(**kwargs) -> None:
        handled.append(kwargs["sender_id"])

    channel._handle_message = _fake_handle_message  # type: ignore[method-assign]

    room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room", member_count=3)
    room_mention_event = SimpleNamespace(
        sender="@alice:matrix.org",
        body="Hello everyone",
        source={"content": {"m.mentions": {"room": True}}},
    )

    channel.config.allow_room_mentions = False
    await channel._on_message(room, room_mention_event)
    assert handled == []
    assert client.typing_calls == []

    channel.config.allow_room_mentions = True
    await channel._on_message(room, room_mention_event)
    assert handled == ["@alice:matrix.org"]
    assert client.typing_calls == [("!room:matrix.org", True, TYPING_NOTICE_TIMEOUT_MS)]


@pytest.mark.asyncio
async def test_on_message_sets_thread_metadata_when_threaded_event() -> None:
    channel = MatrixChannel(_make_config(), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client

    handled: list[dict[str, object]] = []

    async def _fake_handle_message(**kwargs) -> None:
        handled.append(kwargs)

    channel._handle_message = _fake_handle_message  # type: ignore[method-assign]

    room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room", member_count=3)
    event = SimpleNamespace(
        sender="@alice:matrix.org",
        body="Hello",
        event_id="$reply1",
        source={
            "content": {
                "m.relates_to": {
                    "rel_type": "m.thread",
                    "event_id": "$root1",
                }
            }
        },
    )

    await channel._on_message(room, event)

    assert len(handled) == 1
    metadata = handled[0]["metadata"]
    assert metadata["thread_root_event_id"] == "$root1"
    assert metadata["thread_reply_to_event_id"] == "$reply1"
    assert metadata["event_id"] == "$reply1"
    assert handled[0]["session_key"] == "matrix:!room:matrix.org:thread:$root1"


@pytest.mark.asyncio
async def test_on_message_keeps_matrix_thread_sessions_independent() -> None:
    channel = MatrixChannel(_make_config(), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client

    handled: list[dict[str, object]] = []

    async def _fake_handle_message(**kwargs) -> None:
        handled.append(kwargs)

    channel._handle_message = _fake_handle_message  # type: ignore[method-assign]

    room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room", member_count=3)

    def _thread_event(body: str, event_id: str, root_id: str) -> SimpleNamespace:
        return SimpleNamespace(
            sender="@alice:matrix.org",
            body=body,
            event_id=event_id,
            source={
                "content": {
                    "m.relates_to": {
                        "rel_type": "m.thread",
                        "event_id": root_id,
                    }
                }
            },
        )

    await channel._on_message(room, _thread_event("Plan the wedding", "$reply1", "$root1"))
    await channel._on_message(room, _thread_event("Pick a gift", "$reply2", "$root1"))
    await channel._on_message(room, _thread_event("/new", "$reply3", "$root2"))

    assert [message["chat_id"] for message in handled] == [
        "!room:matrix.org",
        "!room:matrix.org",
        "!room:matrix.org",
    ]
    assert [message["session_key"] for message in handled] == [
        "matrix:!room:matrix.org:thread:$root1",
        "matrix:!room:matrix.org:thread:$root1",
        "matrix:!room:matrix.org:thread:$root2",
    ]
    assert handled[2]["content"] == "/new"


@pytest.mark.asyncio
async def test_on_message_keeps_non_threaded_room_session() -> None:
    channel = MatrixChannel(_make_config(), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client

    handled: list[dict[str, object]] = []

    async def _fake_handle_message(**kwargs) -> None:
        handled.append(kwargs)

    channel._handle_message = _fake_handle_message  # type: ignore[method-assign]

    room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room", member_count=3)
    event = SimpleNamespace(
        sender="@alice:matrix.org",
        body="Hello",
        event_id="$event1",
        source={"content": {}},
    )

    await channel._on_message(room, event)

    assert len(handled) == 1
    assert handled[0]["session_key"] is None


@pytest.mark.asyncio
async def test_on_media_message_downloads_attachment_and_sets_metadata(
    monkeypatch, tmp_path
) -> None:
    monkeypatch.setattr("nanobot.channels.matrix.runtime.get_data_dir", lambda: tmp_path)

    channel = MatrixChannel(_make_config(), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    client.download_bytes = b"image"
    channel.client = client

    async def _download_media_bytes(mxc_url: str, limit_bytes: int) -> bytes:
        client.download_calls.append(mxc_url)
        assert limit_bytes >= len(client.download_bytes)
        return client.download_bytes

    monkeypatch.setattr(channel, "_download_media_bytes", _download_media_bytes)

    handled: list[dict[str, object]] = []

    async def _fake_handle_message(**kwargs) -> None:
        handled.append(kwargs)

    channel._handle_message = _fake_handle_message  # type: ignore[method-assign]

    room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room", member_count=2)
    event = SimpleNamespace(
        sender="@alice:matrix.org",
        body="photo.png",
        url="mxc://example.org/mediaid",
        event_id="$event1",
        source={
            "content": {
                "msgtype": "m.image",
                "info": {"mimetype": "image/png", "size": 5},
            }
        },
    )

    await channel._on_media_message(room, event)

    assert len(client.download_calls) == 1
    assert len(handled) == 1
    assert client.typing_calls == [("!room:matrix.org", True, TYPING_NOTICE_TIMEOUT_MS)]

    media_paths = handled[0]["media"]
    assert isinstance(media_paths, list) and len(media_paths) == 1
    media_path = Path(media_paths[0])
    assert media_path.is_file()
    assert media_path.read_bytes() == b"image"

    metadata = handled[0]["metadata"]
    attachments = metadata["attachments"]
    assert isinstance(attachments, list) and len(attachments) == 1
    assert attachments[0]["type"] == "image"
    assert attachments[0]["mxc_url"] == "mxc://example.org/mediaid"
    assert attachments[0]["path"] == str(media_path)
    assert "[attachment: " in handled[0]["content"]


@pytest.mark.asyncio
async def test_on_media_message_sets_thread_metadata_when_threaded_event(
    monkeypatch, tmp_path
) -> None:
    monkeypatch.setattr("nanobot.channels.matrix.runtime.get_data_dir", lambda: tmp_path)

    channel = MatrixChannel(_make_config(), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    client.download_bytes = b"image"
    channel.client = client

    handled: list[dict[str, object]] = []

    async def _fake_handle_message(**kwargs) -> None:
        handled.append(kwargs)

    channel._handle_message = _fake_handle_message  # type: ignore[method-assign]

    room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room", member_count=2)
    event = SimpleNamespace(
        sender="@alice:matrix.org",
        body="photo.png",
        url="mxc://example.org/mediaid",
        event_id="$event1",
        source={
            "content": {
                "msgtype": "m.image",
                "info": {"mimetype": "image/png", "size": 5},
                "m.relates_to": {
                    "rel_type": "m.thread",
                    "event_id": "$root1",
                },
            }
        },
    )

    await channel._on_media_message(room, event)

    assert len(handled) == 1
    metadata = handled[0]["metadata"]
    assert metadata["thread_root_event_id"] == "$root1"
    assert metadata["thread_reply_to_event_id"] == "$event1"
    assert metadata["event_id"] == "$event1"
    assert handled[0]["session_key"] == "matrix:!room:matrix.org:thread:$root1"


@pytest.mark.asyncio
async def test_on_media_message_respects_declared_size_limit(
    monkeypatch, tmp_path
) -> None:
    monkeypatch.setattr("nanobot.channels.matrix.runtime.get_data_dir", lambda: tmp_path)

    channel = MatrixChannel(_make_config(max_media_bytes=3), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client

    handled: list[dict[str, object]] = []

    async def _fake_handle_message(**kwargs) -> None:
        handled.append(kwargs)

    channel._handle_message = _fake_handle_message  # type: ignore[method-assign]

    room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room", member_count=2)
    event = SimpleNamespace(
        sender="@alice:matrix.org",
        body="large.bin",
        url="mxc://example.org/large",
        event_id="$event2",
        source={"content": {"msgtype": "m.file", "info": {"size": 10}}},
    )

    await channel._on_media_message(room, event)

    assert client.download_calls == []
    assert len(handled) == 1
    assert handled[0]["media"] == []
    assert handled[0]["metadata"]["attachments"] == []
    assert "[attachment: large.bin - too large]" in handled[0]["content"]


@pytest.mark.asyncio
async def test_on_media_message_uses_server_limit_when_smaller_than_local_limit(
    monkeypatch, tmp_path
) -> None:
    monkeypatch.setattr("nanobot.channels.matrix.runtime.get_data_dir", lambda: tmp_path)

    channel = MatrixChannel(_make_config(max_media_bytes=10), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    client.content_repository_config_response = SimpleNamespace(upload_size=3)
    channel.client = client

    handled: list[dict[str, object]] = []

    async def _fake_handle_message(**kwargs) -> None:
        handled.append(kwargs)

    channel._handle_message = _fake_handle_message  # type: ignore[method-assign]

    room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room", member_count=2)
    event = SimpleNamespace(
        sender="@alice:matrix.org",
        body="large.bin",
        url="mxc://example.org/large",
        event_id="$event2_server",
        source={"content": {"msgtype": "m.file", "info": {"size": 5}}},
    )

    await channel._on_media_message(room, event)

    assert client.download_calls == []
    assert len(handled) == 1
    assert handled[0]["media"] == []
    assert handled[0]["metadata"]["attachments"] == []
    assert "[attachment: large.bin - too large]" in handled[0]["content"]


@pytest.mark.asyncio
async def test_on_media_message_handles_download_error(monkeypatch, tmp_path) -> None:
    monkeypatch.setattr("nanobot.channels.matrix.runtime.get_data_dir", lambda: tmp_path)

    channel = MatrixChannel(_make_config(), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client

    async def _download_media_bytes(mxc_url: str, _limit_bytes: int):
        client.download_calls.append(mxc_url)
        return None

    monkeypatch.setattr(channel, "_download_media_bytes", _download_media_bytes)

    handled: list[dict[str, object]] = []

    async def _fake_handle_message(**kwargs) -> None:
        handled.append(kwargs)

    channel._handle_message = _fake_handle_message  # type: ignore[method-assign]

    room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room", member_count=2)
    event = SimpleNamespace(
        sender="@alice:matrix.org",
        body="photo.png",
        url="mxc://example.org/mediaid",
        event_id="$event3",
        source={"content": {"msgtype": "m.image", "info": {"size": 5}}},
    )

    await channel._on_media_message(room, event)

    assert len(client.download_calls) == 1
    assert len(handled) == 1
    assert handled[0]["media"] == []
    assert handled[0]["metadata"]["attachments"] == []
    assert "[attachment: photo.png - download failed]" in handled[0]["content"]


@pytest.mark.asyncio
async def test_on_media_message_decrypts_encrypted_media(monkeypatch, tmp_path) -> None:
    monkeypatch.setattr("nanobot.channels.matrix.runtime.get_data_dir", lambda: tmp_path)
    monkeypatch.setattr(
        matrix_module,
        "decrypt_attachment",
        lambda ciphertext, key, sha256, iv: b"plain",
    )

    channel = MatrixChannel(_make_config(), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    client.download_bytes = b"cipher"
    channel.client = client

    async def _download_media_bytes(mxc_url: str, limit_bytes: int) -> bytes:
        client.download_calls.append(mxc_url)
        assert limit_bytes >= len(client.download_bytes)
        return client.download_bytes

    monkeypatch.setattr(channel, "_download_media_bytes", _download_media_bytes)

    handled: list[dict[str, object]] = []

    async def _fake_handle_message(**kwargs) -> None:
        handled.append(kwargs)

    channel._handle_message = _fake_handle_message  # type: ignore[method-assign]

    room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room", member_count=2)
    event = SimpleNamespace(
        sender="@alice:matrix.org",
        body="secret.txt",
        url="mxc://example.org/encrypted",
        event_id="$event4",
        key={"k": "key"},
        hashes={"sha256": "hash"},
        iv="iv",
        source={"content": {"msgtype": "m.file", "info": {"size": 6}}},
    )

    await channel._on_media_message(room, event)

    assert len(handled) == 1
    media_path = Path(handled[0]["media"][0])
    assert media_path.read_bytes() == b"plain"
    attachment = handled[0]["metadata"]["attachments"][0]
    assert attachment["encrypted"] is True
    assert attachment["size_bytes"] == 5


@pytest.mark.asyncio
async def test_on_media_message_handles_decrypt_error(monkeypatch, tmp_path) -> None:
    monkeypatch.setattr("nanobot.channels.matrix.runtime.get_data_dir", lambda: tmp_path)

    def _raise(*args, **kwargs):
        raise matrix_module.EncryptionError("boom")

    monkeypatch.setattr(matrix_module, "decrypt_attachment", _raise)

    channel = MatrixChannel(_make_config(), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    client.download_bytes = b"cipher"
    channel.client = client

    async def _download_media_bytes(mxc_url: str, limit_bytes: int) -> bytes:
        client.download_calls.append(mxc_url)
        assert limit_bytes >= len(client.download_bytes)
        return client.download_bytes

    monkeypatch.setattr(channel, "_download_media_bytes", _download_media_bytes)

    handled: list[dict[str, object]] = []

    async def _fake_handle_message(**kwargs) -> None:
        handled.append(kwargs)

    channel._handle_message = _fake_handle_message  # type: ignore[method-assign]

    room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room", member_count=2)
    event = SimpleNamespace(
        sender="@alice:matrix.org",
        body="secret.txt",
        url="mxc://example.org/encrypted",
        event_id="$event5",
        key={"k": "key"},
        hashes={"sha256": "hash"},
        iv="iv",
        source={"content": {"msgtype": "m.file", "info": {"size": 6}}},
    )

    await channel._on_media_message(room, event)

    assert len(handled) == 1
    assert handled[0]["media"] == []
    assert handled[0]["metadata"]["attachments"] == []
    assert "[attachment: secret.txt - download failed]" in handled[0]["content"]


@pytest.mark.asyncio
async def test_send_clears_typing_after_send() -> None:
    channel = MatrixChannel(_make_config(e2ee_enabled=True), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client

    await channel.send(
        OutboundMessage(channel="matrix", chat_id="!room:matrix.org", content="Hi")
    )

    assert len(client.room_send_calls) == 1
    assert client.room_send_calls[0]["content"] == {
        "msgtype": "m.text",
        "body": "Hi",
        "m.mentions": {},
    }
    assert client.room_send_calls[0]["ignore_unverified_devices"] is True
    assert client.typing_calls[-1] == ("!room:matrix.org", False, TYPING_NOTICE_TIMEOUT_MS)


@pytest.mark.asyncio
async def test_send_uploads_media_and_sends_file_event(tmp_path) -> None:
    channel = MatrixChannel(_make_config(), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client

    file_path = tmp_path / "test.txt"
    file_path.write_text("hello", encoding="utf-8")

    await channel.send(
        OutboundMessage(
            channel="matrix",
            chat_id="!room:matrix.org",
            content="Please review.",
            media=[str(file_path)],
        )
    )

    assert len(client.upload_calls) == 1
    assert not isinstance(client.upload_calls[0]["data_provider"], (bytes, bytearray))
    assert hasattr(client.upload_calls[0]["data_provider"], "read")
    assert client.upload_calls[0]["filename"] == "test.txt"
    assert client.upload_calls[0]["filesize"] == 5
    assert len(client.room_send_calls) == 2
    assert client.room_send_calls[0]["content"]["msgtype"] == "m.file"
    assert client.room_send_calls[0]["content"]["url"] == "mxc://example.org/uploaded"
    assert client.room_send_calls[1]["content"]["body"] == "Please review."


@pytest.mark.asyncio
async def test_send_adds_thread_relates_to_for_thread_metadata() -> None:
    channel = MatrixChannel(_make_config(), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client

    metadata = {
        "thread_root_event_id": "$root1",
        "thread_reply_to_event_id": "$reply1",
    }
    await channel.send(
        OutboundMessage(
            channel="matrix",
            chat_id="!room:matrix.org",
            content="Hi",
            metadata=metadata,
        )
    )

    content = client.room_send_calls[0]["content"]
    assert content["m.relates_to"] == {
        "rel_type": "m.thread",
        "event_id": "$root1",
        "m.in_reply_to": {"event_id": "$reply1"},
        "is_falling_back": True,
    }


@pytest.mark.asyncio
async def test_send_uses_encrypted_media_payload_in_encrypted_room(tmp_path) -> None:
    channel = MatrixChannel(_make_config(e2ee_enabled=True), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    client.rooms["!encrypted:matrix.org"] = SimpleNamespace(encrypted=True)
    channel.client = client

    file_path = tmp_path / "secret.txt"
    file_path.write_text("topsecret", encoding="utf-8")

    await channel.send(
        OutboundMessage(
            channel="matrix",
            chat_id="!encrypted:matrix.org",
            content="",
            media=[str(file_path)],
        )
    )

    assert len(client.upload_calls) == 1
    assert client.upload_calls[0]["encrypt"] is True
    assert len(client.room_send_calls) == 1
    content = client.room_send_calls[0]["content"]
    assert content["msgtype"] == "m.file"
    assert "file" in content
    assert "url" not in content
    assert content["file"]["url"] == "mxc://example.org/uploaded"
    assert content["file"]["hashes"]["sha256"] == "hash"


@pytest.mark.asyncio
async def test_send_does_not_parse_attachment_marker_without_media(tmp_path) -> None:
    channel = MatrixChannel(_make_config(), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client

    missing_path = tmp_path / "missing.txt"
    await channel.send(
        OutboundMessage(
            channel="matrix",
            chat_id="!room:matrix.org",
            content=f"[attachment: {missing_path}]",
        )
    )

    assert client.upload_calls == []
    assert len(client.room_send_calls) == 1
    assert client.room_send_calls[0]["content"]["body"] == f"[attachment: {missing_path}]"


@pytest.mark.asyncio
async def test_send_passes_thread_relates_to_to_attachment_upload(monkeypatch) -> None:
    channel = MatrixChannel(_make_config(), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client
    channel._server_upload_limit_checked = True
    channel._server_upload_limit_bytes = None

    captured: dict[str, object] = {}

    async def _fake_upload_and_send_attachment(
        *,
        room_id: str,
        path: Path,
        limit_bytes: int,
        relates_to: dict[str, object] | None = None,
    ) -> str | None:
        captured["relates_to"] = relates_to
        return None

    monkeypatch.setattr(channel, "_upload_and_send_attachment", _fake_upload_and_send_attachment)

    metadata = {
        "thread_root_event_id": "$root1",
        "thread_reply_to_event_id": "$reply1",
    }
    await channel.send(
        OutboundMessage(
            channel="matrix",
            chat_id="!room:matrix.org",
            content="Hi",
            media=["/tmp/fake.txt"],
            metadata=metadata,
        )
    )

    assert captured["relates_to"] == {
        "rel_type": "m.thread",
        "event_id": "$root1",
        "m.in_reply_to": {"event_id": "$reply1"},
        "is_falling_back": True,
    }


@pytest.mark.asyncio
async def test_send_workspace_restriction_blocks_external_attachment(tmp_path) -> None:
    workspace = tmp_path / "workspace"
    workspace.mkdir()
    file_path = tmp_path / "external.txt"
    file_path.write_text("outside", encoding="utf-8")

    channel = MatrixChannel(
        _make_config(),
        MessageBus(),
        restrict_to_workspace=True,
        workspace=workspace,
    )
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client

    await channel.send(
        OutboundMessage(
            channel="matrix",
            chat_id="!room:matrix.org",
            content="",
            media=[str(file_path)],
        )
    )

    assert client.upload_calls == []
    assert len(client.room_send_calls) == 1
    assert client.room_send_calls[0]["content"]["body"] == "[attachment: external.txt - upload failed]"


@pytest.mark.asyncio
async def test_send_handles_upload_exception_and_reports_failure(tmp_path) -> None:
    channel = MatrixChannel(_make_config(), MessageBus())
    channel.logger = MagicMock()
    client = _FakeAsyncClient("", "", "", None)
    client.raise_on_upload = True
    channel.client = client

    file_path = tmp_path / "broken.txt"
    file_path.write_text("hello", encoding="utf-8")

    await channel.send(
        OutboundMessage(
            channel="matrix",
            chat_id="!room:matrix.org",
            content="Please review.",
            media=[str(file_path)],
        )
    )

    assert len(client.upload_calls) == 0
    assert len(client.room_send_calls) == 1
    assert (
        client.room_send_calls[0]["content"]["body"]
        == "Please review.\n[attachment: broken.txt - upload failed]"
    )
    channel.logger.error.assert_called_once_with(
        "Matrix media upload failed for {}", "broken.txt", exc_info=True
    )


@pytest.mark.asyncio
async def test_attachment_room_send_error_logs_room_id(tmp_path) -> None:
    channel = MatrixChannel(_make_config(), MessageBus())
    channel.logger = MagicMock()
    client = _FakeAsyncClient("", "", "", None)
    client.raise_on_send = True
    channel.client = client

    file_path = tmp_path / "report.txt"
    file_path.write_text("hello", encoding="utf-8")

    failure = await channel._upload_and_send_attachment(
        room_id="!room:matrix.org",
        path=file_path,
        limit_bytes=1024,
    )

    assert failure == "[attachment: report.txt - upload failed]"
    channel.logger.error.assert_called_once_with(
        "Matrix room content send failed for room_id={}",
        "!room:matrix.org",
        exc_info=True,
    )


@pytest.mark.asyncio
async def test_send_uses_server_upload_limit_when_smaller_than_local_limit(tmp_path) -> None:
    channel = MatrixChannel(_make_config(max_media_bytes=10), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    client.content_repository_config_response = SimpleNamespace(upload_size=3)
    channel.client = client

    file_path = tmp_path / "tiny.txt"
    file_path.write_text("hello", encoding="utf-8")

    await channel.send(
        OutboundMessage(
            channel="matrix",
            chat_id="!room:matrix.org",
            content="",
            media=[str(file_path)],
        )
    )

    assert client.upload_calls == []
    assert len(client.room_send_calls) == 1
    assert client.room_send_calls[0]["content"]["body"] == "[attachment: tiny.txt - too large]"


@pytest.mark.asyncio
async def test_send_blocks_all_outbound_media_when_limit_is_zero(tmp_path) -> None:
    channel = MatrixChannel(_make_config(max_media_bytes=0), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client

    file_path = tmp_path / "empty.txt"
    file_path.write_bytes(b"")

    await channel.send(
        OutboundMessage(
            channel="matrix",
            chat_id="!room:matrix.org",
            content="",
            media=[str(file_path)],
        )
    )

    assert client.upload_calls == []
    assert len(client.room_send_calls) == 1
    assert client.room_send_calls[0]["content"]["body"] == "[attachment: empty.txt - too large]"


@pytest.mark.asyncio
async def test_send_omits_ignore_unverified_devices_when_e2ee_disabled() -> None:
    channel = MatrixChannel(_make_config(e2ee_enabled=False), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client

    await channel.send(
        OutboundMessage(channel="matrix", chat_id="!room:matrix.org", content="Hi")
    )

    assert len(client.room_send_calls) == 1
    assert "ignore_unverified_devices" not in client.room_send_calls[0]


@pytest.mark.asyncio
async def test_send_stops_typing_keepalive_task() -> None:
    channel = MatrixChannel(_make_config(), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client
    channel._running = True

    await channel._start_typing_keepalive("!room:matrix.org")
    assert "!room:matrix.org" in channel._typing_tasks

    await channel.send(
        OutboundMessage(channel="matrix", chat_id="!room:matrix.org", content="Hi")
    )

    assert "!room:matrix.org" not in channel._typing_tasks
    assert client.typing_calls[-1] == ("!room:matrix.org", False, TYPING_NOTICE_TIMEOUT_MS)


@pytest.mark.asyncio
async def test_send_progress_keeps_typing_keepalive_running() -> None:
    channel = MatrixChannel(_make_config(), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client
    channel._running = True

    await channel._start_typing_keepalive("!room:matrix.org")
    assert "!room:matrix.org" in channel._typing_tasks

    await channel.send(
        OutboundMessage(
            channel="matrix",
            chat_id="!room:matrix.org",
            content="working...",
            event=ProgressEvent(content="working..."),
        )
    )

    assert "!room:matrix.org" in channel._typing_tasks
    assert client.typing_calls[-1] == ("!room:matrix.org", True, TYPING_NOTICE_TIMEOUT_MS)

    await channel.stop()


@pytest.mark.asyncio
async def test_send_empty_content_does_not_call_room_send() -> None:
    """Progress messages with empty content must not produce an empty body: '' event."""
    channel = MatrixChannel(_make_config(), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client

    await channel.send(
        OutboundMessage(
            channel="matrix",
            chat_id="!room:matrix.org",
            content="",
            event=ProgressEvent(),
        )
    )

    assert client.room_send_calls == []


@pytest.mark.asyncio
async def test_send_whitespace_only_content_does_not_call_room_send() -> None:
    """Progress messages with whitespace-only content must not produce an empty message."""
    channel = MatrixChannel(_make_config(), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client

    await channel.send(
        OutboundMessage(
            channel="matrix",
            chat_id="!room:matrix.org",
            content="   \n\n  ",
            event=ProgressEvent(content="   \n\n  "),
        )
    )

    assert client.room_send_calls == []


@pytest.mark.asyncio
async def test_send_clears_typing_when_send_fails() -> None:
    channel = MatrixChannel(_make_config(), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    client.raise_on_send = True
    channel.client = client

    with pytest.raises(RuntimeError, match="send failed"):
        await channel.send(
            OutboundMessage(channel="matrix", chat_id="!room:matrix.org", content="Hi")
        )

    assert client.typing_calls[-1] == ("!room:matrix.org", False, TYPING_NOTICE_TIMEOUT_MS)


@pytest.mark.asyncio
async def test_send_adds_formatted_body_for_markdown() -> None:
    channel = MatrixChannel(_make_config(), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client

    markdown_text = "# Headline\n\n- [x] done\n\n| A | B |\n| - | - |\n| 1 | 2 |"
    await channel.send(
        OutboundMessage(channel="matrix", chat_id="!room:matrix.org", content=markdown_text)
    )

    content = client.room_send_calls[0]["content"]
    assert content["msgtype"] == "m.text"
    assert content["body"] == markdown_text
    assert content["m.mentions"] == {}
    assert content["format"] == MATRIX_HTML_FORMAT
    assert "<h1>Headline</h1>" in str(content["formatted_body"])
    assert "<table>" in str(content["formatted_body"])
    assert "<li>[x] done</li>" in str(content["formatted_body"])


@pytest.mark.asyncio
async def test_send_adds_formatted_body_for_inline_url_superscript_subscript() -> None:
    channel = MatrixChannel(_make_config(), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client

    markdown_text = "Visit https://example.com and x^2^ plus H~2~O."
    await channel.send(
        OutboundMessage(channel="matrix", chat_id="!room:matrix.org", content=markdown_text)
    )

    content = client.room_send_calls[0]["content"]
    assert content["msgtype"] == "m.text"
    assert content["body"] == markdown_text
    assert content["m.mentions"] == {}
    assert content["format"] == MATRIX_HTML_FORMAT
    assert '<a href="https://example.com" rel="noopener noreferrer">' in str(
        content["formatted_body"]
    )
    assert "<sup>2</sup>" in str(content["formatted_body"])
    assert "<sub>2</sub>" in str(content["formatted_body"])


@pytest.mark.asyncio
async def test_send_sanitizes_disallowed_link_scheme() -> None:
    channel = MatrixChannel(_make_config(), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client

    markdown_text = "[click](javascript:alert(1))"
    await channel.send(
        OutboundMessage(channel="matrix", chat_id="!room:matrix.org", content=markdown_text)
    )

    formatted_body = str(client.room_send_calls[0]["content"]["formatted_body"])
    assert "javascript:" not in formatted_body
    assert "<a" in formatted_body
    assert "href=" not in formatted_body


def test_matrix_html_cleaner_strips_event_handlers_and_script_tags() -> None:
    dirty_html = '<a href="https://example.com" onclick="evil()">x</a><script>alert(1)</script>'
    cleaned_html = matrix_module.MATRIX_HTML_CLEANER.clean(dirty_html)

    assert "<script" not in cleaned_html
    assert "onclick=" not in cleaned_html
    assert '<a href="https://example.com"' in cleaned_html


@pytest.mark.asyncio
async def test_send_keeps_only_mxc_image_sources() -> None:
    channel = MatrixChannel(_make_config(), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client

    markdown_text = "![ok](mxc://example.org/mediaid) ![no](https://example.com/a.png)"
    await channel.send(
        OutboundMessage(channel="matrix", chat_id="!room:matrix.org", content=markdown_text)
    )

    formatted_body = str(client.room_send_calls[0]["content"]["formatted_body"])
    assert 'src="mxc://example.org/mediaid"' in formatted_body
    assert 'src="https://example.com/a.png"' not in formatted_body


@pytest.mark.asyncio
async def test_send_falls_back_to_plaintext_when_markdown_render_fails(monkeypatch) -> None:
    channel = MatrixChannel(_make_config(), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client

    def _raise(text: str) -> str:
        raise RuntimeError("boom")

    monkeypatch.setattr(matrix_module, "MATRIX_MARKDOWN", _raise)
    markdown_text = "# Headline"
    await channel.send(
        OutboundMessage(channel="matrix", chat_id="!room:matrix.org", content=markdown_text)
    )

    content = client.room_send_calls[0]["content"]
    assert content == {"msgtype": "m.text", "body": markdown_text, "m.mentions": {}}


@pytest.mark.asyncio
async def test_send_keeps_plaintext_only_for_plain_text() -> None:
    channel = MatrixChannel(_make_config(), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client

    text = "just a normal sentence without markdown markers"
    await channel.send(
        OutboundMessage(channel="matrix", chat_id="!room:matrix.org", content=text)
    )

    assert client.room_send_calls[0]["content"] == {
        "msgtype": "m.text",
        "body": text,
        "m.mentions": {},
    }


def test_build_matrix_text_content_basic_text() -> None:
    """Test basic text content without HTML formatting."""
    result = _build_matrix_text_content("Hello, World!")
    expected = {
        "msgtype": "m.text",
        "body": "Hello, World!",
        "m.mentions": {}
    }
    assert expected == result


def test_build_matrix_text_content_with_markdown() -> None:
    """Test text content with markdown that renders to HTML."""
    text = "*Hello* **World**"
    result = _build_matrix_text_content(text)
    assert "msgtype" in result
    assert "body" in result
    assert result["body"] == text
    assert "format" in result
    assert result["format"] == "org.matrix.custom.html"
    assert "formatted_body" in result
    assert isinstance(result["formatted_body"], str)
    assert len(result["formatted_body"]) > 0


def test_build_matrix_text_content_with_event_id() -> None:
    """Test text content with event_id for message replacement."""
    event_id = "$8E2XVyINbEhcuAxvxd1d9JhQosNPzkVoU8TrbCAvyHo"
    result = _build_matrix_text_content("Updated message", event_id)
    assert "msgtype" in result
    assert "body" in result
    assert result["m.new_content"]
    assert result["m.new_content"]["body"] == "Updated message"
    assert result["m.relates_to"]["rel_type"] == "m.replace"
    assert result["m.relates_to"]["event_id"] == event_id


def test_build_matrix_text_content_with_event_id_preserves_thread_relation() -> None:
    """Thread relations for edits should stay inside m.new_content."""
    relates_to = {
        "rel_type": "m.thread",
        "event_id": "$root1",
        "m.in_reply_to": {"event_id": "$reply1"},
        "is_falling_back": True,
    }
    result = _build_matrix_text_content("Updated message", "event-1", relates_to)

    assert result["m.relates_to"] == {
        "rel_type": "m.replace",
        "event_id": "event-1",
    }
    assert result["m.new_content"]["m.relates_to"] == relates_to


def test_build_matrix_text_content_no_event_id() -> None:
    """Test that when event_id is not provided, no extra properties are added."""
    result = _build_matrix_text_content("Regular message")

    # Basic required properties should be present
    assert "msgtype" in result
    assert "body" in result
    assert result["body"] == "Regular message"

    # Extra properties for replacement should NOT be present
    assert "m.relates_to" not in result
    assert "m.new_content" not in result
    assert "format" not in result
    assert "formatted_body" not in result


def test_build_matrix_text_content_plain_text_no_html() -> None:
    """Test plain text that should not include HTML formatting."""
    result = _build_matrix_text_content("Simple plain text")
    assert "msgtype" in result
    assert "body" in result
    assert "format" not in result
    assert "formatted_body" not in result


@pytest.mark.asyncio
async def test_send_room_content_returns_room_send_response():
    """Test that _send_room_content returns the response from client.room_send."""
    client = _FakeAsyncClient("", "", "", None)
    channel = MatrixChannel(_make_config(), MessageBus())
    channel.client = client

    room_id = "!test_room:matrix.org"
    content = {"msgtype": "m.text", "body": "Hello World"}

    result = await channel._send_room_content(room_id, content)

    assert result is client.room_send_response


@pytest.mark.asyncio
async def test_send_raises_when_room_send_returns_error(monkeypatch) -> None:
    class _FakeRoomSendError:
        def __str__(self) -> str:
            return "temporary homeserver failure"

    client = _FakeAsyncClient("", "", "", None)
    client.room_send_response = _FakeRoomSendError()
    channel = MatrixChannel(_make_config(), MessageBus())
    channel.client = client
    monkeypatch.setattr(matrix_module, "RoomSendError", _FakeRoomSendError)

    with pytest.raises(RuntimeError, match="temporary homeserver failure"):
        await channel.send(
            OutboundMessage(
                channel="matrix",
                chat_id="!room:matrix.org",
                content="hello",
            )
        )


@pytest.mark.asyncio
async def test_send_delta_creates_stream_buffer_and_sends_initial_message() -> None:
    channel = MatrixChannel(_make_config(), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client
    client.room_send_response.event_id = "$8E2XVyINbEhcuAxvxd1d9JhQosNPzkVoU8TrbCAvyHo"

    await channel.send_delta("!room:matrix.org", "Hello")

    assert "!room:matrix.org" in channel._stream_bufs
    buf = channel._stream_bufs["!room:matrix.org"]
    assert buf.text == "Hello"
    assert buf.event_id == "$8E2XVyINbEhcuAxvxd1d9JhQosNPzkVoU8TrbCAvyHo"
    assert len(client.room_send_calls) == 1
    assert client.room_send_calls[0]["content"]["body"] == "Hello"


@pytest.mark.asyncio
async def test_send_delta_appends_without_sending_before_edit_interval(monkeypatch) -> None:
    channel = MatrixChannel(_make_config(), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client
    client.room_send_response.event_id = "$8E2XVyINbEhcuAxvxd1d9JhQosNPzkVoU8TrbCAvyHo"

    now = 100.0
    monkeypatch.setattr(channel, "monotonic_time", lambda: now)

    await channel.send_delta("!room:matrix.org", "Hello")
    assert len(client.room_send_calls) == 1

    await channel.send_delta("!room:matrix.org", " world")
    assert len(client.room_send_calls) == 1

    buf = channel._stream_bufs["!room:matrix.org"]
    assert buf.text == "Hello world"
    assert buf.event_id == "$8E2XVyINbEhcuAxvxd1d9JhQosNPzkVoU8TrbCAvyHo"


@pytest.mark.asyncio
async def test_send_delta_edits_again_after_interval(monkeypatch) -> None:
    channel = MatrixChannel(_make_config(), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client
    client.room_send_response.event_id = "$8E2XVyINbEhcuAxvxd1d9JhQosNPzkVoU8TrbCAvyHo"

    times = [100.0, 102.0, 104.0, 106.0, 108.0]
    times.reverse()
    monkeypatch.setattr(channel, "monotonic_time", lambda: times and times.pop())

    await channel.send_delta("!room:matrix.org", "Hello")
    await channel.send_delta("!room:matrix.org", " world")

    assert len(client.room_send_calls) == 2
    first_content = client.room_send_calls[0]["content"]
    second_content = client.room_send_calls[1]["content"]

    assert "body" in first_content
    assert first_content["body"] == "Hello"
    assert "m.relates_to" not in first_content

    assert "body" in second_content
    assert "m.relates_to" in second_content
    assert second_content["body"] == "Hello world"
    assert second_content["m.relates_to"] == {
        "rel_type": "m.replace",
        "event_id": "$8E2XVyINbEhcuAxvxd1d9JhQosNPzkVoU8TrbCAvyHo",
    }


@pytest.mark.asyncio
async def test_send_delta_stream_end_replaces_existing_message() -> None:
    channel = MatrixChannel(_make_config(), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client

    channel._stream_bufs["!room:matrix.org"] = matrix_module._StreamBuf(
        text="Final text",
        event_id="event-1",
        last_edit=100.0,
    )

    await channel.send_delta("!room:matrix.org", "", stream_end=True)

    assert "!room:matrix.org" not in channel._stream_bufs
    assert client.typing_calls[-1] == ("!room:matrix.org", False, TYPING_NOTICE_TIMEOUT_MS)
    assert len(client.room_send_calls) == 1
    assert client.room_send_calls[0]["content"]["body"] == "Final text"
    assert client.room_send_calls[0]["content"]["m.relates_to"] == {
        "rel_type": "m.replace",
        "event_id": "event-1",
    }


@pytest.mark.asyncio
async def test_send_delta_merge_next_preserves_buffer() -> None:
    channel = MatrixChannel(_make_config(), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client
    channel._stream_bufs["!room:matrix.org"] = matrix_module._StreamBuf(
        text="first-",
        event_id="event-1",
        last_edit=100.0,
    )
    channel.monotonic_time = lambda: 100.1

    await channel.send_delta(
        "!room:matrix.org",
        "boundary",
        stream_end=True,
        merge_next=True,
    )

    assert channel._stream_bufs["!room:matrix.org"].text == "first-boundary"
    assert client.room_send_calls == []


@pytest.mark.asyncio
async def test_send_delta_keeps_same_room_stream_ids_independent(monkeypatch) -> None:
    channel = MatrixChannel(_make_config(), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client

    event_ids = ["event-a", "event-b"]

    async def _send_room_content(room_id, content):
        client.room_send_calls.append({"room_id": room_id, "content": content})
        return SimpleNamespace(event_id=event_ids.pop(0) if event_ids else "event-final")

    monkeypatch.setattr(channel, "_send_room_content", _send_room_content)

    await channel.send_delta("!room:matrix.org", "A", stream_id="stream-a")
    await channel.send_delta("!room:matrix.org", "B", stream_id="stream-b")
    await channel.send_delta("!room:matrix.org", "1", stream_id="stream-a")
    await channel.send_delta("!room:matrix.org", "2", stream_id="stream-b")

    await channel.send_delta("!room:matrix.org", "", stream_id="stream-a", stream_end=True)
    await channel.send_delta("!room:matrix.org", "", stream_id="stream-b", stream_end=True)

    final_a = client.room_send_calls[-2]["content"]
    final_b = client.room_send_calls[-1]["content"]
    assert final_a["body"] == "A1"
    assert final_a["m.relates_to"]["event_id"] == "event-a"
    assert final_b["body"] == "B2"
    assert final_b["m.relates_to"]["event_id"] == "event-b"


@pytest.mark.asyncio
async def test_send_delta_starts_threaded_stream_inside_thread() -> None:
    channel = MatrixChannel(_make_config(), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client
    client.room_send_response.event_id = "event-1"

    metadata = {
        "thread_root_event_id": "$root1",
        "thread_reply_to_event_id": "$reply1",
    }
    await channel.send_delta("!room:matrix.org", "Hello", metadata)

    assert client.room_send_calls[0]["content"]["m.relates_to"] == {
        "rel_type": "m.thread",
        "event_id": "$root1",
        "m.in_reply_to": {"event_id": "$reply1"},
        "is_falling_back": True,
    }


@pytest.mark.asyncio
async def test_send_delta_threaded_edit_keeps_replace_and_thread_relation(monkeypatch) -> None:
    channel = MatrixChannel(_make_config(), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client
    client.room_send_response.event_id = "event-1"

    times = [100.0, 102.0, 104.0]
    times.reverse()
    monkeypatch.setattr(channel, "monotonic_time", lambda: times and times.pop())

    metadata = {
        "thread_root_event_id": "$root1",
        "thread_reply_to_event_id": "$reply1",
    }
    await channel.send_delta("!room:matrix.org", "Hello", metadata)
    await channel.send_delta("!room:matrix.org", " world", metadata)
    await channel.send_delta("!room:matrix.org", "", metadata, stream_end=True)

    edit_content = client.room_send_calls[1]["content"]
    final_content = client.room_send_calls[2]["content"]

    assert edit_content["m.relates_to"] == {
        "rel_type": "m.replace",
        "event_id": "event-1",
    }
    assert edit_content["m.new_content"]["m.relates_to"] == {
        "rel_type": "m.thread",
        "event_id": "$root1",
        "m.in_reply_to": {"event_id": "$reply1"},
        "is_falling_back": True,
    }
    assert final_content["m.relates_to"] == {
        "rel_type": "m.replace",
        "event_id": "event-1",
    }
    assert final_content["m.new_content"]["m.relates_to"] == {
        "rel_type": "m.thread",
        "event_id": "$root1",
        "m.in_reply_to": {"event_id": "$reply1"},
        "is_falling_back": True,
    }


@pytest.mark.asyncio
async def test_send_delta_stream_end_noop_when_buffer_missing() -> None:
    channel = MatrixChannel(_make_config(), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client

    await channel.send_delta("!room:matrix.org", "", stream_end=True)

    assert client.room_send_calls == []
    assert client.typing_calls == []


@pytest.mark.asyncio
async def test_send_delta_on_error_stops_typing(monkeypatch) -> None:
    channel = MatrixChannel(_make_config(), MessageBus())
    channel.logger = MagicMock()
    client = _FakeAsyncClient("", "", "", None)
    client.raise_on_send = True
    channel.client = client

    now = 100.0
    monkeypatch.setattr(channel, "monotonic_time", lambda: now)

    await channel.send_delta("!room:matrix.org", "Hello", {"room_id": "!room:matrix.org"})

    assert "!room:matrix.org" in channel._stream_bufs
    assert channel._stream_bufs["!room:matrix.org"].text == "Hello"
    assert len(client.room_send_calls) == 1

    assert len(client.typing_calls) == 1
    channel.logger.error.assert_called_once_with(
        "Stream send/edit failed for chat_id={}", "!room:matrix.org", exc_info=True
    )


@pytest.mark.asyncio
async def test_send_delta_ignores_whitespace_only_delta(monkeypatch) -> None:
    channel = MatrixChannel(_make_config(), MessageBus())
    client = _FakeAsyncClient("", "", "", None)
    channel.client = client

    now = 100.0
    monkeypatch.setattr(channel, "monotonic_time", lambda: now)

    await channel.send_delta("!room:matrix.org", "   ")

    assert "!room:matrix.org" in channel._stream_bufs
    assert channel._stream_bufs["!room:matrix.org"].text == "   "


@pytest.mark.asyncio
async def test_fetch_media_rejects_missing_declared_size(monkeypatch, tmp_path) -> None:
    channel = MatrixChannel(_make_config(max_media_bytes=8), MessageBus())
    client = _FakeAsyncClient("https://matrix.org", "", "", None)
    channel.client = client
    monkeypatch.setattr("nanobot.channels.matrix.runtime.get_media_dir", lambda _name: tmp_path)

    async def _download_should_not_run(*_args, **_kwargs):
        raise AssertionError("download should be rejected before fetching bytes")

    monkeypatch.setattr(channel, "_download_media_bytes", _download_should_not_run)
    event = SimpleNamespace(
        sender="@alice:matrix.org",
        event_id="$event1",
        body="payload.bin",
        url="mxc://example.org/media",
        source={"content": {"msgtype": "m.file"}},
    )

    attachment, marker = await channel._fetch_media_attachment(
        SimpleNamespace(room_id="!room:matrix.org"),
        event,
    )

    assert attachment is None
    assert marker == "[attachment: payload.bin - too large]"


@pytest.mark.asyncio
async def test_fetch_media_rejects_bool_declared_size(monkeypatch, tmp_path) -> None:
    channel = MatrixChannel(_make_config(max_media_bytes=8), MessageBus())
    client = _FakeAsyncClient("https://matrix.org", "", "", None)
    channel.client = client
    monkeypatch.setattr("nanobot.channels.matrix.runtime.get_media_dir", lambda _name: tmp_path)

    async def _download_should_not_run(*_args, **_kwargs):
        raise AssertionError("bool size should be rejected before fetching bytes")

    monkeypatch.setattr(channel, "_download_media_bytes", _download_should_not_run)
    event = SimpleNamespace(
        sender="@alice:matrix.org",
        event_id="$event1",
        body="payload.bin",
        url="mxc://example.org/media",
        source={"content": {"msgtype": "m.file", "info": {"size": True}}},
    )

    attachment, marker = await channel._fetch_media_attachment(
        SimpleNamespace(room_id="!room:matrix.org"),
        event,
    )

    assert attachment is None
    assert marker == "[attachment: payload.bin - too large]"


@pytest.mark.asyncio
async def test_fetch_media_rejects_declared_oversized_before_download(monkeypatch, tmp_path) -> None:
    channel = MatrixChannel(_make_config(max_media_bytes=8), MessageBus())
    client = _FakeAsyncClient("https://matrix.org", "", "", None)
    channel.client = client
    monkeypatch.setattr("nanobot.channels.matrix.runtime.get_media_dir", lambda _name: tmp_path)

    async def _download_should_not_run(*_args, **_kwargs):
        raise AssertionError("download should be rejected before fetching bytes")

    monkeypatch.setattr(channel, "_download_media_bytes", _download_should_not_run)
    event = SimpleNamespace(
        sender="@alice:matrix.org",
        event_id="$event1",
        body="payload.bin",
        url="mxc://example.org/media",
        source={"content": {"msgtype": "m.file", "info": {"size": 9}}},
    )

    attachment, marker = await channel._fetch_media_attachment(
        SimpleNamespace(room_id="!room:matrix.org"),
        event,
    )

    assert attachment is None
    assert marker == "[attachment: payload.bin - too large]"


@pytest.mark.asyncio
async def test_fetch_media_maps_streaming_cap_to_too_large(monkeypatch, tmp_path) -> None:
    channel = MatrixChannel(_make_config(max_media_bytes=8), MessageBus())
    client = _FakeAsyncClient("https://matrix.org", "", "", None)
    channel.client = client
    monkeypatch.setattr("nanobot.channels.matrix.runtime.get_media_dir", lambda _name: tmp_path)

    async def _download_too_large(_mxc_url: str, _limit_bytes: int):
        raise matrix_module._MediaTooLargeError

    monkeypatch.setattr(channel, "_download_media_bytes", _download_too_large)
    event = SimpleNamespace(
        sender="@alice:matrix.org",
        event_id="$event1",
        body="payload.bin",
        url="mxc://example.org/media",
        source={"content": {"msgtype": "m.file", "info": {"size": 8}}},
    )

    attachment, marker = await channel._fetch_media_attachment(
        SimpleNamespace(room_id="!room:matrix.org"),
        event,
    )

    assert attachment is None
    assert marker == "[attachment: payload.bin - too large]"
    assert client.room_send_calls == []
