"""Tests for the WeCom platform adapter."""

import asyncio
import base64
import os
import socket
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock

import pytest

from gateway.config import PlatformConfig






class TestWeComInboundImageExtension:
    def test_octet_stream_falls_through_to_magic_bytes(self):
        """WeCom's CDN serves images as application/octet-stream; the cached file must get the
        real image extension from magic bytes, not ".bin" (#10085)."""
        from plugins.platforms.wecom.adapter import WeComAdapter

        jpeg = b"\xff\xd8\xff\xe0" + b"\x00" * 16
        ext = WeComAdapter._guess_extension(
            "https://wwcdn.weixin.qq.com/img?aeskey=abc", "application/octet-stream",
            fallback=WeComAdapter._detect_image_ext(jpeg))
        assert ext == ".jpg"
        assert WeComAdapter._guess_extension("https://x/y.png", "image/png", fallback=".jpg") == ".png"

    def test_encoded_aeskey_and_octet_stream_image_cached_as_real_image(self, monkeypatch):
        """End to end through `_cache_media`: a percent-encoded, unpadded `aeskey` decrypts, and an
        octet-stream-labelled PNG is stored with an image MIME, not application/octet-stream."""
        from urllib.parse import quote
        from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
        from plugins.platforms.wecom import media as wecom_media
        from plugins.platforms.wecom.adapter import WeComAdapter

        key = os.urandom(32)
        png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 40
        pad = 16 - len(png) % 16
        enc = Cipher(algorithms.AES(key), modes.CBC(key[:16])).encryptor()
        encrypted = enc.update(png + bytes([pad]) * pad) + enc.finalize()
        encoded_key = quote(base64.b64encode(key).decode().rstrip("="), safe="")

        adapter = WeComAdapter.__new__(WeComAdapter)
        stored = {}

        async def _download(url, max_bytes):
            return encrypted, {"content-type": "application/octet-stream"}

        async def _cache(raw, ext):
            stored["raw"] = raw
            return f"/tmp/img{ext}"

        monkeypatch.setattr(adapter, "_download_remote_bytes", _download)
        monkeypatch.setattr(wecom_media, "cache_image_from_bytes_async", _cache)

        result = asyncio.run(adapter._cache_media("image", {"url": "https://cdn/x", "aeskey": encoded_key}))

        assert result == ("/tmp/img.png", "image/png")
        assert stored["raw"] == png


class TestWeComAdapterAuthzScope:
    """dm_policy/allowlist reads must honor the profile secret scope under
    multiplexing (#93522): a secondary profile's own scope is authoritative
    and must not inherit the default profile's process-env authorization."""

    @pytest.fixture()
    def multiplex_on(self):
        from agent import secret_scope

        previous = secret_scope.is_multiplex_active()
        secret_scope.set_multiplex_active(True)
        try:
            yield
        finally:
            secret_scope.set_multiplex_active(previous)

    def test_scoped_construction_reads_authz_from_scope_not_environ(self, multiplex_on, monkeypatch):
        from agent import secret_scope
        from plugins.platforms.wecom.adapter import WeComAdapter

        monkeypatch.setenv("WECOM_DM_POLICY", "pairing")
        monkeypatch.setenv("WECOM_ALLOWED_USERS", "default-user")
        token = secret_scope.set_secret_scope(
            {"WECOM_DM_POLICY": "allowlist", "WECOM_ALLOWED_USERS": "scoped-user"}
        )
        try:
            adapter = WeComAdapter(PlatformConfig(enabled=True))
        finally:
            secret_scope.reset_secret_scope(token)
        assert adapter._dm_policy == "allowlist"
        assert adapter._allow_from == ["scoped-user"]

    def test_scoped_miss_does_not_admit_default_profiles_allowlist(self, multiplex_on, monkeypatch):
        from agent import secret_scope
        from plugins.platforms.wecom.adapter import WeComAdapter

        monkeypatch.setenv("WECOM_DM_POLICY", "allowlist")
        monkeypatch.setenv("WECOM_ALLOWED_USERS", "default-user")
        token = secret_scope.set_secret_scope({"SOMETHING_ELSE": "x"})
        try:
            adapter = WeComAdapter(PlatformConfig(enabled=True))
        finally:
            secret_scope.reset_secret_scope(token)
        assert adapter._dm_policy == "pairing"
        assert adapter._allow_from == []

    def test_scoped_construction_reads_bot_id_from_scope_not_environ(self, multiplex_on, monkeypatch):
        """bot_id must honor the same scope as its neighboring _secret read
        (both are read on adjacent lines in __init__) -- a secondary profile's
        own bot_id must never fall back to the default profile's os.environ
        value."""
        from agent import secret_scope
        from plugins.platforms.wecom.adapter import WeComAdapter

        monkeypatch.setenv("WECOM_BOT_ID", "default-profile-bot-id")
        monkeypatch.setenv("WECOM_SECRET", "default-profile-secret")
        token = secret_scope.set_secret_scope(
            {"WECOM_BOT_ID": "scoped-bot-id", "WECOM_SECRET": "scoped-secret"}
        )
        try:
            adapter = WeComAdapter(PlatformConfig(enabled=True))
        finally:
            secret_scope.reset_secret_scope(token)
        assert adapter._bot_id == "scoped-bot-id"
        assert adapter._secret == "scoped-secret"

    def test_scoped_miss_does_not_leak_default_profiles_bot_id(self, multiplex_on, monkeypatch):
        from agent import secret_scope
        from plugins.platforms.wecom.adapter import DEFAULT_WS_URL, WeComAdapter

        monkeypatch.setenv("WECOM_BOT_ID", "default-profile-bot-id")
        monkeypatch.setenv("WECOM_WEBSOCKET_URL", "wss://default-profile.example/ws")
        token = secret_scope.set_secret_scope({"SOMETHING_ELSE": "x"})
        try:
            adapter = WeComAdapter(PlatformConfig(enabled=True))
        finally:
            secret_scope.reset_secret_scope(token)
        assert adapter._bot_id == ""
        assert adapter._ws_url == DEFAULT_WS_URL


