import asyncio
import json
import tempfile
import time
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock

import httpx
import pytest

from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.weixin import runtime as weixin_mod
from nanobot.channels.weixin.runtime import (
    ITEM_IMAGE,
    ITEM_TEXT,
    MESSAGE_TYPE_BOT,
    WEIXIN_CHANNEL_VERSION,
    WeixinAuthError,
    WeixinChannel,
    WeixinConfig,
    _decrypt_aes_ecb,
    _encrypt_aes_ecb,
)


def _make_channel() -> tuple[WeixinChannel, MessageBus]:
    bus = MessageBus()
    channel = WeixinChannel(
        WeixinConfig(
            enabled=True,
            allow_from=["*"],
            state_dir=tempfile.mkdtemp(prefix="nanobot-weixin-test-"),
        ),
        bus,
    )
    return channel, bus


@pytest.fixture
def no_qr_poll_delay(monkeypatch: pytest.MonkeyPatch) -> None:
    """Keep QR state-machine tests event-driven without one-second polling sleeps."""
    real_sleep = asyncio.sleep

    async def yield_to_loop(_delay: float) -> None:
        await real_sleep(0)

    class AsyncioProxy:
        sleep = staticmethod(yield_to_loop)

        def __getattr__(self, name: str):
            return getattr(asyncio, name)

    monkeypatch.setattr(weixin_mod, "asyncio", AsyncioProxy())


def test_make_headers_includes_route_tag_when_configured() -> None:
    bus = MessageBus()
    channel = WeixinChannel(
        WeixinConfig(enabled=True, allow_from=["*"], route_tag=123),
        bus,
    )
    channel._token = "token"

    headers = channel._make_headers()

    assert headers["Authorization"] == "Bearer token"
    assert headers["SKRouteTag"] == "123"
    assert headers["iLink-App-Id"] == "bot"
    assert headers["iLink-App-ClientVersion"] == str((2 << 16) | (4 << 8) | 6)


def test_channel_version_matches_reference_plugin_version() -> None:
    assert WEIXIN_CHANNEL_VERSION == "2.4.6"


def test_save_and_load_state_persists_context_tokens(tmp_path) -> None:
    bus = MessageBus()
    channel = WeixinChannel(
        WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
        bus,
    )
    channel._token = "token"
    channel._get_updates_buf = "cursor"
    channel._context_tokens = {"wx-user": "ctx-1"}

    channel._save_state()

    saved = json.loads((tmp_path / "account.json").read_text())
    assert saved["context_tokens"] == {"wx-user": "ctx-1"}

    restored = WeixinChannel(
        WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
        bus,
    )

    assert restored._load_state() is True
    assert restored._context_tokens == {"wx-user": "ctx-1"}


def test_save_state_preserves_token_committed_by_another_instance(tmp_path) -> None:
    channel = WeixinChannel(
        WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
        MessageBus(),
    )
    channel._token = "old-token"
    channel._save_state()

    replacement = {
        "token": "new-token",
        "base_url": "https://new.example",
        "get_updates_buf": "",
        "context_tokens": {},
        "typing_tickets": {},
    }
    (tmp_path / "account.json").write_text(json.dumps(replacement), encoding="utf-8")

    channel._get_updates_buf = "stale-cursor"
    channel._save_state()

    assert json.loads((tmp_path / "account.json").read_text()) == replacement


def test_save_state_force_overwrites_replaced_token(tmp_path) -> None:
    channel = WeixinChannel(
        WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
        MessageBus(),
    )
    (tmp_path / "account.json").write_text(json.dumps({"token": "old-token"}), encoding="utf-8")

    channel.connect_commit_account(token="new-token", base_url="https://new.example")

    saved = json.loads((tmp_path / "account.json").read_text())
    assert saved["token"] == "new-token"
    assert saved["base_url"] == "https://new.example"


def test_save_state_persists_explicit_config_token_over_stale_state(tmp_path) -> None:
    channel = WeixinChannel(
        WeixinConfig(
            enabled=True,
            allow_from=["*"],
            token="configured-token",
            state_dir=str(tmp_path),
        ),
        MessageBus(),
    )
    channel._token = "configured-token"
    channel._get_updates_buf = "current-cursor"
    (tmp_path / "account.json").write_text(
        json.dumps({"token": "stale-token", "get_updates_buf": "stale-cursor"}),
        encoding="utf-8",
    )

    channel._save_state()

    saved = json.loads((tmp_path / "account.json").read_text())
    assert saved["token"] == "configured-token"
    assert saved["get_updates_buf"] == "current-cursor"


def test_save_state_preserves_qr_replacement_of_configured_token(tmp_path) -> None:
    config = WeixinConfig(
        enabled=True,
        allow_from=["*"],
        token="configured-token",
        state_dir=str(tmp_path),
    )
    old_runtime = WeixinChannel(config, MessageBus())
    old_runtime._token = "configured-token"

    replacement = WeixinChannel(config, MessageBus())
    replacement.connect_commit_account(
        token="replacement-token",
        base_url="https://new.example",
    )

    old_runtime._save_state()

    saved = json.loads((tmp_path / "account.json").read_text())
    assert saved["token"] == "replacement-token"
    assert saved["base_url"] == "https://new.example"


def test_save_state_with_empty_runtime_token_preserves_persisted_account(tmp_path) -> None:
    channel = WeixinChannel(
        WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
        MessageBus(),
    )
    persisted = {"token": "persisted-token", "get_updates_buf": "persisted-cursor"}
    (tmp_path / "account.json").write_text(json.dumps(persisted), encoding="utf-8")

    channel._save_state()

    assert json.loads((tmp_path / "account.json").read_text()) == persisted


@pytest.mark.asyncio
async def test_login_force_ignores_persisted_account_through_qr_flow(tmp_path) -> None:
    persisted = {
        "token": "persisted-token",
        "get_updates_buf": "persisted-cursor",
        "context_tokens": {"wx-user": "ctx-persisted"},
        "typing_tickets": {"wx-user": {"ticket": "ticket-persisted"}},
        "base_url": "https://persisted.example",
    }
    channel = WeixinChannel(
        WeixinConfig(
            enabled=True,
            allow_from=["*"],
            token="configured-token",
            state_dir=str(tmp_path),
        ),
        MessageBus(),
    )
    (tmp_path / "account.json").write_text(
        json.dumps(persisted),
        encoding="utf-8",
    )
    channel._print_qr_code = lambda _url: None
    channel._api_post = AsyncMock(
        side_effect=[
            {"qrcode": "qr-1", "qrcode_img_content": "url-1"},
            {"qrcode": "qr-2", "qrcode_img_content": "url-2"},
        ]
    )
    channel._api_get_with_base = AsyncMock(
        side_effect=[
            {"status": "expired"},
            {"status": "binded_redirect"},
        ]
    )

    ok = await channel.login(force=True)

    assert ok is False
    assert [call.args[1]["local_token_list"] for call in channel._api_post.await_args_list] == [
        [],
        [],
    ]
    assert channel._token == ""
    assert channel._get_updates_buf == ""
    assert channel._context_tokens == {}
    assert channel._typing_tickets == {}
    assert channel.config.base_url == "https://ilinkai.weixin.qq.com"
    assert json.loads((tmp_path / "account.json").read_text()) == persisted


@pytest.mark.asyncio
async def test_login_without_force_reuses_persisted_account(tmp_path) -> None:
    channel = WeixinChannel(
        WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
        MessageBus(),
    )
    (tmp_path / "account.json").write_text(
        json.dumps(
            {
                "token": "persisted-token",
                "get_updates_buf": "persisted-cursor",
                "context_tokens": {"wx-user": "ctx-persisted"},
                "base_url": "https://persisted.example",
            }
        ),
        encoding="utf-8",
    )
    channel._qr_login = AsyncMock(return_value=False)

    ok = await channel.login(force=False)

    assert ok is True
    channel._qr_login.assert_not_awaited()
    assert channel._token == "persisted-token"
    assert channel._get_updates_buf == "persisted-cursor"
    assert channel._context_tokens == {"wx-user": "ctx-persisted"}
    assert channel.config.base_url == "https://persisted.example"


@pytest.mark.asyncio
async def test_process_message_deduplicates_inbound_ids() -> None:
    channel, bus = _make_channel()
    msg = {
        "message_type": 1,
        "message_id": "m1",
        "from_user_id": "wx-user",
        "context_token": "ctx-1",
        "item_list": [
            {"type": ITEM_TEXT, "text_item": {"text": "hello"}},
        ],
    }

    await channel._process_message(msg)
    first = await asyncio.wait_for(bus.consume_inbound(), timeout=1.0)
    await channel._process_message(msg)

    assert first.sender_id == "wx-user"
    assert first.chat_id == "wx-user"
    assert first.content == "hello"
    assert bus.inbound_size == 0


