import asyncio
import json
import zipfile
from io import BytesIO
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock

import httpx
import pytest

# Check optional dingtalk dependencies before running tests
try:
    import nanobot.channels.dingtalk.runtime as dingtalk_module

    DINGTALK_AVAILABLE = dingtalk_module.DINGTALK_AVAILABLE
except ImportError:
    DINGTALK_AVAILABLE = False

if not DINGTALK_AVAILABLE:
    pytest.skip("DingTalk dependencies not installed (dingtalk-stream)", allow_module_level=True)

from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.dingtalk.runtime import (
    DingTalkChannel,
    DingTalkConfig,
    NanobotDingTalkHandler,
)


class _FakeResponse:
    def __init__(
        self,
        status_code: int = 200,
        json_body: dict | None = None,
        *,
        content: bytes = b"",
        headers: dict[str, str] | None = None,
        url: str = "https://example.com/file",
    ) -> None:
        self.status_code = status_code
        self._json_body = json_body or {}
        self.text = content.decode("utf-8", errors="replace") if content else "{}"
        self.content = content
        self.headers = headers or {"content-type": "application/json"}
        self.url = httpx.URL(url)

    def json(self) -> dict:
        return self._json_body


class _FakeHttp:
    def __init__(self, responses: list[_FakeResponse] | None = None) -> None:
        self.calls: list[dict] = []
        self._responses = list(responses) if responses else []

    def _next_response(self) -> _FakeResponse:
        if self._responses:
            return self._responses.pop(0)
        return _FakeResponse()

    async def post(self, url: str, json=None, headers=None, **kwargs):
        self.calls.append(
            {"method": "POST", "url": url, "json": json, "headers": headers, "kwargs": kwargs}
        )
        return self._next_response()

    async def get(self, url: str, **kwargs):
        self.calls.append({"method": "GET", "url": url, "kwargs": kwargs})
        return self._next_response()


class _NetworkErrorHttp:
    """HTTP client stub that raises httpx.TransportError on every request."""

    def __init__(self) -> None:
        self.calls: list[dict] = []

    async def post(self, url: str, json=None, headers=None, **kwargs):
        self.calls.append({"method": "POST", "url": url, "json": json, "headers": headers})
        raise httpx.ConnectError("Connection refused")

    async def get(self, url: str, **kwargs):
        self.calls.append({"method": "GET", "url": url})
        raise httpx.ConnectError("Connection refused")


@pytest.mark.asyncio
async def test_group_message_keeps_sender_id_and_routes_chat_id() -> None:
    config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["user1"])
    bus = MessageBus()
    channel = DingTalkChannel(config, bus)

    await channel._on_message(
        "hello",
        sender_id="user1",
        sender_name="Alice",
        conversation_type="2",
        conversation_id="conv123",
    )

    msg = await bus.consume_inbound()
    assert msg.sender_id == "user1"
    assert msg.chat_id == "group:conv123"
    assert msg.metadata["conversation_type"] == "2"


@pytest.mark.asyncio
async def test_group_user_isolation_false_uses_shared_session() -> None:
    """By default group messages share the same session_key."""
    config = DingTalkConfig(
        client_id="app", client_secret="secret", allow_from=["*"], group_user_isolation=False
    )
    bus = MessageBus()
    channel = DingTalkChannel(config, bus)

    for user_id in ("user1", "user2"):
        await channel._on_message(
            "hello",
            sender_id=user_id,
            sender_name=user_id,
            conversation_type="2",
            conversation_id="conv123",
        )

    msg1 = await bus.consume_inbound()
    msg2 = await bus.consume_inbound()
    assert msg1.session_key == msg2.session_key == "dingtalk:group:conv123"
    assert msg1.chat_id == msg2.chat_id == "group:conv123"


@pytest.mark.asyncio
async def test_group_user_isolation_true_separates_sessions() -> None:
    """When group_user_isolation is True, each user gets their own session_key."""
    config = DingTalkConfig(
        client_id="app", client_secret="secret", allow_from=["*"], group_user_isolation=True
    )
    bus = MessageBus()
    channel = DingTalkChannel(config, bus)

    for user_id in ("user1", "user2"):
        await channel._on_message(
            "hello",
            sender_id=user_id,
            sender_name=user_id,
            conversation_type="2",
            conversation_id="conv123",
        )

    msg1 = await bus.consume_inbound()
    msg2 = await bus.consume_inbound()
    assert msg1.session_key == "dingtalk:group:conv123:user1"
    assert msg2.session_key == "dingtalk:group:conv123:user2"
    assert msg1.chat_id == msg2.chat_id == "group:conv123"


def test_disable_private_chat_uses_camel_case_config_key() -> None:
    config = DingTalkConfig.model_validate({"disablePrivateChat": True})

    assert config.disable_private_chat is True
    assert config.model_dump(mode="json", by_alias=True)["disablePrivateChat"] is True