class TestWeComConnect:

    @pytest.mark.asyncio
    async def test_connect_records_handshake_failure_details(self, monkeypatch):
        import plugins.platforms.wecom.adapter as wecom_module
        from plugins.platforms.wecom.adapter import WeComAdapter

        class DummyClient:
            async def aclose(self):
                return None

        monkeypatch.setattr(wecom_module, "AIOHTTP_AVAILABLE", True)
        monkeypatch.setattr(wecom_module, "HTTPX_AVAILABLE", True)
        monkeypatch.setattr(
            wecom_module,
            "httpx",
            SimpleNamespace(AsyncClient=lambda **kwargs: DummyClient()),
        )

        adapter = WeComAdapter(
            PlatformConfig(enabled=True, extra={"bot_id": "bot-1", "secret": "secret-1"})
        )
        adapter._open_connection = AsyncMock(side_effect=RuntimeError("invalid secret (errcode=40013)"))

        success = await adapter.connect()

        assert success is False
        assert adapter.has_fatal_error is True
        assert adapter.fatal_error_code == "wecom_connect_error"
        assert "invalid secret" in (adapter.fatal_error_message or "")




class TestWeComReplyMode:

    @pytest.mark.asyncio
    async def test_send_image_file_uses_passive_reply_media_when_reply_context_exists(self):
        from plugins.platforms.wecom.adapter import WeComAdapter

        adapter = WeComAdapter(PlatformConfig(enabled=True))
        adapter._reply_req_ids["msg-1"] = "req-1"
        adapter._prepare_outbound_media = AsyncMock(
            return_value={
                "data": b"image-bytes",
                "content_type": "image/png",
                "file_name": "demo.png",
                "detected_type": "image",
                "final_type": "image",
                "rejected": False,
                "reject_reason": None,
                "downgraded": False,
                "downgrade_note": None,
            }
        )
        adapter._upload_media_bytes = AsyncMock(return_value={"media_id": "media-1", "type": "image"})
        adapter._send_reply_request = AsyncMock(
            return_value={"headers": {"req_id": "req-1"}, "errcode": 0}
        )

        result = await adapter.send_image_file("chat-123", "/tmp/demo.png", reply_to="msg-1")

        assert result.success is True
        adapter._send_reply_request.assert_awaited_once()
        args = adapter._send_reply_request.await_args.args
        assert args[0] == "req-1"
        assert args[1] == {"msgtype": "image", "image": {"media_id": "media-1"}}


class TestExtractText:

    def test_extracts_mixed_text(self):
        from plugins.platforms.wecom.adapter import WeComAdapter

        body = {
            "msgtype": "mixed",
            "mixed": {
                "msg_item": [
                    {"msgtype": "text", "text": {"content": "part1"}},
                    {"msgtype": "image", "image": {"url": "https://example.com/x.png"}},
                    {"msgtype": "text", "text": {"content": "part2"}},
                ]
            },
        }
        text, _reply_text = WeComAdapter._extract_text(body)
        assert text == "part1\npart2"


class TestCallbackDispatch:
    @pytest.mark.asyncio
    @pytest.mark.parametrize("cmd", ["aibot_msg_callback", "aibot_callback"])
    async def test_dispatch_accepts_new_and_legacy_callback_cmds(self, cmd):
        from plugins.platforms.wecom.adapter import WeComAdapter

        adapter = WeComAdapter(PlatformConfig(enabled=True))
        adapter._on_message = AsyncMock()

        await adapter._dispatch_payload({"cmd": cmd, "headers": {"req_id": "req-1"}, "body": {}})

        adapter._on_message.assert_awaited_once()


class TestPolicyHelpers:

    def test_dm_allowlist_honors_env_only_allowed_users(self, monkeypatch):
        """Env-only setup (WECOM_DM_POLICY + WECOM_ALLOWED_USERS, no config
        ``extra``) must populate the DM allowlist. Otherwise ``dm_policy:
        allowlist`` runs with an empty allowlist and drops every listed user
        at intake — the documented env vars become no-ops."""
        from plugins.platforms.wecom.adapter import WeComAdapter

        monkeypatch.setenv("WECOM_DM_POLICY", "allowlist")
        monkeypatch.setenv("WECOM_ALLOWED_USERS", "user-1, user-2")

        adapter = WeComAdapter(PlatformConfig(enabled=True))

        assert adapter._dm_policy == "allowlist"
        assert adapter._allow_from == ["user-1", "user-2"]
        assert adapter._is_dm_allowed("user-1") is True
        assert adapter._is_dm_allowed("user-2") is True
        assert adapter._is_dm_allowed("stranger") is False


    def test_pairing_group_policy_blocks_without_explicit_group_allow_from(self):
        from plugins.platforms.wecom.adapter import WeComAdapter

        adapter = WeComAdapter(
            PlatformConfig(enabled=True, extra={"group_policy": "pairing"})
        )

        assert adapter._is_group_allowed("group-1", "user-1") is False


class TestMediaHelpers:
    def test_detect_wecom_media_type(self):
        from plugins.platforms.wecom.adapter import WeComAdapter

        assert WeComAdapter._detect_wecom_media_type("image/png") == "image"
        assert WeComAdapter._detect_wecom_media_type("video/mp4") == "video"
        assert WeComAdapter._detect_wecom_media_type("audio/amr") == "voice"
        assert WeComAdapter._detect_wecom_media_type("application/pdf") == "file"

    def test_voice_non_amr_downgrades_to_file(self):
        from plugins.platforms.wecom.adapter import WeComAdapter

        result = WeComAdapter._apply_file_size_limits(128, "voice", "audio/mpeg")

        assert result["final_type"] == "file"
        assert result["downgraded"] is True
        assert "AMR" in (result["downgrade_note"] or "")


class TestMediaUpload:


    @pytest.mark.asyncio
    async def test_download_remote_bytes_blocks_connect_time_rebind(self, monkeypatch):
        import httpcore
        from httpcore._backends.auto import AutoBackend
        from plugins.platforms.wecom.adapter import WeComAdapter
        from tools.url_safety import SSRFConnectionBlocked

        for proxy_var in (
            "HTTP_PROXY",
            "HTTPS_PROXY",
            "ALL_PROXY",
            "http_proxy",
            "https_proxy",
            "all_proxy",
        ):
            monkeypatch.delenv(proxy_var, raising=False)

        answers = iter(("93.184.216.34", "169.254.169.254"))

        def fake_getaddrinfo(_host, port, *_args, **_kwargs):
            ip = next(answers)
            return [
                (socket.AF_INET, socket.SOCK_STREAM, 6, "", (ip, port or 0))
            ]

        connect_attempts = []

        async def fake_connect_tcp(
            _self,
            host,
            port,
            timeout=None,
            local_address=None,
            socket_options=None,
        ):
            connect_attempts.append((host, port))
            raise httpcore.ConnectError("stop before network")

        monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)
        monkeypatch.setattr(AutoBackend, "connect_tcp", fake_connect_tcp)

        adapter = WeComAdapter(PlatformConfig(enabled=True))
        with pytest.raises(SSRFConnectionBlocked):
            await adapter._download_remote_bytes(
                "http://rebind.example/file.bin", max_bytes=1024
            )

        assert connect_attempts == []