@pytest.mark.asyncio
async def test_process_message_caches_context_token_and_send_uses_it() -> None:
    channel, _bus = _make_channel()
    channel._client = object()
    channel._token = "token"
    channel._send_text = AsyncMock()

    await channel._process_message(
        {
            "message_type": 1,
            "message_id": "m2",
            "from_user_id": "wx-user",
            "context_token": "ctx-2",
            "item_list": [
                {"type": ITEM_TEXT, "text_item": {"text": "ping"}},
            ],
        }
    )

    await channel.send(
        type("Msg", (), {"chat_id": "wx-user", "content": "pong", "media": [], "metadata": {}})()
    )

    channel._send_text.assert_awaited_once_with("wx-user", "pong", "ctx-2")


@pytest.mark.asyncio
async def test_process_message_pairs_unauthorized_sender_before_media_side_effects(
    monkeypatch,
    tmp_path,
) -> None:
    bus = MessageBus()
    channel = WeixinChannel(
        WeixinConfig(enabled=True, allow_from=["allowed-user"], state_dir=str(tmp_path)),
        bus,
    )
    channel._client = object()
    channel._token = "token"
    channel._download_media_item = AsyncMock(return_value="/tmp/test.jpg")
    channel._start_typing = AsyncMock()
    channel._get_typing_ticket = AsyncMock(return_value="")
    channel._send_text = AsyncMock()
    monkeypatch.setattr(
        "nanobot.channels.base.generate_code", lambda _ch, _sid: "ABCD-EFGH"
    )

    await channel._process_message(
        {
            "message_type": 1,
            "message_id": "m-unauthorized",
            "from_user_id": "blocked-user",
            "context_token": "ctx-blocked",
            "item_list": [
                {"type": ITEM_IMAGE, "image_item": {"media": {"encrypt_query_param": "x"}}},
            ],
        }
    )

    assert channel._context_tokens == {}
    channel._download_media_item.assert_not_awaited()
    channel._start_typing.assert_not_awaited()
    channel._send_text.assert_awaited_once()
    send_args = channel._send_text.await_args.args
    assert send_args[0] == "blocked-user"
    assert "ABCD-EFGH" in send_args[1]
    assert send_args[2] == "ctx-blocked"
    assert bus.inbound_size == 0


@pytest.mark.asyncio
async def test_process_message_persists_context_token_to_state_file(tmp_path) -> None:
    bus = MessageBus()
    channel = WeixinChannel(
        WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
        bus,
    )

    await channel._process_message(
        {
            "message_type": 1,
            "message_id": "m2b",
            "from_user_id": "wx-user",
            "context_token": "ctx-2b",
            "item_list": [
                {"type": ITEM_TEXT, "text_item": {"text": "ping"}},
            ],
        }
    )

    saved = json.loads((tmp_path / "account.json").read_text())
    assert saved["context_tokens"] == {"wx-user": "ctx-2b"}


@pytest.mark.asyncio
async def test_process_message_extracts_media_and_preserves_paths() -> None:
    channel, bus = _make_channel()
    channel._download_media_item = AsyncMock(return_value="/tmp/test.jpg")

    await channel._process_message(
        {
            "message_type": 1,
            "message_id": "m3",
            "from_user_id": "wx-user",
            "context_token": "ctx-3",
            "item_list": [
                {"type": ITEM_IMAGE, "image_item": {"media": {"encrypt_query_param": "x"}}},
            ],
        }
    )

    inbound = await asyncio.wait_for(bus.consume_inbound(), timeout=1.0)

    assert "[image]" in inbound.content
    assert "/tmp/test.jpg" in inbound.content
    assert inbound.media == ["/tmp/test.jpg"]


@pytest.mark.asyncio
async def test_process_message_falls_back_to_referenced_media_when_no_top_level_media() -> None:
    channel, bus = _make_channel()
    channel._download_media_item = AsyncMock(return_value="/tmp/ref.jpg")

    await channel._process_message(
        {
            "message_type": 1,
            "message_id": "m3-ref-fallback",
            "from_user_id": "wx-user",
            "context_token": "ctx-3-ref-fallback",
            "item_list": [
                {
                    "type": ITEM_TEXT,
                    "text_item": {"text": "reply to image"},
                    "ref_msg": {
                        "message_item": {
                            "type": ITEM_IMAGE,
                            "image_item": {"media": {"encrypt_query_param": "ref-enc"}},
                        },
                    },
                },
            ],
        }
    )

    inbound = await asyncio.wait_for(bus.consume_inbound(), timeout=1.0)

    channel._download_media_item.assert_awaited_once_with(
        {"media": {"encrypt_query_param": "ref-enc"}},
        "image",
    )
    assert inbound.media == ["/tmp/ref.jpg"]
    assert "reply to image" in inbound.content
    assert "[image]" in inbound.content


@pytest.mark.asyncio
async def test_process_message_does_not_use_referenced_fallback_when_top_level_media_exists() -> None:
    channel, bus = _make_channel()
    channel._download_media_item = AsyncMock(side_effect=["/tmp/top.jpg", "/tmp/ref.jpg"])

    await channel._process_message(
        {
            "message_type": 1,
            "message_id": "m3-ref-no-fallback",
            "from_user_id": "wx-user",
            "context_token": "ctx-3-ref-no-fallback",
            "item_list": [
                {"type": ITEM_IMAGE, "image_item": {"media": {"encrypt_query_param": "top-enc"}}},
                {
                    "type": ITEM_TEXT,
                    "text_item": {"text": "has top-level media"},
                    "ref_msg": {
                        "message_item": {
                            "type": ITEM_IMAGE,
                            "image_item": {"media": {"encrypt_query_param": "ref-enc"}},
                        },
                    },
                },
            ],
        }
    )

    inbound = await asyncio.wait_for(bus.consume_inbound(), timeout=1.0)

    channel._download_media_item.assert_awaited_once_with(
        {"media": {"encrypt_query_param": "top-enc"}},
        "image",
    )
    assert inbound.media == ["/tmp/top.jpg"]
    assert "/tmp/ref.jpg" not in inbound.content


@pytest.mark.asyncio
async def test_process_message_does_not_fallback_when_top_level_media_exists_but_download_fails() -> None:
    channel, bus = _make_channel()
    # Top-level image download fails (None), referenced image would succeed if fallback were triggered.
    channel._download_media_item = AsyncMock(side_effect=[None, "/tmp/ref.jpg"])

    await channel._process_message(
        {
            "message_type": 1,
            "message_id": "m3-ref-no-fallback-on-failure",
            "from_user_id": "wx-user",
            "context_token": "ctx-3-ref-no-fallback-on-failure",
            "item_list": [
                {"type": ITEM_IMAGE, "image_item": {"media": {"encrypt_query_param": "top-enc"}}},
                {
                    "type": ITEM_TEXT,
                    "text_item": {"text": "quoted has media"},
                    "ref_msg": {
                        "message_item": {
                            "type": ITEM_IMAGE,
                            "image_item": {"media": {"encrypt_query_param": "ref-enc"}},
                        },
                    },
                },
            ],
        }
    )

    inbound = await asyncio.wait_for(bus.consume_inbound(), timeout=1.0)

    # Should only attempt top-level media item; reference fallback must not activate.
    channel._download_media_item.assert_awaited_once_with(
        {"media": {"encrypt_query_param": "top-enc"}},
        "image",
    )
    assert inbound.media == []
    assert "[image]" in inbound.content
    assert "/tmp/ref.jpg" not in inbound.content


@pytest.mark.asyncio
async def test_send_without_context_token_raises() -> None:
    channel, _bus = _make_channel()
    channel._client = object()
    channel._token = "token"
    channel._send_text = AsyncMock()

    with pytest.raises(RuntimeError, match="context_token missing"):
        await channel.send(
            type("Msg", (), {"chat_id": "unknown-user", "content": "pong", "media": [], "metadata": {}})()
        )

    channel._send_text.assert_not_awaited()


@pytest.mark.asyncio
async def test_send_raises_when_authentication_is_required() -> None:
    channel, _bus = _make_channel()
    channel._client = object()
    channel._token = "token"
    channel._context_tokens["wx-user"] = "ctx-2"
    channel._auth_required = True
    channel._send_text = AsyncMock()

    with pytest.raises(WeixinAuthError, match="bot token is stale"):
        await channel.send(
            type("Msg", (), {"chat_id": "wx-user", "content": "pong", "media": [], "metadata": {}})()
        )

    channel._send_text.assert_not_awaited()


@pytest.mark.asyncio
async def test_get_typing_ticket_fetches_and_caches_per_user() -> None:
    channel, _bus = _make_channel()
    channel._client = object()
    channel._token = "token"
    channel._api_post = AsyncMock(return_value={"ret": 0, "typing_ticket": "ticket-1"})

    first = await channel._get_typing_ticket("wx-user", "ctx-1")
    second = await channel._get_typing_ticket("wx-user", "ctx-2")

    assert first == "ticket-1"
    assert second == "ticket-1"
    channel._api_post.assert_awaited_once_with(
        "ilink/bot/getconfig",
        {"ilink_user_id": "wx-user", "context_token": "ctx-1", "base_info": weixin_mod.BASE_INFO},
    )