@pytest.mark.asyncio
async def test_dm_rejected_when_private_chat_disabled(monkeypatch) -> None:
    """With disable_private_chat=True, a 1:1 DM is rejected: nothing reaches the
    bus (no session is created) and the bot replies with a notice directing the
    user to group chat. Even allowlisted senders are blocked in DMs."""
    config = DingTalkConfig(
        client_id="app",
        client_secret="secret",
        allow_from=["*"],  # even allowlisted senders are blocked in DMs
        disable_private_chat=True,
    )
    bus = MessageBus()
    channel = DingTalkChannel(config, bus)

    async def fake_get_token():
        return "test-token"

    monkeypatch.setattr(channel, "_get_access_token", fake_get_token)
    channel._http = _FakeHttp()

    await channel._on_message(
        "hello",
        sender_id="user1",
        sender_name="Alice",
        conversation_type="1",
    )

    # No inbound message was published -> no session created
    assert bus.inbound.empty()

    # A notice was sent back to the DM user via the private-chat API
    assert len(channel._http.calls) == 1
    call = channel._http.calls[0]
    assert call["url"] == "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend"
    assert call["json"]["msgKey"] == "sampleMarkdown"
    assert call["json"]["userIds"] == ["user1"]
    assert "该机器人未开启私聊，请在群聊中与我对话。" in call["json"]["msgParam"]


@pytest.mark.asyncio
async def test_dm_allowed_when_private_chat_not_disabled() -> None:
    """By default (disable_private_chat=False), a 1:1 DM still reaches the bus."""
    config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"])
    bus = MessageBus()
    channel = DingTalkChannel(config, bus)

    await channel._on_message(
        "hello",
        sender_id="user1",
        sender_name="Alice",
        conversation_type="1",
    )

    msg = await bus.consume_inbound()
    assert msg.chat_id == "user1"
    assert msg.metadata["conversation_type"] == "1"


@pytest.mark.asyncio
async def test_group_message_allowed_when_private_chat_disabled() -> None:
    """Disabling private chat must not affect group messages."""
    config = DingTalkConfig(
        client_id="app", client_secret="secret", allow_from=["*"], disable_private_chat=True
    )
    bus = MessageBus()
    channel = DingTalkChannel(config, bus)

    await channel._on_message(
        "hello",
        sender_id="user1",
        sender_name="Alice",
        conversation_type="2",
        conversation_id="conv123",
    )

    msg = await bus.consume_inbound()
    assert msg.chat_id == "group:conv123"


@pytest.mark.asyncio
async def test_group_send_uses_group_messages_api() -> None:
    config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"])
    channel = DingTalkChannel(config, MessageBus())
    channel._http = _FakeHttp()

    ok = await channel._send_batch_message(
        "token",
        "group:conv123",
        "sampleMarkdown",
        {"text": "hello", "title": "Nanobot Reply"},
    )

    assert ok is True
    call = channel._http.calls[0]
    assert call["url"] == "https://api.dingtalk.com/v1.0/robot/groupMessages/send"
    assert call["json"]["openConversationId"] == "conv123"
    assert call["json"]["msgKey"] == "sampleMarkdown"


@pytest.mark.asyncio
async def test_group_send_prepends_sender_mention(monkeypatch) -> None:
    """Group replies are prefixed with a markdown header naming the sender."""
    config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"])
    channel = DingTalkChannel(config, MessageBus())
    channel._http = _FakeHttp()

    async def _fake_token() -> str:
        return "token"

    monkeypatch.setattr(channel, "_get_access_token", _fake_token)

    await channel.send(
        OutboundMessage(
            channel="dingtalk",
            chat_id="group:conv123",
            content="hello",
            metadata={"sender_name": "Alice"},
        )
    )

    sent_text = json.loads(channel._http.calls[0]["json"]["msgParam"])["text"]
    assert sent_text == "# @Alice\n\nhello"


@pytest.mark.asyncio
async def test_group_send_escapes_untrusted_sender_name(monkeypatch) -> None:
    """A sender nickname cannot inject extra Markdown blocks into the reply."""
    config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"])
    channel = DingTalkChannel(config, MessageBus())
    channel._http = _FakeHttp()

    async def _fake_token() -> str:
        return "token"

    monkeypatch.setattr(channel, "_get_access_token", _fake_token)

    await channel.send(
        OutboundMessage(
            channel="dingtalk",
            chat_id="group:conv123",
            content="hello",
            metadata={"sender_name": "Alice\n# [click](https://evil) *admin*"},
        )
    )

    sent_text = json.loads(channel._http.calls[0]["json"]["msgParam"])["text"]
    assert sent_text == r"# @Alice \# \[click\]\(https://evil\) \*admin\*" + "\n\nhello"


@pytest.mark.asyncio
async def test_private_send_does_not_prepend_mention(monkeypatch) -> None:
    """Private replies are sent verbatim, without the sender header."""
    config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"])
    channel = DingTalkChannel(config, MessageBus())
    channel._http = _FakeHttp()

    async def _fake_token() -> str:
        return "token"

    monkeypatch.setattr(channel, "_get_access_token", _fake_token)

    await channel.send(
        OutboundMessage(
            channel="dingtalk",
            chat_id="user1",  # private chat: no "group:" prefix
            content="hello",
            metadata={"sender_name": "Alice"},
        )
    )

    sent_text = json.loads(channel._http.calls[0]["json"]["msgParam"])["text"]
    assert sent_text == "hello"


@pytest.mark.asyncio
async def test_message_without_sender_id_is_dropped() -> None:
    """Malformed inbound events must not publish or attempt an invalid reply."""
    config = DingTalkConfig(
        client_id="app",
        client_secret="secret",
        allow_from=["*"],
        disable_private_chat=True,
    )
    bus = MessageBus()
    channel = DingTalkChannel(config, bus)
    channel._http = _FakeHttp()

    await channel._on_message(
        "hello",
        sender_id=None,
        sender_name="Unknown",
        conversation_type="1",
    )

    assert bus.inbound.empty()
    assert channel._http.calls == []