class TestSend:




    @pytest.mark.asyncio
    async def test_approval_confirmation_uses_proactive_send(self):
        """Regression: force_proactive_send=True must use APP_CMD_SEND to avoid
        consuming the req_id that the post-approval stream needs. Passive reply
        on the same req_id causes WeCom to render the stream seed as empty bubble."""
        from plugins.platforms.wecom.adapter import APP_CMD_SEND, WeComAdapter

        adapter = WeComAdapter(PlatformConfig(enabled=True))
        # Simulate a cached req_id from the user's /approve message
        adapter._last_chat_req_ids["chat-123"] = "req-approve"
        adapter._send_request = AsyncMock(return_value={"headers": {"req_id": "req-approve"}, "errcode": 0})
        adapter._send_reply_request = AsyncMock(
            return_value={"headers": {"req_id": "req-approve"}, "errcode": 0}
        )

        result = await adapter.send(
            "chat-123",
            "✅ Approved 1 command. Continuing...",
            metadata={"is_approval_prompt": True, "force_proactive_send": True},
        )

        assert result.success is True
        # Must use APP_CMD_SEND (proactive), NOT _send_reply_request (passive)
        adapter._send_request.assert_awaited_once_with(
            APP_CMD_SEND,
            {
                "chatid": "chat-123",
                "msgtype": "markdown",
                "markdown": {"content": "✅ Approved 1 command. Continuing..."},
            },
        )
        adapter._send_reply_request.assert_not_awaited()

    @pytest.mark.asyncio
    async def test_approval_request_prompt_uses_passive_reply(self):
        """is_approval_prompt alone (without force_proactive_send) must still use
        passive reply. The initial approval *request* prompt needs passive reply
        because groups cannot use APP_CMD_SEND."""
        from plugins.platforms.wecom.adapter import WeComAdapter

        adapter = WeComAdapter(PlatformConfig(enabled=True))
        adapter._last_chat_req_ids["group-chat"] = "req-user-msg"
        adapter._send_reply_request = AsyncMock(
            return_value={"headers": {"req_id": "req-user-msg"}, "errcode": 0}
        )
        adapter._send_request = AsyncMock(return_value={"errcode": 0})

        result = await adapter.send(
            "group-chat",
            "⚠️ Dangerous command requires approval...",
            metadata={"is_approval_prompt": True},  # No force_proactive_send
        )

        assert result.success is True
        # Should use passive reply (preserving req_id for group delivery)
        adapter._send_reply_request.assert_awaited_once()
        adapter._send_request.assert_not_awaited()


    @pytest.mark.asyncio
    async def test_force_proactive_falls_back_to_passive_for_groups(self):
        """Regression: force_proactive_send must NOT use APP_CMD_SEND in group chats.
        WeCom AI Bots cannot initiate APP_CMD_SEND in groups — only passive reply
        (APP_CMD_RESPONSE) bound to a req_id works."""
        from plugins.platforms.wecom.adapter import WeComAdapter

        adapter = WeComAdapter(PlatformConfig(enabled=True))
        adapter._last_chat_req_ids["group-chat"] = "req-approve"
        # Mark this chat as a group
        adapter._group_chat_ids.add("group-chat")

        adapter._send_reply_request = AsyncMock(
            return_value={"headers": {"req_id": "req-approve"}, "errcode": 0}
        )
        adapter._send_request = AsyncMock(return_value={"errcode": 0})

        result = await adapter.send(
            "group-chat",
            "✅ Approved 1 command. Continuing...",
            metadata={"is_approval_prompt": True, "force_proactive_send": True},
        )

        assert result.success is True
        # Group chats must fall back to passive reply even with force_proactive_send
        adapter._send_reply_request.assert_awaited_once()
        adapter._send_request.assert_not_awaited()

    @pytest.mark.asyncio
    async def test_group_send_fails_early_without_req_id(self):
        """Group chats with no cached req_id must fail with a clear error
        instead of attempting APP_CMD_SEND (which WeCom will reject)."""
        from plugins.platforms.wecom.adapter import WeComAdapter

        adapter = WeComAdapter(PlatformConfig(enabled=True))
        # No req_id cached for this group
        adapter._group_chat_ids.add("group-no-req")
        adapter._send_request = AsyncMock(return_value={"errcode": 0})

        result = await adapter.send("group-no-req", "hello group")

        assert result.success is False
        assert "req_id" in (result.error or "").lower()
        # Should NOT attempt APP_CMD_SEND
        adapter._send_request.assert_not_awaited()


class TestInboundMessages:
    @pytest.mark.asyncio
    async def test_on_message_builds_event(self):
        from plugins.platforms.wecom.adapter import WeComAdapter

        adapter = WeComAdapter(
            PlatformConfig(
                enabled=True,
                extra={"group_policy": "allowlist", "group_allow_from": ["group-1"]},
            )
        )
        adapter._text_batch_delay_seconds = 0  # disable batching for tests
        adapter.handle_message = AsyncMock()
        adapter._extract_media = AsyncMock(return_value=(["/tmp/test.png"], ["image/png"]))

        payload = {
            "cmd": "aibot_msg_callback",
            "headers": {"req_id": "req-1"},
            "body": {
                "msgid": "msg-1",
                "chatid": "group-1",
                "chattype": "group",
                "from": {"userid": "user-1"},
                "msgtype": "text",
                "text": {"content": "hello"},
            },
        }

        await adapter._on_message(payload)

        adapter.handle_message.assert_awaited_once()
        event = adapter.handle_message.await_args.args[0]
        assert event.text == "hello"
        assert event.source.chat_id == "group-1"
        assert event.source.user_id == "user-1"
        assert event.media_urls == ["/tmp/test.png"]
        assert event.media_types == ["image/png"]