@pytest.mark.asyncio
async def test_send_uses_typing_start_and_cancel_when_ticket_available() -> None:
    channel, _bus = _make_channel()
    channel._client = object()
    channel._token = "token"
    channel._context_tokens["wx-user"] = "ctx-typing"
    channel._context_token_at["wx-user"] = time.time()
    channel._send_text = AsyncMock()
    channel._api_post = AsyncMock(
        side_effect=[
            {"ret": 0, "typing_ticket": "ticket-typing"},
            {"ret": 0},
            {"ret": 0},
        ]
    )

    await channel.send(
        type("Msg", (), {"chat_id": "wx-user", "content": "pong", "media": [], "metadata": {}})()
    )

    channel._send_text.assert_awaited_once_with("wx-user", "pong", "ctx-typing")
    assert channel._api_post.await_count == 3
    assert channel._api_post.await_args_list[0].args[0] == "ilink/bot/getconfig"
    assert channel._api_post.await_args_list[1].args[0] == "ilink/bot/sendtyping"
    assert channel._api_post.await_args_list[1].args[1]["status"] == 1
    assert channel._api_post.await_args_list[2].args[0] == "ilink/bot/sendtyping"
    assert channel._api_post.await_args_list[2].args[1]["status"] == 2


@pytest.mark.asyncio
async def test_send_still_sends_text_when_typing_ticket_missing() -> None:
    channel, _bus = _make_channel()
    channel._client = object()
    channel._token = "token"
    channel._context_tokens["wx-user"] = "ctx-no-ticket"
    channel._context_token_at["wx-user"] = time.time()
    channel._send_text = AsyncMock()
    channel._api_post = AsyncMock(return_value={"ret": 1, "errmsg": "no config"})

    await channel.send(
        type("Msg", (), {"chat_id": "wx-user", "content": "pong", "media": [], "metadata": {}})()
    )

    channel._send_text.assert_awaited_once_with("wx-user", "pong", "ctx-no-ticket")
    channel._api_post.assert_awaited_once()
    assert channel._api_post.await_args_list[0].args[0] == "ilink/bot/getconfig"


@pytest.mark.asyncio
async def test_poll_once_requires_login_on_stale_token() -> None:
    channel, _bus = _make_channel()
    channel._client = SimpleNamespace(timeout=None)
    channel._token = "token"
    channel._api_post = AsyncMock(return_value={"ret": 0, "errcode": -14, "errmsg": "expired"})

    with pytest.raises(WeixinAuthError, match="no replacement credentials"):
        await channel._poll_once()

    assert channel._auth_required is True


@pytest.mark.asyncio
async def test_poll_once_reloads_refreshed_state_after_stale_token(
    tmp_path,
) -> None:
    channel = WeixinChannel(
        WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
        MessageBus(),
    )
    channel._token = "old-token"
    channel._save_state()
    (tmp_path / "account.json").write_text(
        json.dumps({"token": "new-token", "base_url": "https://new.example"}),
        encoding="utf-8",
    )
    channel._client = object()
    channel._api_post = AsyncMock(
        side_effect=[
            {"ret": 0, "errcode": -14, "errmsg": "stale"},
            {"ret": 0},
        ]
    )

    await channel._poll_once()

    assert channel._token == "new-token"
    assert channel.config.base_url == "https://new.example"


@pytest.mark.asyncio
async def test_poll_once_keeps_explicit_token_and_requires_login(
    tmp_path,
) -> None:
    channel = WeixinChannel(
        WeixinConfig(
            enabled=True,
            allow_from=["*"],
            token="configured-token",
            state_dir=str(tmp_path),
        ),
        MessageBus(),
    )
    channel._token = "configured-token"
    (tmp_path / "account.json").write_text(
        json.dumps({"token": "stale-token", "base_url": "https://stale.example"}),
        encoding="utf-8",
    )
    channel._client = object()
    channel._api_post = AsyncMock(
        return_value={"ret": 0, "errcode": -14, "errmsg": "stale"}
    )

    with pytest.raises(WeixinAuthError, match="no replacement credentials"):
        await channel._poll_once()

    assert channel._token == "configured-token"
    assert channel.config.base_url == "https://ilinkai.weixin.qq.com"


@pytest.mark.asyncio
async def test_poll_once_loads_qr_replacement_for_configured_token(tmp_path) -> None:
    config = WeixinConfig(
        enabled=True,
        allow_from=["*"],
        token="configured-token",
        state_dir=str(tmp_path),
    )
    replacement = WeixinChannel(config, MessageBus())
    replacement.connect_commit_account(
        token="replacement-token",
        base_url="https://new.example",
    )

    channel = WeixinChannel(config, MessageBus())
    channel._token = "configured-token"
    channel._client = object()
    channel._api_post = AsyncMock(
        side_effect=[
            {"ret": 0, "errcode": -14, "errmsg": "stale"},
            {"ret": 0},
        ]
    )

    await channel._poll_once()

    assert channel._token == "replacement-token"
    assert channel.config.base_url == "https://new.example"


@pytest.mark.asyncio
async def test_start_uses_qr_replacement_for_configured_token(tmp_path) -> None:
    config = WeixinConfig(
        enabled=True,
        allow_from=["*"],
        token="configured-token",
        state_dir=str(tmp_path),
    )
    connector = WeixinChannel(config, MessageBus())
    connector.connect_commit_account(
        token="replacement-token",
        base_url="https://new.example",
    )

    channel = WeixinChannel(config, MessageBus())
    observed_tokens: list[str] = []

    async def stop_after_first_poll() -> None:
        observed_tokens.append(channel._token)
        channel._running = False

    channel._notify_lifecycle = AsyncMock()  # type: ignore[method-assign]
    channel._poll_once = stop_after_first_poll  # type: ignore[method-assign]

    await channel.start()
    await channel.stop()

    assert observed_tokens == ["replacement-token"]
    assert channel.config.base_url == "https://new.example"