@pytest.mark.asyncio
async def test_handler_uses_voice_recognition_text_when_text_is_empty(monkeypatch) -> None:
    bus = MessageBus()
    channel = DingTalkChannel(
        DingTalkConfig(client_id="app", client_secret="secret", allow_from=["user1"]),
        bus,
    )
    handler = NanobotDingTalkHandler(channel)

    class _FakeChatbotMessage:
        text = None
        extensions = {"content": {"recognition": "voice transcript"}}
        sender_staff_id = "user1"
        sender_id = "fallback-user"
        sender_nick = "Alice"
        message_type = "audio"

        @staticmethod
        def from_dict(_data):
            return _FakeChatbotMessage()

    monkeypatch.setattr(dingtalk_module, "ChatbotMessage", _FakeChatbotMessage)
    monkeypatch.setattr(dingtalk_module, "AckMessage", SimpleNamespace(STATUS_OK="OK"))

    status, body = await handler.process(
        SimpleNamespace(
            data={
                "conversationType": "2",
                "conversationId": "conv123",
                "text": {"content": ""},
            }
        )
    )

    await asyncio.gather(*list(channel._background_tasks))
    msg = await bus.consume_inbound()

    assert (status, body) == ("OK", "OK")
    assert msg.content == "voice transcript"
    assert msg.sender_id == "user1"
    assert msg.chat_id == "group:conv123"


@pytest.mark.asyncio
async def test_handler_retrieves_background_message_failure(monkeypatch) -> None:
    bus = MessageBus()
    channel = DingTalkChannel(
        DingTalkConfig(client_id="app", client_secret="secret", allow_from=["user1"]),
        bus,
    )
    handler = NanobotDingTalkHandler(channel)
    failure = RuntimeError("inbound dispatch failed")
    mock_logger = MagicMock()
    channel.logger = mock_logger

    class _FakeChatbotMessage:
        text = SimpleNamespace(content="hello")
        extensions = {}
        sender_staff_id = "user1"
        sender_id = "fallback-user"
        sender_nick = "Alice"
        message_type = "text"

        @staticmethod
        def from_dict(_data):
            return _FakeChatbotMessage()

    async def fail(*_args) -> None:
        raise failure

    monkeypatch.setattr(dingtalk_module, "ChatbotMessage", _FakeChatbotMessage)
    monkeypatch.setattr(dingtalk_module, "AckMessage", SimpleNamespace(STATUS_OK="OK"))
    monkeypatch.setattr(channel, "_on_message", fail)
    event_loop = asyncio.get_running_loop()
    previous_handler = event_loop.get_exception_handler()
    loop_errors: list[dict[str, object]] = []
    event_loop.set_exception_handler(lambda _loop, context: loop_errors.append(context))

    try:
        status, body = await handler.process(
            SimpleNamespace(data={"conversationType": "1", "text": {"content": "hello"}})
        )
        for _ in range(10):
            await asyncio.sleep(0)
            if not channel._background_tasks:
                break
    finally:
        event_loop.set_exception_handler(previous_handler)

    assert (status, body) == ("OK", "OK")
    assert not channel._background_tasks
    assert not loop_errors
    mock_logger.opt.assert_called_once_with(exception=failure)
    mock_logger.opt.return_value.error.assert_called_once_with(
        "DingTalk inbound message task failed"
    )


@pytest.mark.asyncio
async def test_handler_processes_file_message(monkeypatch) -> None:
    """Test that file messages are handled and forwarded with downloaded path."""
    bus = MessageBus()
    channel = DingTalkChannel(
        DingTalkConfig(client_id="app", client_secret="secret", allow_from=["user1"]),
        bus,
    )
    handler = NanobotDingTalkHandler(channel)

    class _FakeFileChatbotMessage:
        text = None
        extensions = {}
        image_content = None
        rich_text_content = None
        sender_staff_id = "user1"
        sender_id = "fallback-user"
        sender_nick = "Alice"
        message_type = "file"

        @staticmethod
        def from_dict(_data):
            return _FakeFileChatbotMessage()

    async def fake_download(download_code, filename, sender_id):
        return f"/tmp/nanobot_dingtalk/{sender_id}/{filename}"

    monkeypatch.setattr(dingtalk_module, "ChatbotMessage", _FakeFileChatbotMessage)
    monkeypatch.setattr(dingtalk_module, "AckMessage", SimpleNamespace(STATUS_OK="OK"))
    monkeypatch.setattr(channel, "_download_dingtalk_file", fake_download)

    status, body = await handler.process(
        SimpleNamespace(
            data={
                "conversationType": "1",
                "content": {"downloadCode": "abc123", "fileName": "report.xlsx"},
                "text": {"content": ""},
            }
        )
    )

    await asyncio.gather(*list(channel._background_tasks))
    msg = await bus.consume_inbound()

    assert (status, body) == ("OK", "OK")
    assert "[File]" in msg.content
    assert "/tmp/nanobot_dingtalk/user1/report.xlsx" in msg.content