class TestWeComZombieSessionFix:
    """Tests for PR #11572 — device_id, markdown reply, group req_id fallback."""


    @pytest.mark.asyncio
    async def test_on_message_does_not_cache_blocked_sender_req_id(self):
        """Blocked chats shouldn't populate the proactive-send fallback cache."""
        from plugins.platforms.wecom.adapter import WeComAdapter

        adapter = WeComAdapter(
            PlatformConfig(
                enabled=True,
                extra={"group_policy": "allowlist", "group_allow_from": ["group-ok"]},
            )
        )
        adapter.handle_message = AsyncMock()
        adapter._extract_media = AsyncMock(return_value=([], []))

        payload = {
            "cmd": "aibot_msg_callback",
            "headers": {"req_id": "req-abc"},
            "body": {
                "msgid": "msg-1",
                "chatid": "group-blocked",
                "chattype": "group",
                "from": {"userid": "user-1"},
                "msgtype": "text",
                "text": {"content": "hi"},
            },
        }

        await adapter._on_message(payload)
        adapter.handle_message.assert_not_awaited()
        assert "group-blocked" not in adapter._last_chat_req_ids

    def test_remember_chat_req_id_is_bounded(self):
        from plugins.platforms.wecom.adapter import DEDUP_MAX_SIZE, WeComAdapter

        adapter = WeComAdapter(PlatformConfig(enabled=True))
        for i in range(DEDUP_MAX_SIZE + 50):
            adapter._remember_chat_req_id(f"chat-{i}", f"req-{i}")
        assert len(adapter._last_chat_req_ids) <= DEDUP_MAX_SIZE
        # The most recently remembered chat must still be present.
        latest = f"chat-{DEDUP_MAX_SIZE + 49}"
        assert adapter._last_chat_req_ids[latest] == f"req-{DEDUP_MAX_SIZE + 49}"


    @pytest.mark.asyncio
    async def test_proactive_group_send_falls_back_to_cached_req_id(self):
        """Sending into a group without reply_to should use the last cached
        req_id via APP_CMD_RESPONSE — WeCom AI Bots cannot initiate APP_CMD_SEND
        in group chats (errcode 600039)."""
        from plugins.platforms.wecom.adapter import WeComAdapter

        adapter = WeComAdapter(PlatformConfig(enabled=True))
        adapter._last_chat_req_ids["group-1"] = "inbound-req-42"
        adapter._send_reply_request = AsyncMock(
            return_value={"headers": {"req_id": "inbound-req-42"}, "errcode": 0}
        )
        adapter._send_request = AsyncMock(
            return_value={"headers": {"req_id": "new"}, "errcode": 0}
        )

        result = await adapter.send("group-1", "ping", reply_to=None)

        assert result.success is True
        # Must route through reply (APP_CMD_RESPONSE), not proactive send.
        adapter._send_reply_request.assert_awaited_once()
        adapter._send_request.assert_not_awaited()
        args = adapter._send_reply_request.await_args.args
        assert args[0] == "inbound-req-42"
        assert args[1]["msgtype"] == "markdown"
        assert args[1]["markdown"]["content"] == "ping"


class TestTextBatchFlushRace:
    """Regression tests for the cancel-delivery race in _flush_text_batch.

    When asyncio.sleep() fires and Task.cancel() is called before the task
    runs, CPython sets _must_cancel but cannot cancel the already-done sleep
    future.  CancelledError is then delivered at the *next* await
    (handle_message), after the task has already popped the event — the
    superseding task sees an empty batch and silently drops the message.
    The fix adds a synchronous task-registry check between the sleep and
    the pop so a superseded task returns before touching the event.
    """

    @pytest.mark.asyncio
    async def test_superseded_task_does_not_pop_or_process_event(self):
        """A flush task that has been superseded must leave the event in the
        batch dict for the new task to handle."""
        from gateway.platforms.event import MessageEvent, MessageType
        from plugins.platforms.wecom.adapter import WeComAdapter

        adapter = WeComAdapter(PlatformConfig(enabled=True))
        adapter._text_batch_delay_seconds = 0

        key = "test-session"
        event = MessageEvent(text="hello", message_type=MessageType.TEXT)
        adapter._pending_text_batches[key] = event

        handle_calls = []

        async def fake_handle(evt):
            handle_calls.append(evt)

        adapter.handle_message = fake_handle

        # Create T1 and register it.
        t1 = asyncio.create_task(adapter._flush_text_batch(key))
        adapter._pending_text_batch_tasks[key] = t1

        # Simulate T2 superseding T1 before T1 wakes from sleep.
        t2 = asyncio.create_task(asyncio.sleep(0.2))
        adapter._pending_text_batch_tasks[key] = t2

        # Yield long enough for T1's sleep(0) to complete and T1 to run.
        await asyncio.sleep(0.05)

        t2.cancel()
        try:
            await t2
        except asyncio.CancelledError:
            pass

        # T1 must have returned without processing or removing the event.
        assert handle_calls == [], "superseded task must not call handle_message"
        assert adapter._pending_text_batches.get(key) is event, (
            "superseded task must not pop the event"
        )

    @pytest.mark.asyncio
    async def test_active_task_processes_event_normally(self):
        """When the task is not superseded it must still process the event."""
        from gateway.platforms.event import MessageEvent, MessageType
        from plugins.platforms.wecom.adapter import WeComAdapter

        adapter = WeComAdapter(PlatformConfig(enabled=True))
        adapter._text_batch_delay_seconds = 0

        key = "test-session"
        event = MessageEvent(text="world", message_type=MessageType.TEXT)
        adapter._pending_text_batches[key] = event

        handle_calls = []

        async def fake_handle(evt):
            handle_calls.append(evt)

        adapter.handle_message = fake_handle

        t1 = asyncio.create_task(adapter._flush_text_batch(key))
        adapter._pending_text_batch_tasks[key] = t1

        # No superseding task — T1 should process normally.
        await asyncio.sleep(0.05)

        assert handle_calls == [event], "active task must call handle_message"
        assert adapter._pending_text_batches.get(key) is None, (
            "active task must pop the event after processing"
        )