@pytest.mark.asyncio
async def test_manager_surfaces_actionable_weixin_auth_error_without_traceback(
    tmp_path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    from nanobot.channels import manager as manager_mod

    channel = WeixinChannel(
        WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
        MessageBus(),
    )
    channel.start = AsyncMock(  # type: ignore[method-assign]
        side_effect=WeixinAuthError(
            "getupdates",
            errcode=-14,
            errmsg="stale",
        )
    )
    errors: list[str] = []
    tracebacks: list[str] = []
    monkeypatch.setattr(
        manager_mod.logger,
        "error",
        lambda message, *args: errors.append(message.format(*args)),
    )
    monkeypatch.setattr(
        manager_mod.logger,
        "exception",
        lambda message, *args: tracebacks.append(message.format(*args)),
    )
    manager = manager_mod.ChannelManager.__new__(manager_mod.ChannelManager)
    manager._channel_errors = {}

    await manager._start_channel("weixin", channel)

    assert manager._channel_errors["weixin"] == (
        "WeChat login expired. Scan again to reconnect."
    )
    assert errors == [
        "Failed to start channel weixin: WeChat login expired. Scan again to reconnect."
    ]
    assert tracebacks == []


@pytest.mark.asyncio
async def test_qr_login_refreshes_expired_qr_and_then_succeeds(
    no_qr_poll_delay,
) -> None:
    channel, _bus = _make_channel()
    channel._running = True
    channel._save_state = lambda **_kwargs: None
    channel._print_qr_code = lambda url: None
    channel._api_post = AsyncMock(
        side_effect=[
            {"qrcode": "qr-1", "qrcode_img_content": "url-1"},
            {"qrcode": "qr-2", "qrcode_img_content": "url-2"},
        ]
    )
    channel._api_get_with_base = AsyncMock(
        side_effect=[
            {"status": "expired"},
            {
                "status": "confirmed",
                "bot_token": "token-2",
                "ilink_bot_id": "bot-2",
                "baseurl": "https://example.test",
                "ilink_user_id": "wx-user",
            },
        ]
    )

    ok = await channel._qr_login()

    assert ok is True
    assert channel._token == "token-2"
    assert channel.config.base_url == "https://example.test"


@pytest.mark.asyncio
async def test_qr_login_returns_false_after_too_many_expired_qr_codes(
    no_qr_poll_delay,
) -> None:
    channel, _bus = _make_channel()
    channel._running = True
    channel._print_qr_code = lambda url: None
    channel._api_post = AsyncMock(
        side_effect=[
            {"qrcode": "qr-1", "qrcode_img_content": "url-1"},
            {"qrcode": "qr-2", "qrcode_img_content": "url-2"},
            {"qrcode": "qr-3", "qrcode_img_content": "url-3"},
            {"qrcode": "qr-4", "qrcode_img_content": "url-4"},
        ]
    )
    channel._api_get_with_base = AsyncMock(
        side_effect=[
            {"status": "expired"},
            {"status": "expired"},
            {"status": "expired"},
            {"status": "expired"},
        ]
    )

    ok = await channel._qr_login()

    assert ok is False


@pytest.mark.asyncio
async def test_qr_login_switches_polling_base_url_on_redirect_status(
    no_qr_poll_delay,
) -> None:
    channel, _bus = _make_channel()
    channel._running = True
    channel._save_state = lambda **_kwargs: None
    channel._print_qr_code = lambda url: None
    channel._fetch_qr_code = AsyncMock(return_value=("qr-1", "url-1"))

    status_side_effect = [
        {"status": "scaned_but_redirect", "redirect_host": "idc.redirect.test"},
        {
            "status": "confirmed",
            "bot_token": "token-3",
            "ilink_bot_id": "bot-3",
            "baseurl": "https://example.test",
            "ilink_user_id": "wx-user",
        },
    ]
    channel._api_get = AsyncMock(side_effect=list(status_side_effect))
    channel._api_get_with_base = AsyncMock(side_effect=list(status_side_effect))

    ok = await channel._qr_login()

    assert ok is True
    assert channel._token == "token-3"
    assert channel._api_get_with_base.await_count == 2
    first_call = channel._api_get_with_base.await_args_list[0]
    second_call = channel._api_get_with_base.await_args_list[1]
    assert first_call.kwargs["base_url"] == "https://ilinkai.weixin.qq.com"
    assert second_call.kwargs["base_url"] == "https://idc.redirect.test"


@pytest.mark.asyncio
async def test_qr_login_redirect_without_host_keeps_current_polling_base_url(
    no_qr_poll_delay,
) -> None:
    channel, _bus = _make_channel()
    channel._running = True
    channel._save_state = lambda **_kwargs: None
    channel._print_qr_code = lambda url: None
    channel._fetch_qr_code = AsyncMock(return_value=("qr-1", "url-1"))

    status_side_effect = [
        {"status": "scaned_but_redirect"},
        {
            "status": "confirmed",
            "bot_token": "token-4",
            "ilink_bot_id": "bot-4",
            "baseurl": "https://example.test",
            "ilink_user_id": "wx-user",
        },
    ]
    channel._api_get = AsyncMock(side_effect=list(status_side_effect))
    channel._api_get_with_base = AsyncMock(side_effect=list(status_side_effect))

    ok = await channel._qr_login()

    assert ok is True
    assert channel._token == "token-4"
    assert channel._api_get_with_base.await_count == 2
    first_call = channel._api_get_with_base.await_args_list[0]
    second_call = channel._api_get_with_base.await_args_list[1]
    assert first_call.kwargs["base_url"] == "https://ilinkai.weixin.qq.com"
    assert second_call.kwargs["base_url"] == "https://ilinkai.weixin.qq.com"


@pytest.mark.asyncio
async def test_qr_login_resets_redirect_base_url_after_qr_refresh(
    no_qr_poll_delay,
) -> None:
    channel, _bus = _make_channel()
    channel._running = True
    channel._save_state = lambda **_kwargs: None
    channel._print_qr_code = lambda url: None
    channel._fetch_qr_code = AsyncMock(side_effect=[("qr-1", "url-1"), ("qr-2", "url-2")])

    channel._api_get_with_base = AsyncMock(
        side_effect=[
            {"status": "scaned_but_redirect", "redirect_host": "idc.redirect.test"},
            {"status": "expired"},
            {
                "status": "confirmed",
                "bot_token": "token-5",
                "ilink_bot_id": "bot-5",
                "baseurl": "https://example.test",
                "ilink_user_id": "wx-user",
            },
        ]
    )

    ok = await channel._qr_login()

    assert ok is True
    assert channel._token == "token-5"
    assert channel._api_get_with_base.await_count == 3
    first_call = channel._api_get_with_base.await_args_list[0]
    second_call = channel._api_get_with_base.await_args_list[1]
    third_call = channel._api_get_with_base.await_args_list[2]
    assert first_call.kwargs["base_url"] == "https://ilinkai.weixin.qq.com"
    assert second_call.kwargs["base_url"] == "https://idc.redirect.test"
    assert third_call.kwargs["base_url"] == "https://ilinkai.weixin.qq.com"


@pytest.mark.asyncio
async def test_process_message_skips_bot_messages() -> None:
    channel, bus = _make_channel()

    await channel._process_message(
        {
            "message_type": MESSAGE_TYPE_BOT,
            "message_id": "m4",
            "from_user_id": "wx-user",
            "item_list": [
                {"type": ITEM_TEXT, "text_item": {"text": "hello"}},
            ],
        }
    )

    assert bus.inbound_size == 0


@pytest.mark.asyncio
async def test_process_message_starts_typing_on_inbound() -> None:
    """Typing indicator fires immediately when user message arrives."""
    channel, _bus = _make_channel()
    channel._running = True
    channel._client = object()
    channel._token = "token"
    channel._start_typing = AsyncMock()

    await channel._process_message(
        {
            "message_type": 1,
            "message_id": "m-typing",
            "from_user_id": "wx-user",
            "context_token": "ctx-typing",
            "item_list": [
                {"type": ITEM_TEXT, "text_item": {"text": "hello"}},
            ],
        }
    )

    channel._start_typing.assert_awaited_once_with("wx-user", "ctx-typing")


@pytest.mark.asyncio
async def test_send_final_message_clears_typing_indicator() -> None:
    """Non-progress send should cancel typing status."""
    channel, _bus = _make_channel()
    channel._client = object()
    channel._token = "token"
    channel._context_tokens["wx-user"] = "ctx-2"
    channel._typing_tickets["wx-user"] = {"ticket": "ticket-2", "next_fetch_at": 9999999999}
    channel._send_text = AsyncMock()
    channel._api_post = AsyncMock(return_value={"ret": 0})

    await channel.send(
        type("Msg", (), {"chat_id": "wx-user", "content": "pong", "media": [], "metadata": {}})()
    )

    channel._send_text.assert_awaited_once_with("wx-user", "pong", "ctx-2")
    typing_cancel_calls = [
        c for c in channel._api_post.await_args_list
        if c.args[0] == "ilink/bot/sendtyping" and c.args[1]["status"] == 2
    ]
    assert len(typing_cancel_calls) >= 1


@pytest.mark.asyncio
async def test_send_progress_message_keeps_typing_indicator() -> None:
    """Progress messages must not cancel typing status."""
    channel, _bus = _make_channel()
    channel._client = object()
    channel._token = "token"
    channel._context_tokens["wx-user"] = "ctx-2"
    channel._typing_tickets["wx-user"] = {"ticket": "ticket-2", "next_fetch_at": 9999999999}
    channel._send_text = AsyncMock()
    channel._api_post = AsyncMock(return_value={"ret": 0})

    await channel.send(
        type(
            "Msg",
            (),
            {
                "chat_id": "wx-user",
                "content": "thinking",
                "media": [],
                "event": ProgressEvent(content="thinking"),
                "metadata": {},
            },
        )()
    )

    channel._send_text.assert_awaited_once_with("wx-user", "thinking", "ctx-2")
    typing_cancel_calls = [
        c for c in channel._api_post.await_args_list
        if c.args and c.args[0] == "ilink/bot/sendtyping" and c.args[1].get("status") == 2
    ]
    assert len(typing_cancel_calls) == 0


class _DummyHttpResponse:
    def __init__(self, *, headers: dict[str, str] | None = None, status_code: int = 200) -> None:
        self.headers = headers or {}
        self.status_code = status_code

    def raise_for_status(self) -> None:
        return None


@pytest.mark.asyncio
async def test_send_media_uses_upload_full_url_when_present(tmp_path) -> None:
    channel, _bus = _make_channel()

    media_file = tmp_path / "photo.jpg"
    media_file.write_bytes(b"hello-weixin")

    cdn_post = AsyncMock(return_value=_DummyHttpResponse(headers={"x-encrypted-param": "dl-param"}))
    channel._client = SimpleNamespace(post=cdn_post)
    channel._api_post = AsyncMock(
        side_effect=[
            {
                "upload_full_url": "https://upload-full.example.test/path?foo=bar",
                "upload_param": "should-not-be-used",
            },
            {"ret": 0},
        ]
    )

    await channel._send_media_file("wx-user", str(media_file), "ctx-1")

    # first POST call is CDN upload
    cdn_url = cdn_post.await_args_list[0].args[0]
    assert cdn_url == "https://upload-full.example.test/path?foo=bar"


@pytest.mark.asyncio
async def test_send_media_falls_back_to_upload_param_url(tmp_path) -> None:
    channel, _bus = _make_channel()

    media_file = tmp_path / "photo.jpg"
    media_file.write_bytes(b"hello-weixin")

    cdn_post = AsyncMock(return_value=_DummyHttpResponse(headers={"x-encrypted-param": "dl-param"}))
    channel._client = SimpleNamespace(post=cdn_post)
    channel._api_post = AsyncMock(
        side_effect=[
            {"upload_param": "enc-need-fallback"},
            {"ret": 0},
        ]
    )

    await channel._send_media_file("wx-user", str(media_file), "ctx-1")

    cdn_url = cdn_post.await_args_list[0].args[0]
    assert cdn_url.startswith(f"{channel.config.cdn_base_url}/upload?encrypted_query_param=enc-need-fallback")
    assert "&filekey=" in cdn_url


@pytest.mark.asyncio
async def test_send_media_voice_file_uses_voice_item_and_voice_upload_type(tmp_path) -> None:
    channel, _bus = _make_channel()

    media_file = tmp_path / "voice.mp3"
    media_file.write_bytes(b"voice-bytes")

    cdn_post = AsyncMock(return_value=_DummyHttpResponse(headers={"x-encrypted-param": "voice-dl-param"}))
    channel._client = SimpleNamespace(post=cdn_post)
    channel._api_post = AsyncMock(
        side_effect=[
            {"upload_full_url": "https://upload-full.example.test/voice?foo=bar"},
            {"ret": 0},
        ]
    )

    await channel._send_media_file("wx-user", str(media_file), "ctx-voice")

    getupload_body = channel._api_post.await_args_list[0].args[1]
    assert getupload_body["media_type"] == 4

    sendmessage_body = channel._api_post.await_args_list[1].args[1]
    item = sendmessage_body["msg"]["item_list"][0]
    assert item["type"] == 3
    assert "voice_item" in item
    assert "file_item" not in item
    assert item["voice_item"]["media"]["encrypt_query_param"] == "voice-dl-param"


@pytest.mark.asyncio
async def test_send_typing_uses_keepalive_until_send_finishes() -> None:
    channel, _bus = _make_channel()
    channel._client = object()
    channel._token = "token"
    channel._context_tokens["wx-user"] = "ctx-typing-loop"

    typing_statuses: list[int] = []
    keepalive_seen = asyncio.Event()

    async def _api_post_side_effect(endpoint: str, _body: dict | None = None, *, auth: bool = True):
        if endpoint == "ilink/bot/getconfig":
            return {"ret": 0, "typing_ticket": "ticket-keepalive"}
        if endpoint == "ilink/bot/sendtyping" and _body is not None:
            status = int(_body["status"])
            typing_statuses.append(status)
            if status == 1 and typing_statuses.count(1) >= 2:
                keepalive_seen.set()
        return {"ret": 0}

    channel._api_post = AsyncMock(side_effect=_api_post_side_effect)

    async def _slow_send_text(*_args, **_kwargs) -> None:
        await asyncio.wait_for(keepalive_seen.wait(), timeout=1.0)

    channel._send_text = AsyncMock(side_effect=_slow_send_text)

    old_interval = weixin_mod.TYPING_KEEPALIVE_INTERVAL_S
    weixin_mod.TYPING_KEEPALIVE_INTERVAL_S = 0.01
    try:
        await channel.send(
            type("Msg", (), {"chat_id": "wx-user", "content": "pong", "media": [], "metadata": {}})()
        )
    finally:
        weixin_mod.TYPING_KEEPALIVE_INTERVAL_S = old_interval

    assert typing_statuses.count(1) >= 2
    assert typing_statuses[-1] == 2


@pytest.mark.asyncio
async def test_get_typing_ticket_failure_uses_backoff_and_cached_ticket(monkeypatch) -> None:
    channel, _bus = _make_channel()
    channel._client = object()
    channel._token = "token"

    now = {"value": 1000.0}
    monkeypatch.setattr(weixin_mod.time, "time", lambda: now["value"])
    monkeypatch.setattr(weixin_mod.random, "random", lambda: 0.5)

    channel._api_post = AsyncMock(return_value={"ret": 0, "typing_ticket": "ticket-ok"})
    first = await channel._get_typing_ticket("wx-user", "ctx-1")
    assert first == "ticket-ok"

    # force refresh window reached
    now["value"] = now["value"] + (12 * 60 * 60) + 1
    channel._api_post = AsyncMock(return_value={"ret": 1, "errmsg": "temporary failure"})

    # On refresh failure, should still return cached ticket and apply backoff.
    second = await channel._get_typing_ticket("wx-user", "ctx-2")
    assert second == "ticket-ok"
    assert channel._api_post.await_count == 1

    # Before backoff expiry, no extra fetch should happen.
    now["value"] += 1
    third = await channel._get_typing_ticket("wx-user", "ctx-3")
    assert third == "ticket-ok"
    assert channel._api_post.await_count == 1


@pytest.mark.asyncio
async def test_qr_login_treats_temporary_connect_error_as_wait_and_recovers(
    no_qr_poll_delay,
) -> None:
    channel, _bus = _make_channel()
    channel._running = True
    channel._save_state = lambda **_kwargs: None
    channel._print_qr_code = lambda url: None
    channel._fetch_qr_code = AsyncMock(return_value=("qr-1", "url-1"))

    request = httpx.Request("GET", "https://ilinkai.weixin.qq.com/ilink/bot/get_qrcode_status")
    channel._api_get_with_base = AsyncMock(
        side_effect=[
            httpx.ConnectError("temporary network", request=request),
            {
                "status": "confirmed",
                "bot_token": "token-net-ok",
                "ilink_bot_id": "bot-id",
                "baseurl": "https://example.test",
                "ilink_user_id": "wx-user",
            },
        ]
    )

    ok = await channel._qr_login()

    assert ok is True
    assert channel._token == "token-net-ok"


@pytest.mark.asyncio
async def test_qr_login_treats_5xx_gateway_response_error_as_wait_and_recovers(
    no_qr_poll_delay,
) -> None:
    channel, _bus = _make_channel()
    channel._running = True
    channel._save_state = lambda **_kwargs: None
    channel._print_qr_code = lambda url: None
    channel._fetch_qr_code = AsyncMock(return_value=("qr-1", "url-1"))

    request = httpx.Request("GET", "https://ilinkai.weixin.qq.com/ilink/bot/get_qrcode_status")
    response = httpx.Response(status_code=524, request=request)
    channel._api_get_with_base = AsyncMock(
        side_effect=[
            httpx.HTTPStatusError("gateway timeout", request=request, response=response),
            {
                "status": "confirmed",
                "bot_token": "token-5xx-ok",
                "ilink_bot_id": "bot-id",
                "baseurl": "https://example.test",
                "ilink_user_id": "wx-user",
            },
        ]
    )

    ok = await channel._qr_login()

    assert ok is True
    assert channel._token == "token-5xx-ok"


def test_decrypt_aes_ecb_strips_valid_pkcs7_padding() -> None:
    key_b64 = "MDEyMzQ1Njc4OWFiY2RlZg=="  # base64("0123456789abcdef")
    plaintext = b"hello-weixin-padding"

    ciphertext = _encrypt_aes_ecb(plaintext, key_b64)
    decrypted = _decrypt_aes_ecb(ciphertext, key_b64)

    assert decrypted == plaintext


def test_missing_aes_dependency_recommends_weixin_plugin(monkeypatch) -> None:
    real_import = __import__

    def fake_import(name, *args, **kwargs):
        if name.startswith(("Crypto", "cryptography")):
            raise ImportError("missing AES dependency")
        return real_import(name, *args, **kwargs)

    warnings: list[str] = []
    monkeypatch.setattr("builtins.__import__", fake_import)
    monkeypatch.setattr(
        weixin_mod.logger,
        "warning",
        lambda message, *args: warnings.append(message.format(*args)),
    )
    key_b64 = "MDEyMzQ1Njc4OWFiY2RlZg=="
    data = b"unencrypted media"

    assert _encrypt_aes_ecb(data, key_b64) == data
    assert _decrypt_aes_ecb(data, key_b64) == data
    assert warnings == [
        "Cannot encrypt media. Run `nanobot plugins enable weixin` to install WeChat support.",
        "Cannot decrypt media. Run `nanobot plugins enable weixin` to install WeChat support.",
    ]


class _DummyDownloadResponse:
    def __init__(self, content: bytes, status_code: int = 200) -> None:
        self.content = content
        self.status_code = status_code

    def raise_for_status(self) -> None:
        return None


class _DummyErrorDownloadResponse(_DummyDownloadResponse):
    def __init__(self, url: str, status_code: int) -> None:
        super().__init__(content=b"", status_code=status_code)
        self._url = url

    def raise_for_status(self) -> None:
        request = httpx.Request("GET", self._url)
        response = httpx.Response(self.status_code, request=request)
        raise httpx.HTTPStatusError(
            f"download failed with status {self.status_code}",
            request=request,
            response=response,
        )


@pytest.mark.asyncio
async def test_download_media_item_uses_full_url_when_present(tmp_path) -> None:
    channel, _bus = _make_channel()
    weixin_mod.get_media_dir = lambda _name: tmp_path

    full_url = "https://cdn.example.test/download/full"
    channel._client = SimpleNamespace(
        get=AsyncMock(return_value=_DummyDownloadResponse(content=b"raw-image-bytes"))
    )

    item = {
        "media": {
            "full_url": full_url,
            "encrypt_query_param": "enc-fallback-should-not-be-used",
        },
    }
    saved_path = await channel._download_media_item(item, "image")

    assert saved_path is not None
    assert Path(saved_path).read_bytes() == b"raw-image-bytes"
    channel._client.get.assert_awaited_once_with(full_url)


@pytest.mark.asyncio
async def test_download_media_item_falls_back_when_full_url_returns_retryable_error(tmp_path) -> None:
    channel, _bus = _make_channel()
    weixin_mod.get_media_dir = lambda _name: tmp_path

    full_url = "https://cdn.example.test/download/full?taskid=123"
    channel._client = SimpleNamespace(
        get=AsyncMock(
            side_effect=[
                _DummyErrorDownloadResponse(full_url, 500),
                _DummyDownloadResponse(content=b"fallback-bytes"),
            ]
        )
    )

    item = {
        "media": {
            "full_url": full_url,
            "encrypt_query_param": "enc-fallback",
        },
    }
    saved_path = await channel._download_media_item(item, "image")

    assert saved_path is not None
    assert Path(saved_path).read_bytes() == b"fallback-bytes"
    assert channel._client.get.await_count == 2
    assert channel._client.get.await_args_list[0].args[0] == full_url
    fallback_url = channel._client.get.await_args_list[1].args[0]
    assert fallback_url.startswith(f"{channel.config.cdn_base_url}/download?encrypted_query_param=enc-fallback")


@pytest.mark.asyncio
async def test_download_media_item_falls_back_to_encrypt_query_param(tmp_path) -> None:
    channel, _bus = _make_channel()
    weixin_mod.get_media_dir = lambda _name: tmp_path

    channel._client = SimpleNamespace(
        get=AsyncMock(return_value=_DummyDownloadResponse(content=b"fallback-bytes"))
    )

    item = {"media": {"encrypt_query_param": "enc-fallback"}}
    saved_path = await channel._download_media_item(item, "image")

    assert saved_path is not None
    assert Path(saved_path).read_bytes() == b"fallback-bytes"
    called_url = channel._client.get.await_args_list[0].args[0]
    assert called_url.startswith(f"{channel.config.cdn_base_url}/download?encrypted_query_param=enc-fallback")


@pytest.mark.asyncio
async def test_download_media_item_does_not_retry_when_full_url_fails_without_fallback(tmp_path) -> None:
    channel, _bus = _make_channel()
    weixin_mod.get_media_dir = lambda _name: tmp_path

    full_url = "https://cdn.example.test/download/full"
    channel._client = SimpleNamespace(
        get=AsyncMock(return_value=_DummyErrorDownloadResponse(full_url, 500))
    )

    item = {"media": {"full_url": full_url}}
    saved_path = await channel._download_media_item(item, "image")

    assert saved_path is None
    channel._client.get.assert_awaited_once_with(full_url)


@pytest.mark.asyncio
async def test_download_media_item_non_image_requires_aes_key_even_with_full_url(tmp_path) -> None:
    channel, _bus = _make_channel()
    weixin_mod.get_media_dir = lambda _name: tmp_path

    full_url = "https://cdn.example.test/download/voice"
    channel._client = SimpleNamespace(
        get=AsyncMock(return_value=_DummyDownloadResponse(content=b"ciphertext-or-unknown"))
    )

    item = {
        "media": {
            "full_url": full_url,
        },
    }
    saved_path = await channel._download_media_item(item, "voice")

    assert saved_path is None
    channel._client.get.assert_not_awaited()


# ---------------------------------------------------------------------------
# Tests for media-send error classification (network vs non-network errors)
# ---------------------------------------------------------------------------


def _make_outbound_msg(chat_id: str = "wx-user", content: str = "", media: list | None = None):
    """Build a minimal OutboundMessage-like object for send() tests."""
    from nanobot.bus.events import OutboundMessage

    return OutboundMessage(
        channel="weixin",
        chat_id=chat_id,
        content=content,
        media=media or [],
        metadata={},
    )


@pytest.mark.asyncio
async def test_send_media_timeout_error_propagates_without_text_fallback() -> None:
    """httpx.TimeoutException during media send must re-raise immediately,
    NOT fall back to _send_text (which would also fail during network issues)."""
    channel, _bus = _make_channel()
    channel._client = object()
    channel._token = "token"
    channel._context_tokens["wx-user"] = "ctx-1"
    channel._send_media_file = AsyncMock(side_effect=httpx.TimeoutException("timed out"))
    channel._send_text = AsyncMock()

    msg = _make_outbound_msg(chat_id="wx-user", media=["/tmp/photo.jpg"])

    with pytest.raises(httpx.TimeoutException, match="timed out"):
        await channel.send(msg)

    # _send_text must NOT have been called as a fallback
    channel._send_text.assert_not_awaited()


@pytest.mark.asyncio
async def test_send_media_transport_error_propagates_without_text_fallback() -> None:
    """httpx.TransportError during media send must re-raise immediately."""
    channel, _bus = _make_channel()
    channel._client = object()
    channel._token = "token"
    channel._context_tokens["wx-user"] = "ctx-1"
    channel._send_media_file = AsyncMock(
        side_effect=httpx.TransportError("connection reset")
    )
    channel._send_text = AsyncMock()

    msg = _make_outbound_msg(chat_id="wx-user", media=["/tmp/photo.jpg"])

    with pytest.raises(httpx.TransportError, match="connection reset"):
        await channel.send(msg)

    channel._send_text.assert_not_awaited()


@pytest.mark.asyncio
async def test_send_media_5xx_http_status_error_propagates_without_text_fallback() -> None:
    """httpx.HTTPStatusError with a 5xx status must re-raise immediately."""
    channel, _bus = _make_channel()
    channel._client = object()
    channel._token = "token"
    channel._context_tokens["wx-user"] = "ctx-1"

    fake_response = httpx.Response(
        status_code=503,
        request=httpx.Request("POST", "https://example.test/upload"),
    )
    channel._send_media_file = AsyncMock(
        side_effect=httpx.HTTPStatusError(
            "Service Unavailable", request=fake_response.request, response=fake_response
        )
    )
    channel._send_text = AsyncMock()

    msg = _make_outbound_msg(chat_id="wx-user", media=["/tmp/photo.jpg"])

    with pytest.raises(httpx.HTTPStatusError, match="Service Unavailable"):
        await channel.send(msg)

    channel._send_text.assert_not_awaited()


@pytest.mark.asyncio
async def test_send_media_4xx_http_status_error_falls_back_to_text() -> None:
    """httpx.HTTPStatusError with a 4xx status should fall back to text, not re-raise."""
    channel, _bus = _make_channel()
    channel._client = object()
    channel._token = "token"
    channel._context_tokens["wx-user"] = "ctx-1"

    fake_response = httpx.Response(
        status_code=400,
        request=httpx.Request("POST", "https://example.test/upload"),
    )
    channel._send_media_file = AsyncMock(
        side_effect=httpx.HTTPStatusError(
            "Bad Request", request=fake_response.request, response=fake_response
        )
    )
    channel._send_text = AsyncMock()

    msg = _make_outbound_msg(chat_id="wx-user", media=["/tmp/photo.jpg"])

    # Should NOT raise — 4xx is a client error, non-retryable
    await channel.send(msg)

    # _send_text should have been called with the fallback message
    channel._send_text.assert_awaited_once_with(
        "wx-user", "[Failed to send: photo.jpg]", "ctx-1"
    )


@pytest.mark.asyncio
async def test_send_media_file_not_found_falls_back_to_text() -> None:
    """FileNotFoundError (a non-network error) should fall back to text."""
    channel, _bus = _make_channel()
    channel._client = object()
    channel._token = "token"
    channel._context_tokens["wx-user"] = "ctx-1"
    channel._send_media_file = AsyncMock(
        side_effect=FileNotFoundError("Media file not found: /tmp/missing.jpg")
    )
    channel._send_text = AsyncMock()

    msg = _make_outbound_msg(chat_id="wx-user", media=["/tmp/missing.jpg"])

    # Should NOT raise
    await channel.send(msg)

    channel._send_text.assert_awaited_once_with(
        "wx-user", "[Failed to send: missing.jpg]", "ctx-1"
    )


@pytest.mark.asyncio
async def test_send_media_value_error_falls_back_to_text() -> None:
    """ValueError (e.g. unsupported format) should fall back to text."""
    channel, _bus = _make_channel()
    channel._client = object()
    channel._token = "token"
    channel._context_tokens["wx-user"] = "ctx-1"
    channel._send_media_file = AsyncMock(
        side_effect=ValueError("Unsupported media format")
    )
    channel._send_text = AsyncMock()

    msg = _make_outbound_msg(chat_id="wx-user", media=["/tmp/file.xyz"])

    # Should NOT raise
    await channel.send(msg)

    channel._send_text.assert_awaited_once_with(
        "wx-user", "[Failed to send: file.xyz]", "ctx-1"
    )


@pytest.mark.asyncio
async def test_send_media_network_error_does_not_double_api_calls() -> None:
    """During network issues, media send should make exactly 1 API call attempt,
    not 2 (media + text fallback).  Verify total call count."""
    channel, _bus = _make_channel()
    channel._client = object()
    channel._token = "token"
    channel._context_tokens["wx-user"] = "ctx-1"
    channel._send_media_file = AsyncMock(
        side_effect=httpx.ConnectError("connection refused")
    )
    channel._send_text = AsyncMock()

    msg = _make_outbound_msg(chat_id="wx-user", content="hello", media=["/tmp/img.png"])

    with pytest.raises(httpx.ConnectError):
        await channel.send(msg)

    # _send_media_file called once, _send_text never called
    channel._send_media_file.assert_awaited_once()
    channel._send_text.assert_not_awaited()


# ---------------------------------------------------------------------------
# Tests for _send_text raising on API errors (previously silently swallowed)
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_send_text_raises_on_api_error() -> None:
    """_send_text must raise RuntimeError when the API returns a non-zero errcode,
    matching _send_media_file behavior. This ensures ChannelManager can retry."""
    channel, _bus = _make_channel()
    channel._client = object()
    channel._token = "token"
    channel._api_post = AsyncMock(
        return_value={"errcode": -14, "errmsg": "session expired"}
    )

    with pytest.raises(WeixinAuthError, match="WeChat sendmessage failed.*errcode=-14"):
        await channel._send_text("wx-user", "hello", "ctx-expired")

    channel._api_post.assert_awaited_once()


@pytest.mark.asyncio
async def test_send_text_succeeds_on_zero_errcode() -> None:
    """_send_text must NOT raise when errcode is 0."""
    channel, _bus = _make_channel()
    channel._client = object()
    channel._token = "token"
    channel._api_post = AsyncMock(return_value={"errcode": 0})

    await channel._send_text("wx-user", "hello", "ctx-ok")

    channel._api_post.assert_awaited_once()


@pytest.mark.asyncio
async def test_send_text_raises_on_nonzero_ret_even_when_errcode_zero() -> None:
    """_send_text must raise when the API returns ret != 0, even if errcode is 0.

    The iLink API signals failure through either field.  Checking only errcode
    caused silent message drops (responses generated but never delivered).
    """
    channel, _bus = _make_channel()
    channel._client = object()
    channel._token = "token"
    channel._api_post = AsyncMock(
        return_value={"ret": -100, "errcode": 0, "errmsg": "internal error"}
    )

    with pytest.raises(RuntimeError, match="WeChat sendmessage failed.*ret=-100.*errcode=0"):
        await channel._send_text("wx-user", "hello", "ctx-ok")

    channel._api_post.assert_awaited_once()


# ---------------------------------------------------------------------------
# Tests for _poll_once not silently dropping messages on processing errors
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_poll_once_logs_exception_on_process_message_failure(monkeypatch) -> None:
    """When _process_message raises, _poll_once must log the error and continue
    processing remaining messages instead of silently swallowing the exception."""
    channel, _bus = _make_channel()
    channel._client = SimpleNamespace(timeout=None)
    channel._token = "token"
    channel._get_updates_buf = "old-buf"

    calls = []
    logged_messages: list[str] = []

    async def _failing_process(msg: dict) -> None:
        calls.append(msg.get("message_id"))
        if msg.get("message_id") == "msg-1":
            raise RuntimeError("processing failed")

    channel._process_message = _failing_process  # type: ignore[method-assign]

    monkeypatch.setattr(
        channel.logger,
        "exception",
        lambda message, *args, **kwargs: logged_messages.append(str(message)),
    )

    channel._api_post = AsyncMock(  # type: ignore[method-assign]
        return_value={
            "ret": 0,
            "errcode": 0,
            "get_updates_buf": "new-buf",
            "msgs": [
                {"message_id": "msg-1", "message_type": 1},
                {"message_id": "msg-2", "message_type": 1},
            ],
        }
    )

    await channel._poll_once()

    # Both messages should have been attempted
    assert calls == ["msg-1", "msg-2"]
    # Buffer should still advance (already updated before processing)
    assert channel._get_updates_buf == "new-buf"
    # Error should be logged
    assert any("Failed to process WeChat message" in m for m in logged_messages)


@pytest.mark.asyncio
async def test_poll_loop_logs_exception_and_continues_on_poll_failure(monkeypatch) -> None:
    """When _poll_once raises a non-timeout exception, the start() loop must log
    the error and continue polling instead of exiting silently."""
    channel, _bus = _make_channel()
    channel._client = object()
    channel._token = "token"
    channel.config.token = "token"  # skip QR login in start()
    channel._running = True

    call_count = 0
    logged_messages: list[str] = []

    async def _failing_poll() -> None:
        nonlocal call_count
        call_count += 1
        if call_count == 1:
            raise RuntimeError("poll exploded")
        channel._running = False  # Stop after second call

    channel._poll_once = _failing_poll  # type: ignore[method-assign]

    monkeypatch.setattr(
        channel.logger,
        "exception",
        lambda message, *args, **kwargs: logged_messages.append(str(message)),
    )

    # Use a tiny retry delay so the test finishes quickly
    original_retry = weixin_mod.RETRY_DELAY_S
    weixin_mod.RETRY_DELAY_S = 0.01
    try:
        await channel.start()
    finally:
        weixin_mod.RETRY_DELAY_S = original_retry

    assert call_count == 2
    assert any("WeChat poll loop error" in m for m in logged_messages)


# ---------------------------------------------------------------------------
# Tool-hint buffering
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_buffer_single_tool_hint_not_sent_immediately() -> None:
    channel, _bus = _make_channel()
    channel._client = object()
    channel._token = "token"
    channel.send_tool_hints = True
    channel._context_tokens["wx-user"] = "ctx-1"
    channel._context_token_at["wx-user"] = time.time()
    channel._send_text = AsyncMock()

    await channel.send(
        type(
            "Msg",
            (),
            {
                "chat_id": "wx-user",
                "content": "Using tool",
                "media": [],
                "event": ProgressEvent(content="Using tool", tool_hint=True),
                "metadata": {},
            },
        )()
    )

    channel._send_text.assert_not_awaited()
    assert channel._pending_tool_hints["wx-user"] == ["Using tool"]


@pytest.mark.asyncio
async def test_buffer_multiple_tool_hints_flushed_on_final_answer() -> None:
    channel, _bus = _make_channel()
    channel._client = object()
    channel._token = "token"
    channel.send_tool_hints = True
    channel._context_tokens["wx-user"] = "ctx-1"
    channel._context_token_at["wx-user"] = time.time()
    channel._send_text = AsyncMock()

    for hint in ["tool1", "tool2"]:
        await channel.send(
            type(
                "Msg",
                (),
                {
                    "chat_id": "wx-user",
                    "content": hint,
                    "media": [],
                    "event": ProgressEvent(content=hint, tool_hint=True),
                    "metadata": {},
                },
            )()
        )

    await channel.send(
        type(
            "Msg",
            (),
            {
                "chat_id": "wx-user",
                "content": "Done",
                "media": [],
                "metadata": {},
            },
        )()
    )

    assert channel._send_text.await_count == 2
    channel._send_text.assert_any_await("wx-user", "tool1\n\ntool2", "ctx-1")
    channel._send_text.assert_any_await("wx-user", "Done", "ctx-1")
    assert "wx-user" not in channel._pending_tool_hints


@pytest.mark.asyncio
async def test_thought_progress_flushes_tool_hints() -> None:
    """Thoughts are visible progress messages and must act as separators,
    flushing buffered tool hints before they are sent."""
    channel, _bus = _make_channel()
    channel._client = object()
    channel._token = "token"
    channel.send_tool_hints = True
    channel._context_tokens["wx-user"] = "ctx-1"
    channel._context_token_at["wx-user"] = time.time()
    channel._send_text = AsyncMock()

    # Buffer a tool hint
    await channel.send(
        type(
            "Msg",
            (),
            {
                "chat_id": "wx-user",
                "content": "search 'foo'",
                "media": [],
                "event": ProgressEvent(content="search 'foo'", tool_hint=True),
                "metadata": {},
            },
        )()
    )

    # Send a thought — progress but not a tool_hint.
    # It must act as a separator and flush the buffered hint.
    await channel.send(
        type(
            "Msg",
            (),
            {
                "chat_id": "wx-user",
                "content": "Let me think...",
                "media": [],
                "event": ProgressEvent(content="Let me think..."),
                "metadata": {},
            },
        )()
    )

    # The buffered hint was flushed before the thought was sent.
    channel._send_text.assert_any_await("wx-user", "search 'foo'", "ctx-1")
    channel._send_text.assert_any_await("wx-user", "Let me think...", "ctx-1")
    assert "wx-user" not in channel._pending_tool_hints

    # Final answer arrives with nothing left to flush.
    await channel.send(
        type(
            "Msg",
            (),
            {
                "chat_id": "wx-user",
                "content": "Done",
                "media": [],
                "metadata": {},
            },
        )()
    )

    assert channel._send_text.await_count == 3
    channel._send_text.assert_any_await("wx-user", "Done", "ctx-1")


@pytest.mark.asyncio
async def test_reasoning_delta_does_not_flush_tool_hints() -> None:
    """Reasoning deltas are invisible in WeChat and must NOT flush buffered
    tool hints — otherwise hints separated only by hidden reasoning would
    fail to coalesce."""
    channel, _bus = _make_channel()
    channel._client = object()
    channel._token = "token"
    channel.send_tool_hints = True
    channel._context_tokens["wx-user"] = "ctx-1"
    channel._context_token_at["wx-user"] = time.time()
    channel._send_text = AsyncMock()

    # Buffer a tool hint
    await channel.send(
        type(
            "Msg",
            (),
            {
                "chat_id": "wx-user",
                "content": "search 'foo'",
                "media": [],
                "event": ProgressEvent(content="search 'foo'", tool_hint=True),
                "metadata": {},
            },
        )()
    )

    # Send a reasoning delta — invisible in WeChat, must NOT flush
    await channel.send(
        type(
            "Msg",
            (),
            {
                "chat_id": "wx-user",
                "content": "Thinking step 1...",
                "media": [],
                "event": ProgressEvent(content="Thinking step 1...", reasoning_delta=True),
                "metadata": {},
            },
        )()
    )

    # Reasoning is invisible; hint stays buffered, _send_text not called
    channel._send_text.assert_not_awaited()
    assert channel._pending_tool_hints["wx-user"] == ["search 'foo'"]

    # Final answer flushes the buffered hint
    await channel.send(
        type(
            "Msg",
            (),
            {
                "chat_id": "wx-user",
                "content": "Done",
                "media": [],
                "metadata": {},
            },
        )()
    )

    channel._send_text.assert_any_await("wx-user", "search 'foo'", "ctx-1")
    channel._send_text.assert_any_await("wx-user", "Done", "ctx-1")
    assert "wx-user" not in channel._pending_tool_hints


@pytest.mark.asyncio
async def test_empty_progress_message_does_not_flush_tool_hints() -> None:
    """Empty progress messages (e.g. after_iteration tool_events) have no
    visible content and must NOT act as separators."""
    channel, _bus = _make_channel()
    channel._client = object()
    channel._token = "token"
    channel.send_tool_hints = True
    channel._context_tokens["wx-user"] = "ctx-1"
    channel._context_token_at["wx-user"] = time.time()
    channel._send_text = AsyncMock()

    # Buffer a tool hint
    await channel.send(
        type(
            "Msg",
            (),
            {
                "chat_id": "wx-user",
                "content": "search 'foo'",
                "media": [],
                "event": ProgressEvent(content="search 'foo'", tool_hint=True),
                "metadata": {},
            },
        )()
    )

    # Send an empty progress message (no content, no media)
    await channel.send(
        type(
            "Msg",
            (),
            {
                "chat_id": "wx-user",
                "content": "",
                "media": [],
                "event": ProgressEvent(tool_events=[{"phase": "end"}]),
                "metadata": {},
            },
        )()
    )

    # Nothing should have been sent yet
    channel._send_text.assert_not_awaited()
    assert channel._pending_tool_hints["wx-user"] == ["search 'foo'"]

    # Final answer flushes the buffered hint
    await channel.send(
        type(
            "Msg",
            (),
            {
                "chat_id": "wx-user",
                "content": "Done",
                "media": [],
                "metadata": {},
            },
        )()
    )

    channel._send_text.assert_any_await("wx-user", "search 'foo'", "ctx-1")
    channel._send_text.assert_any_await("wx-user", "Done", "ctx-1")
    assert "wx-user" not in channel._pending_tool_hints


@pytest.mark.asyncio
async def test_buffer_flush_refreshes_context_token() -> None:
    channel, _bus = _make_channel()
    channel._client = object()
    channel._token = "token"
    channel.send_tool_hints = True
    channel._context_tokens["wx-user"] = "ctx-old"
    channel._context_token_at["wx-user"] = time.time()
    channel._refresh_context_token_if_stale = AsyncMock(return_value="ctx-refreshed")
    channel._send_text = AsyncMock()

    await channel.send(
        type(
            "Msg",
            (),
            {
                "chat_id": "wx-user",
                "content": "hint",
                "media": [],
                "event": ProgressEvent(content="hint", tool_hint=True),
                "metadata": {},
            },
        )()
    )

    await channel.send(
        type(
            "Msg",
            (),
            {
                "chat_id": "wx-user",
                "content": "Done",
                "media": [],
                "metadata": {},
            },
        )()
    )

    assert channel._refresh_context_token_if_stale.await_count == 2
    channel._refresh_context_token_if_stale.assert_any_await("wx-user", "ctx-old")
    channel._send_text.assert_any_await("wx-user", "hint", "ctx-refreshed")


@pytest.mark.asyncio
async def test_buffer_flush_failure_does_not_block_final_answer() -> None:
    channel, _bus = _make_channel()
    channel._client = object()
    channel._token = "token"
    channel.send_tool_hints = True
    channel._context_tokens["wx-user"] = "ctx-1"
    channel._context_token_at["wx-user"] = time.time()
    channel._send_text = AsyncMock(side_effect=[RuntimeError("boom"), None])

    await channel.send(
        type(
            "Msg",
            (),
            {
                "chat_id": "wx-user",
                "content": "hint",
                "media": [],
                "event": ProgressEvent(content="hint", tool_hint=True),
                "metadata": {},
            },
        )()
    )

    await channel.send(
        type(
            "Msg",
            (),
            {
                "chat_id": "wx-user",
                "content": "Done",
                "media": [],
                "metadata": {},
            },
        )()
    )

    assert channel._send_text.await_count == 2
    channel._send_text.assert_any_await("wx-user", "hint", "ctx-1")
    channel._send_text.assert_any_await("wx-user", "Done", "ctx-1")


@pytest.mark.asyncio
async def test_buffer_flushed_on_stream_end() -> None:
    channel, _bus = _make_channel()
    channel._client = object()
    channel._token = "token"
    channel.send_tool_hints = True
    channel._context_tokens["wx-user"] = "ctx-1"
    channel._context_token_at["wx-user"] = time.time()
    channel._send_text = AsyncMock()

    await channel.send(
        type(
            "Msg",
            (),
            {
                "chat_id": "wx-user",
                "content": "hint",
                "media": [],
                "event": ProgressEvent(content="hint", tool_hint=True),
                "metadata": {},
            },
        )()
    )

    await channel.send_delta("wx-user", "", stream_end=True)

    channel._send_text.assert_awaited_once_with("wx-user", "hint", "ctx-1")
    assert "wx-user" not in channel._pending_tool_hints


@pytest.mark.asyncio
async def test_stream_end_flushes_buffered_answer() -> None:
    channel, _bus = _make_channel()
    channel._client = object()
    channel._token = "token"
    channel._context_tokens["wx-user"] = "ctx-1"
    channel._context_token_at["wx-user"] = time.time()
    channel._send_text = AsyncMock()

    await channel.send_delta("wx-user", "hello ", {"_stream_delta": True})
    await channel.send_delta("wx-user", "world", {"_stream_end": True})

    channel._send_text.assert_awaited_once_with("wx-user", "hello world", "ctx-1")
    assert "wx-user" not in channel._stream_buffers


@pytest.mark.asyncio
async def test_stream_end_merge_next_preserves_buffer_until_final_end() -> None:
    channel, _bus = _make_channel()
    channel._client = object()
    channel._token = "token"
    channel._context_tokens["wx-user"] = "ctx-1"
    channel._context_token_at["wx-user"] = time.time()
    channel._send_text = AsyncMock()

    await channel.send_delta(
        "wx-user",
        "first-",
        stream_id="s1",
        stream_end=True,
        merge_next=True,
    )
    await channel.send_delta("wx-user", "second", stream_id="s1")
    await channel.send_delta("wx-user", "", stream_id="s1", stream_end=True)

    channel._send_text.assert_awaited_once_with("wx-user", "first-second", "ctx-1")
    assert "s1" not in channel._stream_buffers


@pytest.mark.asyncio
async def test_stream_end_send_failure_keeps_buffer_for_retry() -> None:
    channel, _bus = _make_channel()
    channel._client = object()
    channel._token = "token"
    channel._context_tokens["wx-user"] = "ctx-1"
    channel._context_token_at["wx-user"] = time.time()
    channel._send_text = AsyncMock(side_effect=RuntimeError("temporary send failure"))

    await channel.send_delta("wx-user", "hello ", {"_stream_delta": True})
    with pytest.raises(RuntimeError):
        await channel.send_delta("wx-user", "world", {"_stream_end": True})

    assert channel._stream_buffers["wx-user"] == ["hello "]

    channel._send_text = AsyncMock()
    await channel.send_delta("wx-user", "world", {"_stream_end": True})

    channel._send_text.assert_awaited_once_with("wx-user", "hello world", "ctx-1")
    assert "wx-user" not in channel._stream_buffers


@pytest.mark.asyncio
async def test_stop_clears_buffer() -> None:
    channel, _bus = _make_channel()
    channel._pending_tool_hints["wx-user"] = ["hint1", "hint2"]
    await channel.stop()
    assert "wx-user" not in channel._pending_tool_hints


@pytest.mark.asyncio
async def test_send_tool_hints_false_drops_tool_hints() -> None:
    channel, _bus = _make_channel()
    channel._client = object()
    channel._token = "token"
    channel.send_tool_hints = False
    channel._send_text = AsyncMock()

    await channel.send(
        type(
            "Msg",
            (),
            {
                "chat_id": "wx-user",
                "content": "hint",
                "media": [],
                "event": ProgressEvent(content="hint", tool_hint=True),
                "metadata": {},
            },
        )()
    )

    channel._send_text.assert_not_awaited()
    assert "wx-user" not in channel._pending_tool_hints