@pytest.mark.asyncio
async def test_handler_does_not_spawn_message_task_after_stop_during_download(
    monkeypatch,
) -> None:
    channel = DingTalkChannel(
        DingTalkConfig(client_id="app", client_secret="secret", allow_from=["user1"]),
        MessageBus(),
    )
    handler = NanobotDingTalkHandler(channel)
    download_started = asyncio.Event()
    release_download = asyncio.Event()
    message_task_started = asyncio.Event()

    class _FakeFileChatbotMessage:
        text = None
        extensions = {}
        image_content = None
        rich_text_content = None
        sender_staff_id = "user1"
        sender_id = "fallback-user"
        sender_nick = "Alice"
        message_type = "file"

        @staticmethod
        def from_dict(_data):
            return _FakeFileChatbotMessage()

    async def delayed_download(*_args):
        download_started.set()
        await release_download.wait()
        return "/tmp/nanobot_dingtalk/user1/report.xlsx"

    async def block_message(*_args) -> None:
        message_task_started.set()
        await asyncio.Future()

    monkeypatch.setattr(dingtalk_module, "ChatbotMessage", _FakeFileChatbotMessage)
    monkeypatch.setattr(dingtalk_module, "AckMessage", SimpleNamespace(STATUS_OK="OK"))
    monkeypatch.setattr(channel, "_download_dingtalk_file", delayed_download)
    monkeypatch.setattr(channel, "_on_message", block_message)

    process_task = asyncio.create_task(handler.process(SimpleNamespace(data={
        "conversationType": "1",
        "content": {"downloadCode": "abc123", "fileName": "report.xlsx"},
        "text": {"content": ""},
    })))
    await download_started.wait()

    try:
        await channel.stop()
        release_download.set()
        assert await process_task == ("OK", "OK")
        await asyncio.sleep(0)

        assert not message_task_started.is_set()
        assert not channel._background_tasks
    finally:
        release_download.set()
        if not process_task.done():
            process_task.cancel()
        pending = tuple(channel._background_tasks)
        for task in pending:
            task.cancel()
        await asyncio.gather(process_task, *pending, return_exceptions=True)


def _rich_text_message(rich_text_list):
    class _FakeRichTextChatbotMessage:
        text = None
        extensions = {}
        image_content = None
        rich_text_content = SimpleNamespace(rich_text_list=rich_text_list)
        sender_staff_id = "user1"
        sender_id = "fallback-user"
        sender_nick = "Alice"
        message_type = "richText"

        @staticmethod
        def from_dict(_data):
            return _FakeRichTextChatbotMessage()

    return _FakeRichTextChatbotMessage


@pytest.mark.asyncio
async def test_handler_richtext_keeps_formatted_segments(monkeypatch) -> None:
    """richText segments with non-'text' types (bold/italic/code/pre) must be kept
    and mapped to Markdown, not dropped (issue #4497)."""
    bus = MessageBus()
    channel = DingTalkChannel(
        DingTalkConfig(client_id="app", client_secret="secret", allow_from=["user1"]),
        bus,
    )
    handler = NanobotDingTalkHandler(channel)

    fake_msg = _rich_text_message([
        {"type": "bold", "text": "Title"},
        {"type": "text", "text": "plain"},
        {"type": "italic", "text": "em"},
        {"type": "inlineCode", "text": "x = 1"},
        {"type": "pre", "text": "block"},
    ])
    monkeypatch.setattr(dingtalk_module, "ChatbotMessage", fake_msg)
    monkeypatch.setattr(dingtalk_module, "AckMessage", SimpleNamespace(STATUS_OK="OK"))

    status, body = await handler.process(
        SimpleNamespace(data={"conversationType": "1", "text": {"content": ""}})
    )
    msg = await asyncio.wait_for(bus.consume_inbound(), timeout=2.0)

    assert (status, body) == ("OK", "OK")
    assert msg.content == "**Title** plain *em* `x = 1` ```\nblock\n```"


@pytest.mark.asyncio
async def test_handler_richtext_all_formatted_not_dropped(monkeypatch) -> None:
    """A richText message made only of formatted segments must not end up with empty
    content and fall through to the 'unsupported message type' path (issue #4497)."""
    bus = MessageBus()
    channel = DingTalkChannel(
        DingTalkConfig(client_id="app", client_secret="secret", allow_from=["user1"]),
        bus,
    )
    handler = NanobotDingTalkHandler(channel)

    fake_msg = _rich_text_message([{"type": "bold", "text": "Important"}])
    monkeypatch.setattr(dingtalk_module, "ChatbotMessage", fake_msg)
    monkeypatch.setattr(dingtalk_module, "AckMessage", SimpleNamespace(STATUS_OK="OK"))

    status, body = await handler.process(
        SimpleNamespace(data={"conversationType": "1", "text": {"content": ""}})
    )
    # Before the fix this message produced empty content and never reached the bus,
    # so consume_inbound would block here.
    msg = await asyncio.wait_for(bus.consume_inbound(), timeout=2.0)

    assert (status, body) == ("OK", "OK")
    assert msg.content == "**Important**"