class TestAttachmentTextMerge:
    """WeCom sends "image + text" as two separate inbound callbacks (an
    attachment-only frame, then a text frame ~hundreds of ms later).

    Dispatching the attachment immediately spawns an agent run that the
    trailing text then "interrupts" (junk "⚡ Interrupting" + "✅" acks).
    The adapter buffers an attachment-only message on the existing text-batch
    machinery for a short merge window so the following text merges into ONE
    dispatched event. These tests exercise the real _on_message path.
    """

    @staticmethod
    def _make_adapter(merge_delay: float = 0.15):
        from plugins.platforms.wecom.adapter import WeComAdapter

        adapter = WeComAdapter(
            PlatformConfig(
                enabled=True,
                extra={
                    "dm_policy": "open",
                    "attachment_text_merge_delay_seconds": merge_delay,
                },
            )
        )
        # DM open policy needs the opt-in env flag; force intake open.
        adapter._is_dm_intake_allowed = lambda sender_id: True
        # Keep the text-split batch window tiny so tests are fast.
        adapter._text_batch_delay_seconds = 0.05
        adapter.handle_message = AsyncMock()
        return adapter

    @staticmethod
    def _image_payload(msgid: str, media):
        return {
            "cmd": "aibot_msg_callback",
            "headers": {"req_id": f"req-{msgid}"},
            "body": {
                "msgid": msgid,
                "from": {"userid": "user-1"},
                "msgtype": "image",
                "image": {"url": "https://example.com/x.png"},
                "_media": media,
            },
        }

    @staticmethod
    def _text_payload(msgid: str, content: str):
        return {
            "cmd": "aibot_msg_callback",
            "headers": {"req_id": f"req-{msgid}"},
            "body": {
                "msgid": msgid,
                "from": {"userid": "user-1"},
                "msgtype": "text",
                "text": {"content": content},
            },
        }

    @pytest.mark.asyncio
    async def test_image_then_text_merge_into_one_event(self):
        """image-then-text within the window → ONE dispatched event carrying
        both the media and the text, and NO immediate dispatch of the image
        (so the busy-handler interrupt path is never triggered)."""
        adapter = self._make_adapter(merge_delay=0.2)

        async def fake_extract_media(body):
            if body.get("msgtype") == "image":
                return (["/tmp/x.png"], ["image/png"])
            return ([], [])

        adapter._extract_media = fake_extract_media

        await adapter._on_message(self._image_payload("img-1", None))
        # Image must be held, not dispatched.
        adapter.handle_message.assert_not_called()

        # Text arrives within the merge window.
        await asyncio.sleep(0.05)
        await adapter._on_message(self._text_payload("txt-1", "what is this?"))
        adapter.handle_message.assert_not_called()

        # After the window elapses, exactly one merged event dispatches.
        await asyncio.sleep(0.3)
        adapter.handle_message.assert_awaited_once()
        event = adapter.handle_message.await_args.args[0]
        from gateway.platforms.event import MessageType

        assert event.text == "what is this?"
        assert event.media_urls == ["/tmp/x.png"]
        assert event.media_types == ["image/png"]
        assert event.message_type == MessageType.TEXT

    @pytest.mark.asyncio
    async def test_image_only_dispatched_after_window(self):
        """image-only with no following text → still dispatched on its own
        after the merge window (must not be dropped)."""
        adapter = self._make_adapter(merge_delay=0.15)
        adapter._extract_media = AsyncMock(return_value=(["/tmp/x.png"], ["image/png"]))

        await adapter._on_message(self._image_payload("img-1", None))
        adapter.handle_message.assert_not_called()

        await asyncio.sleep(0.3)
        adapter.handle_message.assert_awaited_once()
        event = adapter.handle_message.await_args.args[0]
        from gateway.platforms.event import MessageType

        assert event.media_urls == ["/tmp/x.png"]
        assert event.message_type == MessageType.PHOTO

    @pytest.mark.asyncio
    async def test_multiple_attachments_then_text_all_merged(self):
        """Two attachment-only frames then text → all media merged into one
        dispatched event with the text."""
        adapter = self._make_adapter(merge_delay=0.2)

        counter = {"n": 0}

        async def fake_extract_media(body):
            if body.get("msgtype") == "image":
                counter["n"] += 1
                n = counter["n"]
                return ([f"/tmp/x{n}.png"], ["image/png"])
            return ([], [])

        adapter._extract_media = fake_extract_media

        await adapter._on_message(self._image_payload("img-1", None))
        await asyncio.sleep(0.03)
        await adapter._on_message(self._image_payload("img-2", None))
        await asyncio.sleep(0.03)
        await adapter._on_message(self._text_payload("txt-1", "describe both"))
        adapter.handle_message.assert_not_called()

        await asyncio.sleep(0.35)
        adapter.handle_message.assert_awaited_once()
        event = adapter.handle_message.await_args.args[0]
        assert event.text == "describe both"
        assert event.media_urls == ["/tmp/x1.png", "/tmp/x2.png"]
        assert event.media_types == ["image/png", "image/png"]

    @pytest.mark.asyncio
    async def test_pure_text_unaffected(self):
        """Regression: pure text still flows through the text-batch path and
        dispatches as a single text event."""
        adapter = self._make_adapter()
        adapter._extract_media = AsyncMock(return_value=([], []))

        await adapter._on_message(self._text_payload("txt-1", "just text"))
        adapter.handle_message.assert_not_called()

        await asyncio.sleep(0.2)
        adapter.handle_message.assert_awaited_once()
        event = adapter.handle_message.await_args.args[0]
        from gateway.platforms.event import MessageType

        assert event.text == "just text"
        assert event.media_urls == []
        assert event.message_type == MessageType.TEXT



# === NATIVE STREAMING (msgtype: stream) ===



# === STREAM TESTS PLACEHOLDER ===


class TestResolveStreamReqId:
    """`_resolve_stream_req_id` precedence: reply_to → cached chat → None."""

    def test_prefers_explicit_reply_to(self):
        from plugins.platforms.wecom.adapter import WeComAdapter

        adapter = WeComAdapter(PlatformConfig(enabled=True))
        adapter._reply_req_ids["msg-123"] = "explicit-req"
        adapter._last_chat_req_ids["chat-1"] = "cached-req"

        assert adapter._resolve_stream_req_id("chat-1", "msg-123") == "explicit-req"

    def test_falls_back_to_cached_chat_req_id(self):
        from plugins.platforms.wecom.adapter import WeComAdapter

        adapter = WeComAdapter(PlatformConfig(enabled=True))
        adapter._last_chat_req_ids["chat-1"] = "cached-req"

        assert adapter._resolve_stream_req_id("chat-1", reply_to=None) == "cached-req"

    def test_returns_none_when_no_anchor(self):
        from plugins.platforms.wecom.adapter import WeComAdapter

        adapter = WeComAdapter(PlatformConfig(enabled=True))
        assert adapter._resolve_stream_req_id("unknown-chat", None) is None

    def test_quoted_reply_to_falls_through_to_chat_cache(self):
        """``quote:msg-id`` (quote-context marker) is not a real reply anchor."""
        from plugins.platforms.wecom.adapter import WeComAdapter

        adapter = WeComAdapter(PlatformConfig(enabled=True))
        adapter._last_chat_req_ids["chat-1"] = "cached-req"

        assert adapter._resolve_stream_req_id("chat-1", "quote:m-1") == "cached-req"


# === LIFECYCLE TESTS PLACEHOLDER ===


class TestSendStreamFrame:
    """`send_stream_frame` lifecycle: init → cumulative updates → finalize."""

    @staticmethod
    def _mock_send_json_with_immediate_ack(adapter):
        """Mock _send_reply_queued to bypass ack tracking entirely.

        For tests that verify frame content/ordering, we don't need actual
        ack tracking — just record what was sent and always succeed.
        """
        sent_frames = []

        async def mock_send_reply_queued(reply_req_id, body, *, is_final=False, skip_if_pending=False):
            sent_frames.append({
                "req_id": reply_req_id,
                "body": body,
                "is_final": is_final,
            })
            return {"errcode": 0, "errmsg": "ok"}

        adapter._send_reply_queued = AsyncMock(side_effect=mock_send_reply_queued)
        adapter._sent_frames = sent_frames

    @pytest.mark.asyncio
    async def test_first_call_seeds_thinking_frame_then_returns_true(self):
        """First frame for a chat sends <think></think> seed, then the
        content frame.

        Fire-and-forget: intermediate frames are pushed immediately (pure
        identity-dedup), so any non-empty payload produces a content frame
        right after the seed — no min_chars / sentence-boundary gating.
        """
        from plugins.platforms.wecom.adapter import WeComAdapter

        adapter = WeComAdapter(PlatformConfig(enabled=True))
        adapter._last_chat_req_ids["chat-1"] = "req-1"
        adapter._ws = MagicMock(closed=False)
        # Mock _send_reply_queued to bypass ack tracking
        self._mock_send_json_with_immediate_ack(adapter)

        payload = "hello world"
        ok = await adapter.send_stream_frame(payload, chat_id="chat-1")

        assert ok is True
        # seed + content = 2 frames
        assert len(adapter._sent_frames) == 2
        seed_frame = adapter._sent_frames[0]
        assert seed_frame["body"]["msgtype"] == "stream"
        assert seed_frame["body"]["stream"]["content"] == "<think></think>"
        assert seed_frame["body"]["stream"]["finish"] is False

        content_frame = adapter._sent_frames[1]
        assert content_frame["body"]["stream"]["content"] == payload
        assert content_frame["body"]["stream"]["finish"] is False

    @pytest.mark.asyncio
    async def test_first_and_second_call_share_stream_id(self):
        """Successive frames use the same stream_id.

        Fire-and-forget pushes each distinct cumulative payload immediately,
        so this exercises stream_id continuity across frames, not chunker
        thresholds.
        """
        from plugins.platforms.wecom.adapter import WeComAdapter

        adapter = WeComAdapter(PlatformConfig(enabled=True))
        adapter._last_chat_req_ids["chat-1"] = "req-1"
        adapter._ws = MagicMock(closed=False)
        # Immediate ack so all frames are sent (no pending-skip)
        self._mock_send_json_with_immediate_ack(adapter)

        first = "alpha"
        second = "alpha beta"  # cumulative growth — differs from `first`
        await adapter.send_stream_frame(first, chat_id="chat-1")
        await adapter.send_stream_frame(second, chat_id="chat-1")

        # seed + first + second = 3 frames
        assert len(adapter._sent_frames) == 3
        ids = [frame["body"]["stream"]["id"] for frame in adapter._sent_frames]
        assert ids[0] == ids[1] == ids[2]
        assert ids[0].startswith("stream_")

    @pytest.mark.asyncio
    async def test_intermediate_frame_skipped_when_pending_ack(self):
        """Intermediate frames are skipped if a prior frame's ack is pending.

        This is the new ack-tracking semantics: if the seed frame's ack hasn't
        returned yet, the next intermediate frame is skipped (returns success
        but doesn't actually send). This prevents errcode 6000 version conflict.
        """
        from plugins.platforms.wecom.adapter import WeComAdapter

        adapter = WeComAdapter(PlatformConfig(enabled=True))
        adapter._last_chat_req_ids["chat-1"] = "req-1"
        adapter._send_json = AsyncMock()  # No auto-ack — pending stays pending
        adapter._ws = MagicMock(closed=False)

        await adapter.send_stream_frame("alpha", chat_id="chat-1")
        # Seed frame sent, pending_ack is set. Immediately send another:
        ok = await adapter.send_stream_frame("alpha beta", chat_id="chat-1")

        assert ok is True  # returns True (skip is silent success)
        # Only seed frame sent; second was skipped due to pending ack.
        assert adapter._send_json.await_count == 1

        # accumulated_text still updated in StreamTurn despite skip.
        turn = list(adapter._stream_turns.values())[0]
        assert turn.accumulated_text == "alpha beta"


    @pytest.mark.asyncio
    async def test_finalize_sends_finish_true_and_resets_state(self):
        """Finalize frame waits for pending ack, sends finish=true, cleans up turn."""
        from plugins.platforms.wecom.adapter import WeComAdapter

        adapter = WeComAdapter(PlatformConfig(enabled=True))
        adapter._last_chat_req_ids["chat-1"] = "req-1"
        adapter._ws = MagicMock(closed=False)
        # Auto-ack so seed + content + finalize all go through
        self._mock_send_json_with_immediate_ack(adapter)

        # With turn_id, creates independent turn
        turn_id = "test-turn-1"
        await adapter.send_stream_frame("partial", chat_id="chat-1", turn_id=turn_id)
        turn_key = "chat-1:test-turn-1"
        assert turn_key in adapter._stream_turns
        turn = adapter._stream_turns[turn_key]
        assert turn.stream_id is not None

        ok = await adapter.send_stream_frame(
            "partial final", chat_id="chat-1", finalize=True, turn_id=turn_id,
        )

        assert ok is True
        # After finalize, turn should be cleaned up
        assert turn_key not in adapter._stream_turns
        # Finalize goes through _send_reply_queued (mocked).
        # Find the finalize frame (is_final=True)
        finalize_frames = [
            f for f in adapter._sent_frames
            if f["body"].get("stream", {}).get("finish") is True
        ]
        assert len(finalize_frames) == 1
        assert finalize_frames[0]["body"]["stream"]["content"] == "partial final"