@pytest.mark.asyncio
async def test_handler_richtext_item_with_text_and_download(monkeypatch) -> None:
    """A rich-text item carrying both text and a downloadCode must yield both the
    text and the downloaded file, not drop the attachment (issue #4497)."""
    bus = MessageBus()
    channel = DingTalkChannel(
        DingTalkConfig(client_id="app", client_secret="secret", allow_from=["user1"]),
        bus,
    )
    handler = NanobotDingTalkHandler(channel)

    fake_msg = _rich_text_message([
        {"text": "see attached", "downloadCode": "abc123", "fileName": "report.xlsx"},
    ])

    async def fake_download(download_code, filename, sender_id):
        return f"/tmp/nanobot_dingtalk/{sender_id}/{filename}"

    monkeypatch.setattr(dingtalk_module, "ChatbotMessage", fake_msg)
    monkeypatch.setattr(dingtalk_module, "AckMessage", SimpleNamespace(STATUS_OK="OK"))
    monkeypatch.setattr(channel, "_download_dingtalk_file", fake_download)

    status, body = await handler.process(
        SimpleNamespace(data={"conversationType": "1", "text": {"content": ""}})
    )
    await asyncio.gather(*list(channel._background_tasks))
    msg = await asyncio.wait_for(bus.consume_inbound(), timeout=2.0)

    assert (status, body) == ("OK", "OK")
    assert "see attached" in msg.content
    assert "/tmp/nanobot_dingtalk/user1/report.xlsx" in msg.content


@pytest.mark.asyncio
async def test_start_configures_http_timeout(monkeypatch) -> None:
    """The shared httpx client must be created with an explicit timeout so file/image
    downloads don't hit httpx's 5s default and ConnectTimeout (issue #4497)."""
    channel = DingTalkChannel(
        DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
        MessageBus(),
    )

    class _FakeStreamClient:
        def __init__(self, _credential):
            pass

        def register_callback_handler(self, _topic, _handler):
            pass

        async def start(self):
            # Exit the reconnect loop after one iteration.
            channel._running = False

    monkeypatch.setattr(dingtalk_module, "DINGTALK_AVAILABLE", True)
    monkeypatch.setattr(dingtalk_module, "Credential", lambda *a, **k: object())
    monkeypatch.setattr(dingtalk_module, "DingTalkStreamClient", _FakeStreamClient)
    monkeypatch.setattr(dingtalk_module, "ChatbotMessage", SimpleNamespace(TOPIC="topic"))

    await channel.start()

    assert channel._http is not None
    timeout = channel._http.timeout
    assert timeout.connect == 10.0
    assert timeout.read == 30.0
    assert timeout.write == 30.0
    assert timeout.pool == 10.0

    await channel.stop()


@pytest.mark.asyncio
async def test_stop_cancels_stream_client_after_sdk_swallows_first_cancel(monkeypatch) -> None:
    channel = DingTalkChannel(
        DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
        MessageBus(),
    )
    created: dict[str, object] = {}

    class _FakeWebsocket:
        def __init__(self) -> None:
            self.closed = False

        async def close(self) -> None:
            self.closed = True

    class _CancelSwallowingStreamClient:
        def __init__(self, _credential):
            self.websocket = _FakeWebsocket()
            self.started = asyncio.Event()
            self.cancelled_once = asyncio.Event()
            created["client"] = self

        def register_callback_handler(self, _topic, _handler):
            pass

        async def start(self):
            self.started.set()
            while True:
                try:
                    await asyncio.Future()
                except asyncio.CancelledError:
                    self.cancelled_once.set()
                    await asyncio.sleep(3600)

    monkeypatch.setattr(dingtalk_module, "DINGTALK_AVAILABLE", True)
    monkeypatch.setattr(dingtalk_module, "Credential", lambda *a, **k: object())
    monkeypatch.setattr(dingtalk_module, "DingTalkStreamClient", _CancelSwallowingStreamClient)
    monkeypatch.setattr(dingtalk_module, "ChatbotMessage", SimpleNamespace(TOPIC="topic"))

    start_task = asyncio.create_task(channel.start())
    while "client" not in created:
        await asyncio.sleep(0)
    client = created["client"]
    await asyncio.wait_for(client.started.wait(), timeout=0.5)

    start_task.cancel()
    await asyncio.wait_for(client.cancelled_once.wait(), timeout=0.5)
    assert not start_task.done()

    await asyncio.wait_for(channel.stop(), timeout=0.5)

    assert client.websocket.closed is True
    assert start_task.cancelled()


@pytest.mark.asyncio
async def test_stop_waits_for_background_message_tasks() -> None:
    channel = DingTalkChannel(
        DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
        MessageBus(),
    )
    mock_logger = MagicMock()
    channel.logger = mock_logger
    started = asyncio.Event()
    cancelled = asyncio.Event()

    async def wait_forever() -> None:
        started.set()
        try:
            await asyncio.Future()
        finally:
            cancelled.set()

    task = asyncio.create_task(wait_forever())
    channel._background_tasks.add(task)
    task.add_done_callback(channel._on_background_task_done)
    await started.wait()

    try:
        await channel.stop()
        assert task.done()
        assert cancelled.is_set()
        assert not channel._background_tasks
        mock_logger.opt.assert_not_called()
    finally:
        if not task.done():
            task.cancel()
        await asyncio.gather(task, return_exceptions=True)


@pytest.mark.asyncio
async def test_download_dingtalk_file(tmp_path, monkeypatch) -> None:
    """Test the two-step file download flow (get URL then download content)."""
    channel = DingTalkChannel(
        DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
        MessageBus(),
    )

    # Mock access token
    async def fake_get_token():
        return "test-token"

    monkeypatch.setattr(channel, "_get_access_token", fake_get_token)

    # Mock HTTP: first POST returns downloadUrl, then GET returns file bytes
    file_content = b"fake file content"
    channel._http = _FakeHttp(responses=[
        _FakeResponse(200, {"downloadUrl": "https://example.com/tmpfile"}),
        _FakeResponse(200),
    ])
    channel._http._responses[1].content = file_content

    # Redirect media dir to tmp_path
    monkeypatch.setattr(
        "nanobot.config.paths.get_media_dir",
        lambda channel_name=None: tmp_path / channel_name if channel_name else tmp_path,
    )

    result = await channel._download_dingtalk_file("code123", "test.xlsx", "user1")

    assert result is not None
    assert result.endswith("test.xlsx")
    assert (tmp_path / "dingtalk" / "user1" / "test.xlsx").read_bytes() == file_content

    # Verify API calls
    assert channel._http.calls[0]["method"] == "POST"
    assert "messageFiles/download" in channel._http.calls[0]["url"]
    assert channel._http.calls[0]["json"]["downloadCode"] == "code123"
    assert channel._http.calls[1]["method"] == "GET"