# === FAILURE TESTS PLACEHOLDER ===


class TestSendStreamFrameFailures:
    """Behavior when no req_id, 846608 expiry, or generic transport errors."""

    @pytest.mark.asyncio
    async def test_returns_false_when_no_req_id_available(self):
        from plugins.platforms.wecom.adapter import WeComAdapter

        adapter = WeComAdapter(PlatformConfig(enabled=True))
        # No reply_to, nothing in _last_chat_req_ids.
        adapter._send_reply_request = AsyncMock()

        ok = await adapter.send_stream_frame("hi", chat_id="unknown-chat")

        assert ok is False
        adapter._send_reply_request.assert_not_awaited()

    @pytest.mark.asyncio
    async def test_returns_false_when_chat_id_missing_on_first_call(self):
        from plugins.platforms.wecom.adapter import WeComAdapter

        adapter = WeComAdapter(PlatformConfig(enabled=True))
        adapter._send_reply_request = AsyncMock()

        ok = await adapter.send_stream_frame("hi", chat_id=None)

        assert ok is False
        adapter._send_reply_request.assert_not_awaited()

    @pytest.mark.asyncio
    async def test_846608_marks_chat_expired_and_returns_false(self):
        """846608 on finalize frame marks the chat expired and returns False."""
        from plugins.platforms.wecom.adapter import WeComAdapter
        from plugins.platforms.wecom.streaming import STREAM_EXPIRED_ERRCODE

        adapter = WeComAdapter(PlatformConfig(enabled=True))
        adapter._last_chat_req_ids["chat-1"] = "req-1"
        adapter._ws = MagicMock(closed=False)

        # Mock _send_reply_queued: intermediate succeeds, final returns 846608
        async def mock_queued(reply_req_id, body, *, is_final=False, skip_if_pending=False):
            if is_final:
                return {"errcode": STREAM_EXPIRED_ERRCODE, "errmsg": "stream expired"}
            return {"errcode": 0, "errmsg": "ok"}

        adapter._send_reply_queued = AsyncMock(side_effect=mock_queued)

        # First call (seed + content) succeeds
        turn_id = "test-turn-2"
        await adapter.send_stream_frame("hello", chat_id="chat-1", turn_id=turn_id)
        # Now try to finalize — ack returns 846608.
        ok = await adapter.send_stream_frame("hello final", chat_id="chat-1", finalize=True, turn_id=turn_id)

        assert ok is False
        assert "chat-1" in adapter._stream_expired_chats
        # This specific turn should be cleaned up
        turn_key = "chat-1:test-turn-2"
        assert turn_key not in adapter._stream_turns

    @pytest.mark.asyncio
    async def test_subsequent_call_to_expired_chat_short_circuits(self):
        """Once a chat is in ``_stream_expired_chats``, send_stream_frame
        bails immediately for new turns without touching the WS."""
        from plugins.platforms.wecom.adapter import WeComAdapter

        adapter = WeComAdapter(PlatformConfig(enabled=True))
        adapter._stream_expired_chats.add("chat-1")
        adapter._last_chat_req_ids["chat-1"] = "req-1"
        adapter._send_reply_request = AsyncMock()

        # Without turn_id: short-circuits immediately
        ok = await adapter.send_stream_frame("hi", chat_id="chat-1")
        assert ok is False
        adapter._send_reply_request.assert_not_awaited()

        # With a new turn_id: also short-circuits (can't create new turn)
        ok = await adapter.send_stream_frame("hi", chat_id="chat-1", turn_id="new-turn")
        assert ok is False
        adapter._send_reply_request.assert_not_awaited()

    @pytest.mark.asyncio
    async def test_inbound_message_clears_expired_marker(self):
        """A fresh inbound req_id must resurrect the stream channel."""
        from plugins.platforms.wecom.adapter import WeComAdapter

        adapter = WeComAdapter(PlatformConfig(enabled=True))
        adapter._stream_expired_chats.add("chat-1")

        adapter._remember_chat_req_id("chat-1", "fresh-req-id")

        assert "chat-1" not in adapter._stream_expired_chats


# === SEND_TYPING TESTS PLACEHOLDER ===


class TestSendTypingTriggersThinking:
    """``send_typing`` is a no-op — typing is handled by stream consumer."""

    @pytest.mark.asyncio
    async def test_send_typing_is_noop(self):
        """send_typing must not open any stream — the consumer seed frame does."""
        from plugins.platforms.wecom.adapter import WeComAdapter

        adapter = WeComAdapter(PlatformConfig(enabled=True))
        adapter._last_chat_req_ids["chat-1"] = "req-1"
        adapter._send_json = AsyncMock()
        adapter._ws = MagicMock(closed=False)

        await adapter.send_typing("chat-1")

        adapter._send_json.assert_not_awaited()
        # No stream turns created
        assert len(adapter._stream_turns) == 0



class TestStreamContentTruncation:
    """Bytes (not codepoints) are truncated to MAX_STREAM_CONTENT_LENGTH."""

    def test_ascii_below_limit_passes_through(self):
        from plugins.platforms.wecom.adapter import WeComAdapter

        out = WeComAdapter._truncate_stream_content("hello", 1000)
        assert out == "hello"

    def test_ascii_above_limit_is_byte_capped(self):
        from plugins.platforms.wecom.adapter import WeComAdapter

        big = "x" * 30000
        out = WeComAdapter._truncate_stream_content(big, 20480)
        assert len(out.encode("utf-8")) <= 20480

    def test_multibyte_truncation_does_not_split_codepoints(self):
        """A 3-byte CJK char must not be sliced mid-byte and emit garbage."""
        from plugins.platforms.wecom.adapter import WeComAdapter

        # Each "你" is 3 UTF-8 bytes.  Limit at 5 bytes — must keep one
        # full char and drop the half-cut second char rather than emit ï¿½.
        out = WeComAdapter._truncate_stream_content("你你", 5)
        assert out == "你"
        # Crucially, must be valid UTF-8 (no replacement chars from
        # mid-byte slices).
        assert "�" not in out





class TestFireAndForgetFrameFlow:
    """Integration: send_stream_frame pushes each distinct cumulative payload
    immediately (pure identity-dedup), with no sentence/min-chars buffering."""

    def _mock_send_json_with_immediate_ack(self, adapter):
        sent_frames = []

        async def mock_send(reply_req_id, body, **kwargs):
            is_final = kwargs.get("is_final", False)
            sent_frames.append({
                "req_id": reply_req_id,
                "body": body,
                "is_final": is_final,
            })
            return {"errcode": 0, "errmsg": "ok"}

        adapter._send_reply_queued = AsyncMock(side_effect=mock_send)
        adapter._sent_frames = sent_frames


    @pytest.mark.asyncio
    async def test_finalize_sends_accumulated_tail(self):
        """Finalize emits the accumulated text with finish=true.

        With no chunker, finalize uses the caller's cumulative text directly.
        """
        from plugins.platforms.wecom.adapter import WeComAdapter

        adapter = WeComAdapter(PlatformConfig(enabled=True))
        adapter._last_chat_req_ids["chat-1"] = "req-1"
        adapter._ws = MagicMock(closed=False)
        self._mock_send_json_with_immediate_ack(adapter)

        # 1: intermediate frame (seed + content).
        await adapter.send_stream_frame("Short.", chat_id="chat-1")
        # 2: finalize with the same text. Content equals last_sent_content, so
        # the adapter appends a zero-width space to force a distinct final frame.
        ok = await adapter.send_stream_frame(
            "Short.", chat_id="chat-1", finalize=True,
        )
        assert ok is True
        # seed + content + finalize = 3 frames.
        assert len(adapter._sent_frames) == 3
        final_frame = adapter._sent_frames[-1]
        assert final_frame["body"]["stream"]["finish"] is True
        # Content survives the finalize (zero-width space appended when it
        # matched the previous frame verbatim).
        assert final_frame["body"]["stream"]["content"].startswith("Short.")

    @pytest.mark.asyncio
    async def test_duplicate_intermediate_content_is_deduped(self):
        """Identical cumulative content skips the send (pure identity-dedup),
        but still returns success."""
        from plugins.platforms.wecom.adapter import WeComAdapter

        adapter = WeComAdapter(PlatformConfig(enabled=True))
        adapter._last_chat_req_ids["chat-1"] = "req-1"
        adapter._ws = MagicMock(closed=False)
        self._mock_send_json_with_immediate_ack(adapter)

        await adapter.send_stream_frame("same text", chat_id="chat-1")
        ok = await adapter.send_stream_frame("same text", chat_id="chat-1")
        assert ok is True
        # seed + first content only; the identical repeat was deduped.
        assert len(adapter._sent_frames) == 2
        assert adapter._sent_frames[-1]["body"]["stream"]["content"] == "same text"


class TestFinalFrameAckTimeoutSemantics:
    """Regression: final-frame ack timeout must not raise / trigger fallback.

    See docs/rca-wecom-stream-final-ack-timeout-duplicate.md — when WeCom's
    ack returns past the 5s window but the frame *was* delivered, raising
    causes the upper layer to fall back to a normal markdown send and the
    user sees the same content twice.  The fix: treat ack timeout as
    success-with-uncertainty and let the caller mark the turn delivered.
    """

    @pytest.mark.asyncio
    async def test_final_frame_ack_timeout_returns_success(self):
        from plugins.platforms.wecom.adapter import WeComAdapter

        adapter = WeComAdapter(PlatformConfig(enabled=True))
        adapter._ws = MagicMock(closed=False)
        adapter._REPLY_ACK_TIMEOUT = 0.05  # snappy for the test
        # _send_json succeeds but no ack ever arrives.
        adapter._send_json = AsyncMock()

        response = await adapter._send_reply_queued(
            "req-1",
            {"msgtype": "stream", "stream": {"id": "stream_x", "content": "final", "finish": True}},
            is_final=True,
        )

        # Aligned-with-official semantics: success-shaped response with the
        # ack_pending flag set so callers can log / observe but no exception.
        assert response.get("errcode") == 0
        assert response.get("ack_pending") is True

    @pytest.mark.asyncio
    async def test_final_frame_send_failure_still_raises(self):
        """Genuine send failures (network/serialization) must still propagate.

        The ack-timeout relaxation only covers the case where the bytes went
        out but the ack didn't return. If ``_send_json`` itself raises, the
        upstream caller still needs to see the error.
        """
        from plugins.platforms.wecom.adapter import WeComAdapter

        adapter = WeComAdapter(PlatformConfig(enabled=True))
        adapter._ws = MagicMock(closed=False)
        adapter._send_json = AsyncMock(side_effect=RuntimeError("ws closed"))

        with pytest.raises(RuntimeError, match="ws closed"):
            await adapter._send_reply_queued(
                "req-1",
                {"msgtype": "stream", "stream": {"id": "stream_x", "content": "x", "finish": True}},
                is_final=True,
            )



@pytest.mark.asyncio
async def test_oversized_cron_output_is_sent_as_successive_markdown_replies():
    """The router hands the full cron payload over and every chunk goes out on the cached reply
    req_id (groups cannot send proactively), each through the rate-limited per-chat queue,
    instead of the tail being sliced off at 4000 chars."""
    from gateway.config import GatewayConfig
    from gateway.delivery import DeliveryRouter
    from plugins.platforms.wecom.adapter import MAX_MESSAGE_LENGTH, WeComAdapter

    adapter = WeComAdapter(PlatformConfig(enabled=True))
    adapter._last_chat_req_ids["group-1"] = "req-1"
    adapter._group_chat_ids.add("group-1")
    sent = []

    async def _reply(req_id, body, **_):
        sent.append((req_id, body["markdown"]["content"]))
        return {"headers": {"req_id": req_id}, "errcode": 0}

    adapter._send_reply_request = _reply
    adapter._send_request = AsyncMock()
    content = "\n\n".join(f"line {i} " + "x" * 200 for i in range(60))
    payload = DeliveryRouter(GatewayConfig())._cap_oversized_output(adapter, content, "job")
    result = await adapter.send("group-1", payload)

    assert result.success
    assert len(sent) > 1 and {req for req, _ in sent} == {"req-1"}
    assert max(len(text) for _, text in sent) <= MAX_MESSAGE_LENGTH
    assert "line 59 " in sent[-1][1]
    adapter._send_request.assert_not_awaited()
    # Each chunk is its own queued send, so each draws a token from the 30 msgs/min bucket.
    assert adapter._get_token_usage("group-1")["normal"] == len(sent)