@pytest.mark.asyncio
async def test_read_media_bytes_rejects_private_http_target_before_fetch() -> None:
    """Remote media fetches must not reach loopback/private addresses."""
    channel = DingTalkChannel(
        DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
        MessageBus(),
    )
    channel._http = _FakeHttp(
        responses=[
            _FakeResponse(
                200,
                content=b"internal secret",
                headers={"content-type": "text/plain"},
                url="http://127.0.0.1/admin.txt",
            )
        ]
    )

    data, filename, content_type = await channel._read_media_bytes("http://127.0.0.1/admin.txt")

    assert (data, filename, content_type) == (None, None, None)
    assert channel._http.calls == []


@pytest.mark.asyncio
async def test_read_media_bytes_rejects_private_redirect_result() -> None:
    """A public-looking media URL must not be accepted after redirecting private."""
    channel = DingTalkChannel(
        DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
        MessageBus(),
    )
    channel._http = _FakeHttp(
        responses=[
            _FakeResponse(
                200,
                content=b"metadata bytes",
                headers={"content-type": "text/plain"},
                url="http://127.0.0.1/metadata",
            )
        ]
    )

    data, filename, content_type = await channel._read_media_bytes("https://example.com/safe.txt")

    assert (data, filename, content_type) == (None, None, None)
    assert len(channel._http.calls) == 1


@pytest.mark.asyncio
async def test_read_media_bytes_rejects_oversized_remote_response(monkeypatch) -> None:
    """DingTalk media downloads should enforce a byte cap before upload."""
    monkeypatch.setattr(dingtalk_module, "DINGTALK_MAX_REMOTE_MEDIA_BYTES", 8, raising=False)
    channel = DingTalkChannel(
        DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
        MessageBus(),
    )
    channel._http = _FakeHttp(
        responses=[
            _FakeResponse(
                200,
                content=b"123456789",
                headers={"content-type": "text/plain"},
                url="https://example.com/large.txt",
            )
        ]
    )

    data, filename, content_type = await channel._read_media_bytes("https://example.com/large.txt")

    assert (data, filename, content_type) == (None, None, None)


@pytest.mark.asyncio
async def test_read_media_bytes_does_not_follow_remote_redirects_by_default() -> None:
    """Redirects are refused by default instead of followed into internal networks."""
    channel = DingTalkChannel(
        DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
        MessageBus(),
    )
    channel._http = _FakeHttp(
        responses=[
            _FakeResponse(
                302,
                headers={"location": "http://127.0.0.1/metadata"},
                url="https://example.com/redirect.txt",
            )
        ]
    )

    data, filename, content_type = await channel._read_media_bytes("https://example.com/redirect.txt")

    assert (data, filename, content_type) == (None, None, None)
    assert channel._http.calls[0]["kwargs"]["follow_redirects"] is False


@pytest.mark.asyncio
async def test_read_media_bytes_follows_safe_redirect_when_explicitly_enabled() -> None:
    """Operators can opt in to public redirects without enabling private redirects."""
    channel = DingTalkChannel(
        DingTalkConfig(
            client_id="app",
            client_secret="secret",
            allow_from=["*"],
            allow_remote_media_redirects=True,
        ),
        MessageBus(),
    )
    channel._http = _FakeHttp(
        responses=[
            _FakeResponse(
                302,
                headers={"location": "https://example.com/final.txt"},
                url="https://example.com/redirect.txt",
            ),
            _FakeResponse(
                200,
                content=b"redirected media",
                headers={"content-type": "text/plain"},
                url="https://example.com/final.txt",
            ),
        ]
    )

    data, filename, content_type = await channel._read_media_bytes("https://example.com/redirect.txt")

    assert (data, filename, content_type) == (b"redirected media", "redirect.txt", "text/plain")
    assert [call["url"] for call in channel._http.calls] == [
        "https://example.com/redirect.txt",
        "https://example.com/final.txt",
    ]
    assert all(call["kwargs"]["follow_redirects"] is False for call in channel._http.calls)


@pytest.mark.asyncio
async def test_read_media_bytes_blocks_cross_host_redirect_without_allowlist() -> None:
    """Redirect opt-in should not allow arbitrary cross-host redirects by default."""
    channel = DingTalkChannel(
        DingTalkConfig(
            client_id="app",
            client_secret="secret",
            allow_from=["*"],
            allow_remote_media_redirects=True,
        ),
        MessageBus(),
    )
    channel._http = _FakeHttp(
        responses=[
            _FakeResponse(
                302,
                headers={"location": "https://example.org/final.txt"},
                url="https://example.com/redirect.txt",
            ),
            _FakeResponse(
                200,
                content=b"cross-host media",
                headers={"content-type": "text/plain"},
                url="https://example.org/final.txt",
            ),
        ]
    )

    data, filename, content_type = await channel._read_media_bytes("https://example.com/redirect.txt")

    assert (data, filename, content_type) == (None, None, None)
    assert [call["url"] for call in channel._http.calls] == ["https://example.com/redirect.txt"]


@pytest.mark.asyncio
async def test_read_media_bytes_allows_cross_host_redirect_when_allowlisted() -> None:
    """Operators can explicitly allow a known CDN/download host for redirects."""
    channel = DingTalkChannel(
        DingTalkConfig(
            client_id="app",
            client_secret="secret",
            allow_from=["*"],
            allow_remote_media_redirects=True,
            remote_media_redirect_allowed_hosts=["example.org"],
        ),
        MessageBus(),
    )
    channel._http = _FakeHttp(
        responses=[
            _FakeResponse(
                302,
                headers={"location": "https://example.org/final.txt"},
                url="https://example.com/redirect.txt",
            ),
            _FakeResponse(
                200,
                content=b"cross-host media",
                headers={"content-type": "text/plain"},
                url="https://example.org/final.txt",
            ),
        ]
    )

    data, filename, content_type = await channel._read_media_bytes("https://example.com/redirect.txt")

    assert (data, filename, content_type) == (b"cross-host media", "redirect.txt", "text/plain")
    assert [call["url"] for call in channel._http.calls] == [
        "https://example.com/redirect.txt",
        "https://example.org/final.txt",
    ]


@pytest.mark.asyncio
async def test_read_media_bytes_blocks_private_redirect_even_when_redirects_enabled() -> None:
    """Redirect opt-in must still validate each hop before fetching it."""
    channel = DingTalkChannel(
        DingTalkConfig(
            client_id="app",
            client_secret="secret",
            allow_from=["*"],
            allow_remote_media_redirects=True,
        ),
        MessageBus(),
    )
    channel._http = _FakeHttp(
        responses=[
            _FakeResponse(
                302,
                headers={"location": "http://127.0.0.1/metadata"},
                url="https://example.com/redirect.txt",
            ),
            _FakeResponse(
                200,
                content=b"internal secret",
                headers={"content-type": "text/plain"},
                url="http://127.0.0.1/metadata",
            ),
        ]
    )

    data, filename, content_type = await channel._read_media_bytes("https://example.com/redirect.txt")

    assert (data, filename, content_type) == (None, None, None)
    assert [call["url"] for call in channel._http.calls] == ["https://example.com/redirect.txt"]


def test_normalize_upload_payload_zips_html_attachment() -> None:
    channel = DingTalkChannel(
        DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
        MessageBus(),
    )

    data, filename, content_type = channel._normalize_upload_payload(
        "report.html",
        b"<html><body>Hello</body></html>",
        "text/html",
    )

    assert filename == "report.zip"
    assert content_type == "application/zip"

    archive = zipfile.ZipFile(BytesIO(data))
    assert archive.namelist() == ["report.html"]
    assert archive.read("report.html") == b"<html><body>Hello</body></html>"


@pytest.mark.asyncio
async def test_send_media_ref_zips_html_before_upload(tmp_path, monkeypatch) -> None:
    channel = DingTalkChannel(
        DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
        MessageBus(),
    )

    html_path = tmp_path / "report.html"
    html_path.write_text("<html><body>Hello</body></html>", encoding="utf-8")

    captured: dict[str, object] = {}

    async def fake_upload_media(*, token, data, media_type, filename, content_type):
        captured.update(
            {
                "token": token,
                "data": data,
                "media_type": media_type,
                "filename": filename,
                "content_type": content_type,
            }
        )
        return "media-123"

    async def fake_send_batch_message(token, chat_id, msg_key, msg_param):
        captured.update(
            {
                "sent_token": token,
                "chat_id": chat_id,
                "msg_key": msg_key,
                "msg_param": msg_param,
            }
        )
        return True

    monkeypatch.setattr(channel, "_upload_media", fake_upload_media)
    monkeypatch.setattr(channel, "_send_batch_message", fake_send_batch_message)

    ok = await channel._send_media_ref("token-123", "user-1", str(html_path))

    assert ok is True
    assert captured["media_type"] == "file"
    assert captured["filename"] == "report.zip"
    assert captured["content_type"] == "application/zip"
    assert captured["msg_key"] == "sampleFile"
    assert captured["msg_param"] == {
        "mediaId": "media-123",
        "fileName": "report.zip",
        "fileType": "zip",
    }

    archive = zipfile.ZipFile(BytesIO(captured["data"]))
    assert archive.namelist() == ["report.html"]


# ── Exception handling tests ──────────────────────────────────────────


@pytest.mark.asyncio
async def test_send_batch_message_propagates_transport_error() -> None:
    """Network/transport errors must re-raise so callers can retry."""
    config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"])
    channel = DingTalkChannel(config, MessageBus())
    channel._http = _NetworkErrorHttp()

    with pytest.raises(httpx.ConnectError, match="Connection refused"):
        await channel._send_batch_message(
            "token",
            "user123",
            "sampleMarkdown",
            {"text": "hello", "title": "Nanobot Reply"},
        )

    # The POST was attempted exactly once
    assert len(channel._http.calls) == 1
    assert channel._http.calls[0]["method"] == "POST"


@pytest.mark.asyncio
async def test_send_batch_message_returns_false_on_api_error() -> None:
    """DingTalk API-level errors (non-200 status, errcode != 0) should return False."""
    config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"])
    channel = DingTalkChannel(config, MessageBus())

    # Non-200 status code → API error → return False
    channel._http = _FakeHttp(responses=[_FakeResponse(400, {"errcode": 400})])
    result = await channel._send_batch_message(
        "token", "user123", "sampleMarkdown", {"text": "hello"}
    )
    assert result is False

    # 200 with non-zero errcode → API error → return False
    channel._http = _FakeHttp(responses=[_FakeResponse(200, {"errcode": 100})])
    result = await channel._send_batch_message(
        "token", "user123", "sampleMarkdown", {"text": "hello"}
    )
    assert result is False

    # 200 with errcode=0 → success → return True
    channel._http = _FakeHttp(responses=[_FakeResponse(200, {"errcode": 0})])
    result = await channel._send_batch_message(
        "token", "user123", "sampleMarkdown", {"text": "hello"}
    )
    assert result is True


@pytest.mark.asyncio
async def test_send_raises_when_access_token_is_unavailable(monkeypatch) -> None:
    channel = DingTalkChannel(
        DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
        MessageBus(),
    )
    monkeypatch.setattr(channel, "_get_access_token", AsyncMock(return_value=None))

    with pytest.raises(RuntimeError, match="access token unavailable"):
        await channel.send(
            OutboundMessage(channel="dingtalk", chat_id="user123", content="hello")
        )


@pytest.mark.asyncio
async def test_send_raises_when_text_is_not_delivered(monkeypatch) -> None:
    channel = DingTalkChannel(
        DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
        MessageBus(),
    )
    monkeypatch.setattr(channel, "_get_access_token", AsyncMock(return_value="token"))
    monkeypatch.setattr(channel, "_send_markdown_text", AsyncMock(return_value=False))

    with pytest.raises(RuntimeError, match="text message was not delivered"):
        await channel.send(
            OutboundMessage(channel="dingtalk", chat_id="user123", content="hello")
        )


@pytest.mark.asyncio
async def test_send_media_ref_short_circuits_on_transport_error() -> None:
    """When the first send fails with a transport error, _send_media_ref must
    re-raise immediately instead of trying download+upload+fallback."""
    config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"])
    channel = DingTalkChannel(config, MessageBus())
    channel._http = _NetworkErrorHttp()

    # An image URL triggers the sampleImageMsg path first
    with pytest.raises(httpx.ConnectError, match="Connection refused"):
        await channel._send_media_ref("token", "user123", "https://example.com/photo.jpg")

    # Only one POST should have been attempted — no download/upload/fallback
    assert len(channel._http.calls) == 1
    assert channel._http.calls[0]["method"] == "POST"


@pytest.mark.asyncio
async def test_send_media_ref_short_circuits_on_download_transport_error() -> None:
    """When the image URL send returns an API error (False) but the download
    for the fallback hits a transport error, it must re-raise rather than
    silently returning False."""
    config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"])
    channel = DingTalkChannel(config, MessageBus())

    # First POST (sampleImageMsg) returns API error → False, then GET (download) raises transport error
    class _MixedHttp:
        def __init__(self) -> None:
            self.calls: list[dict] = []

        async def post(self, url, json=None, headers=None, **kwargs):
            self.calls.append({"method": "POST", "url": url})
            # API-level failure: 200 with errcode != 0
            return _FakeResponse(200, {"errcode": 100})

        async def get(self, url, **kwargs):
            self.calls.append({"method": "GET", "url": url})
            raise httpx.ConnectError("Connection refused")

    channel._http = _MixedHttp()

    with pytest.raises(httpx.ConnectError, match="Connection refused"):
        await channel._send_media_ref("token", "user123", "https://example.com/photo.jpg")

    # Should have attempted POST (image URL) and GET (download), but NOT upload
    assert len(channel._http.calls) == 2
    assert channel._http.calls[0]["method"] == "POST"
    assert channel._http.calls[1]["method"] == "GET"


@pytest.mark.asyncio
async def test_send_media_ref_short_circuits_on_upload_transport_error() -> None:
    """When download succeeds but upload hits a transport error, must re-raise."""
    config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"])
    channel = DingTalkChannel(config, MessageBus())

    image_bytes = b"\xff\xd8\xff\xe0" + b"\x00" * 100  # minimal JPEG-ish data

    class _UploadFailsHttp:
        def __init__(self) -> None:
            self.calls: list[dict] = []

        async def post(self, url, json=None, headers=None, files=None, **kwargs):
            self.calls.append({"method": "POST", "url": url})
            # If it's the upload endpoint, raise transport error
            if "media/upload" in url:
                raise httpx.ConnectError("Connection refused")
            # Otherwise (sampleImageMsg), return API error to trigger fallback
            return _FakeResponse(200, {"errcode": 100})

        async def get(self, url, **kwargs):
            self.calls.append({"method": "GET", "url": url})
            resp = _FakeResponse(200)
            resp.content = image_bytes
            resp.headers = {"content-type": "image/jpeg"}
            return resp

    channel._http = _UploadFailsHttp()

    with pytest.raises(httpx.ConnectError, match="Connection refused"):
        await channel._send_media_ref("token", "user123", "https://example.com/photo.jpg")

    # POST (image URL), GET (download), POST (upload) attempted — no further sends
    methods = [c["method"] for c in channel._http.calls]
    assert methods == ["POST", "GET", "POST"]
