# -*- coding: utf-8 -*-
"""
DingTalk Channel Unit Tests

Comprehensive unit tests for DingTalkChannel covering:
- Initialization and configuration
- Session webhook management (storage/retrieval/expiry)
- Token caching mechanism
- Message deduplication (thread safety)
- Send methods (webhook, Open API, AI Card)
- Utility functions

Test Patterns:
- Uses MockAiohttpSession for HTTP request mocking
- Tests based on CHAN-D02 (webhook expiry), CHAN-D04 (file receiving)
- Covers complex internal logic not covered by contract tests

Run:
    pytest tests/unit/channels/test_dingtalk.py -v
    pytest tests/unit/channels/test_dingtalk.py::TestDingTalkSessionWebhook -v
"""

# pylint: disable=redefined-outer-name,protected-access,unused-argument
# pylint: disable=broad-exception-raised,using-constant-test,unused-import
# pylint: disable=reimported
from __future__ import annotations


import asyncio
import json
import threading
import time
from pathlib import Path
from typing import Generator
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from qwenpaw.app.channels.renderer import ChannelDisplayConfig

from qwenpaw.exceptions import ChannelError
from tests.fixtures.channels.mock_http import MockAiohttpSession

# =============================================================================
# Fixtures
# =============================================================================


@pytest.fixture
def mock_process_handler() -> AsyncMock:
    """Mock process handler that yields simple events."""

    async def mock_process(*_args, **_kwargs):
        mock_event = MagicMock()
        mock_event.object = "message"
        mock_event.status = "completed"
        mock_event.type = "text"
        yield mock_event

    return AsyncMock(side_effect=mock_process)


@pytest.fixture
def temp_media_dir(tmp_path) -> Path:
    """Temporary directory for media files."""
    media_dir = tmp_path / ".copaw" / "media"
    media_dir.mkdir(parents=True, exist_ok=True)
    return media_dir


@pytest.fixture
def temp_workspace_dir(tmp_path) -> Path:
    """Temporary workspace directory."""
    workspace = tmp_path / ".copaw" / "workspace"
    workspace.mkdir(parents=True, exist_ok=True)
    return workspace


@pytest.fixture
def dingtalk_channel(
    mock_process_handler,
    temp_media_dir,
) -> Generator:
    """Create a DingTalkChannel instance for testing."""
    from qwenpaw.app.channels.dingtalk.channel import DingTalkChannel

    channel = DingTalkChannel(
        process=mock_process_handler,
        enabled=True,
        client_id="test_client_id",
        client_secret="test_client_secret",
        bot_prefix="[TestBot] ",
        media_dir=str(temp_media_dir),
        display_config=ChannelDisplayConfig(
            show_tool_calls=False,
            show_tool_results=False,
        ),
    )
    yield channel


@pytest.fixture
def dingtalk_channel_with_workspace(
    mock_process_handler,
    temp_workspace_dir,
) -> Generator:
    """Create a DingTalkChannel with workspace for testing."""
    from qwenpaw.app.channels.dingtalk.channel import DingTalkChannel

    channel = DingTalkChannel(
        process=mock_process_handler,
        enabled=True,
        client_id="test_client_id",
        client_secret="test_client_secret",
        bot_prefix="[TestBot] ",
        workspace_dir=temp_workspace_dir,
        display_config=ChannelDisplayConfig(
            show_tool_calls=False,
            show_tool_results=False,
        ),
    )
    yield channel


@pytest.fixture
def mock_http_session() -> MockAiohttpSession:
    """Create a mock aiohttp session."""
    return MockAiohttpSession()


def _make_oauth_sdk(token: str = "token_123") -> AsyncMock:
    """Return a mock _oauth_sdk whose get_access_token_async returns token."""
    sdk = AsyncMock()
    body = MagicMock()
    body.access_token = token
    response = MagicMock()
    response.body = body
    sdk.get_access_token_async = AsyncMock(return_value=response)
    return sdk


def _make_robot_sdk() -> AsyncMock:
    """Return a mock _robot_sdk with commonly-used async methods."""
    sdk = AsyncMock()
    sdk.org_group_send_with_options_async = AsyncMock(return_value=MagicMock())
    sdk.batch_send_otowith_options_async = AsyncMock(return_value=MagicMock())
    sdk.robot_message_file_download_with_options_async = AsyncMock(
        return_value=MagicMock(),
    )
    return sdk


def _make_card_sdk() -> AsyncMock:
    """Return a mock _card_sdk with commonly-used async methods."""
    sdk = AsyncMock()
    sdk.create_card_with_options_async = AsyncMock(return_value=MagicMock())
    sdk.deliver_card_with_options_async = AsyncMock(return_value=MagicMock())
    sdk.streaming_update_with_options_async = AsyncMock(
        return_value=MagicMock(),
    )
    return sdk


# =============================================================================
# P0: Initialization and Configuration
# =============================================================================


class TestDingTalkChannelInit:
    """
    Tests for DingTalkChannel initialization and factory methods.
    Verifies correct storage of configuration parameters.
    """

    def test_init_stores_basic_config(
        self,
        mock_process_handler,
        temp_media_dir,
    ):
        """Constructor should store all basic configuration parameters."""
        from qwenpaw.app.channels.dingtalk.channel import DingTalkChannel

        channel = DingTalkChannel(
            process=mock_process_handler,
            enabled=True,
            client_id="my_client_id",
            client_secret="my_client_secret",
            bot_prefix="[Bot] ",
            message_type="text",
            media_dir=str(temp_media_dir),
        )

        assert channel.enabled is True
        assert channel.client_id == "my_client_id"
        assert channel.client_secret == "my_client_secret"
        assert channel.bot_prefix == "[Bot] "
        assert channel.message_type == "text"
        assert channel.channel == "dingtalk"

    def test_init_stores_advanced_config(
        self,
        mock_process_handler,
        temp_media_dir,
    ):
        """Constructor should store advanced configuration parameters."""
        from qwenpaw.app.channels.dingtalk.channel import DingTalkChannel

        channel = DingTalkChannel(
            process=mock_process_handler,
            enabled=True,
            client_id="test_id",
            client_secret="test_secret",
            bot_prefix="",
            card_template_id="template_123",
            card_template_key="my_key",
            robot_code="robot_456",
            require_mention=True,
            card_auto_layout=True,
        )

        assert channel.card_template_id == "template_123"
        assert channel.card_template_key == "my_key"
        assert channel.robot_code == "robot_456"
        assert channel.require_mention is True
        assert channel.card_auto_layout is True

    def test_init_creates_required_data_structures(self, mock_process_handler):
        """Constructor should initialize required internal data structures."""
        from qwenpaw.app.channels.dingtalk.channel import DingTalkChannel

        channel = DingTalkChannel(
            process=mock_process_handler,
            enabled=True,
            client_id="test_id",
            client_secret="test_secret",
            bot_prefix="",
        )

        # Session webhook store
        assert hasattr(channel, "_session_webhook_store")
        assert isinstance(channel._session_webhook_store, dict)

        # Processing message IDs set
        assert hasattr(channel, "_processing_message_ids")
        assert isinstance(channel._processing_message_ids, set)

        # Token cache
        assert hasattr(channel, "_token_value")
        assert channel._token_value is None
        assert hasattr(channel, "_token_expires_at")
        assert channel._token_expires_at == 0.0

    def test_init_creates_locks(self, mock_process_handler):
        """Constructor should create required locks for thread safety."""
        from qwenpaw.app.channels.dingtalk.channel import DingTalkChannel

        channel = DingTalkChannel(
            process=mock_process_handler,
            enabled=True,
            client_id="test_id",
            client_secret="test_secret",
            bot_prefix="",
        )

        # Session webhook lock
        assert hasattr(channel, "_session_webhook_lock")
        assert isinstance(channel._session_webhook_lock, asyncio.Lock)

        # Token lock
        assert hasattr(channel, "_token_lock")
        assert isinstance(channel._token_lock, asyncio.Lock)

        # Processing message IDs lock
        assert hasattr(channel, "_processing_message_ids_lock")
        lock_type = type(channel._processing_message_ids_lock).__name__
        assert "lock" in lock_type.lower()

    def test_channel_type_is_dingtalk(self, dingtalk_channel):
        """Channel type must be 'dingtalk'."""
        assert dingtalk_channel.channel == "dingtalk"


class TestDingTalkChannelFromEnv:
    """Tests for from_env factory method."""

    def test_from_env_reads_basic_env_vars(
        self,
        mock_process_handler,
        monkeypatch,
    ):
        """from_env should read basic environment variables."""
        from qwenpaw.app.channels.dingtalk.channel import DingTalkChannel

        monkeypatch.setenv("DINGTALK_CHANNEL_ENABLED", "0")
        monkeypatch.setenv("DINGTALK_CLIENT_ID", "env_client_id")
        monkeypatch.setenv("DINGTALK_CLIENT_SECRET", "env_client_secret")
        monkeypatch.setenv("DINGTALK_BOT_PREFIX", "[EnvBot] ")

        channel = DingTalkChannel.from_env(mock_process_handler)

        assert channel.enabled is False
        assert channel.client_id == "env_client_id"
        assert channel.client_secret == "env_client_secret"
        assert channel.bot_prefix == "[EnvBot] "

    def test_from_env_reads_advanced_env_vars(
        self,
        mock_process_handler,
        monkeypatch,
    ):
        """from_env should read advanced environment variables."""
        from qwenpaw.app.channels.dingtalk.channel import DingTalkChannel

        monkeypatch.setenv("DINGTALK_CLIENT_ID", "test_id")
        monkeypatch.setenv("DINGTALK_CLIENT_SECRET", "test_secret")
        monkeypatch.setenv("DINGTALK_MESSAGE_TYPE", "text")
        monkeypatch.setenv("DINGTALK_CARD_TEMPLATE_ID", "template_env")
        monkeypatch.setenv("DINGTALK_CARD_TEMPLATE_KEY", "content_env")
        monkeypatch.setenv("DINGTALK_ROBOT_CODE", "robot_env")
        monkeypatch.setenv("DINGTALK_REQUIRE_MENTION", "1")
        monkeypatch.setenv("DINGTALK_CARD_AUTO_LAYOUT", "1")

        channel = DingTalkChannel.from_env(mock_process_handler)

        assert channel.message_type == "text"
        assert channel.card_template_id == "template_env"
        assert channel.card_template_key == "content_env"
        assert channel.robot_code == "robot_env"
        assert channel.require_mention is True
        assert channel.card_auto_layout is True

    def test_from_env_allow_from_parsing(
        self,
        mock_process_handler,
        monkeypatch,
    ):
        """from_env should parse DINGTALK_ALLOW_FROM correctly."""
        from qwenpaw.app.channels.dingtalk.channel import DingTalkChannel

        monkeypatch.setenv("DINGTALK_CLIENT_ID", "test_id")
        monkeypatch.setenv("DINGTALK_CLIENT_SECRET", "test_secret")
        monkeypatch.setenv("DINGTALK_ALLOW_FROM", "user1,user2,user3")

        channel = DingTalkChannel.from_env(mock_process_handler)

        assert "user1" in channel.allow_from
        assert "user2" in channel.allow_from
        assert "user3" in channel.allow_from

    def test_from_env_allow_from_empty(
        self,
        mock_process_handler,
        monkeypatch,
    ):
        """from_env should handle empty DINGTALK_ALLOW_FROM."""
        from qwenpaw.app.channels.dingtalk.channel import DingTalkChannel

        monkeypatch.setenv("DINGTALK_CLIENT_ID", "test_id")
        monkeypatch.setenv("DINGTALK_CLIENT_SECRET", "test_secret")
        monkeypatch.setenv("DINGTALK_ALLOW_FROM", "")

        channel = DingTalkChannel.from_env(mock_process_handler)

        assert channel.allow_from == set()

    def test_from_env_defaults(self, mock_process_handler, monkeypatch):
        """from_env should use sensible defaults."""
        from qwenpaw.app.channels.dingtalk.channel import DingTalkChannel

        monkeypatch.setenv("DINGTALK_CLIENT_ID", "test_id")
        monkeypatch.setenv("DINGTALK_CLIENT_SECRET", "test_secret")
        monkeypatch.delenv("DINGTALK_CHANNEL_ENABLED", raising=False)
        monkeypatch.delenv("DINGTALK_BOT_PREFIX", raising=False)
        monkeypatch.delenv("DINGTALK_REQUIRE_MENTION", raising=False)

        channel = DingTalkChannel.from_env(mock_process_handler)

        assert channel.enabled is True  # Default enabled
        assert channel.bot_prefix == ""  # Default empty
        assert channel.require_mention is False  # Default False


class TestDingTalkChannelFromConfig:
    """Tests for from_config factory method."""

    def test_from_config_uses_config_values(self, mock_process_handler):
        """from_config should use values from config object."""
        from qwenpaw.app.channels.dingtalk.channel import DingTalkChannel
        from qwenpaw.config.config import DingTalkConfig

        config = DingTalkConfig(
            enabled=False,
            client_id="config_client_id",
            client_secret="config_client_secret",
            bot_prefix="[ConfigBot] ",
            message_type="text",
            dm_policy="allowlist",  # Valid values: 'open' or 'allowlist'
            group_policy="allowlist",
            require_mention=True,
        )

        channel = DingTalkChannel.from_config(
            process=mock_process_handler,
            config=config,
        )

        assert channel.enabled is False
        assert channel.client_id == "config_client_id"
        assert channel.client_secret == "config_client_secret"
        assert channel.bot_prefix == "[ConfigBot] "
        assert channel.message_type == "text"
        assert channel.dm_policy == "allowlist"
        assert channel.group_policy == "allowlist"
        assert channel.require_mention is True


# =============================================================================
# P1: Session Webhook Management (Critical for CHAN-D02)
# =============================================================================


@pytest.mark.asyncio
class TestDingTalkSessionWebhook:
    """
    Tests for session webhook storage and retrieval.

    CHAN-D02: 钉钉 sessionWebhook 过期后定时推送
    - sessionWebhook has an expiry time
    - System should refresh/re-obtain valid sessionWebhook
    - Webhooks should be persisted to disk for cron jobs
    """

    async def test_load_session_webhook_from_memory(self, dingtalk_channel):
        """Loading webhook should first check memory."""
        # Pre-populate memory store
        dingtalk_channel._session_webhook_store["dingtalk:sw:memtest"] = {
            "webhook": "http://memory.webhook",
            "expired_time": 9999999999999,
        }

        result = await dingtalk_channel._load_session_webhook(
            "dingtalk:sw:memtest",
        )

        assert result == "http://memory.webhook"

    async def test_load_session_webhook_from_disk(
        self,
        dingtalk_channel_with_workspace,
        temp_workspace_dir,
    ):
        """Loading webhook should fallback to disk if not in memory."""
        channel = dingtalk_channel_with_workspace

        # Create file manually
        store_path = temp_workspace_dir / "dingtalk_session_webhooks.json"
        data = {
            "dingtalk:sw:diskload": {
                "webhook": "http://disk.webhook",
                "expired_time": 9999999999999,
                "conversation_id": "conv_from_disk",
            },
        }
        with open(store_path, "w", encoding="utf-8") as f:
            json.dump(data, f)

        result = await channel._load_session_webhook("dingtalk:sw:diskload")

        assert result == "http://disk.webhook"
        # Should also be loaded into memory
        assert "dingtalk:sw:diskload" in channel._session_webhook_store

    async def test_load_session_webhook_not_found_returns_none(
        self,
        dingtalk_channel,
    ):
        """Loading non-existent webhook should return None."""
        result = await dingtalk_channel._load_session_webhook(
            "dingtalk:sw:nonexistent",
        )

        assert result is None

    async def test_load_session_webhook_empty_key_returns_none(
        self,
        dingtalk_channel,
    ):
        """Loading with empty key should return None."""
        result = await dingtalk_channel._load_session_webhook("")

        assert result is None

    async def test_save_session_webhook_empty_key_skips(
        self,
        dingtalk_channel,
    ):
        """Saving with empty key should be skipped."""
        await dingtalk_channel._save_session_webhook(
            webhook_key="",
            session_webhook="http://test",
        )

        assert "" not in dingtalk_channel._session_webhook_store

    async def test_save_session_webhook_empty_webhook_skips(
        self,
        dingtalk_channel,
    ):
        """Saving with empty webhook should be skipped."""
        await dingtalk_channel._save_session_webhook(
            webhook_key="key",
            session_webhook="",
        )

        assert "key" not in dingtalk_channel._session_webhook_store


# =============================================================================
# P1: Token Caching (HTTP Mock tests)
# =============================================================================


@pytest.mark.asyncio
class TestDingTalkTokenCache:
    """
    Tests for access token caching mechanism.

    DingTalk access tokens should be cached and only refreshed when expired.
    Uses asyncio loop time (monotonic) not wall clock time.
    """

    async def test_get_access_token_fetches_when_empty(
        self,
        dingtalk_channel,
    ):
        """Should fetch new token when cache is empty."""
        from qwenpaw.app.channels.dingtalk.constants import (
            DINGTALK_TOKEN_TTL_SECONDS,
        )

        dingtalk_channel._oauth_sdk = _make_oauth_sdk("new_token_123")

        token = await dingtalk_channel._get_access_token()

        assert token == "new_token_123"
        assert dingtalk_channel._token_value == "new_token_123"
        # Token expires in the future (uses loop time + TTL)
        assert (
            dingtalk_channel._token_expires_at
            > asyncio.get_running_loop().time()
        )
        assert dingtalk_channel._token_expires_at <= (
            asyncio.get_running_loop().time() + DINGTALK_TOKEN_TTL_SECONDS
        )

    async def test_get_access_token_uses_cache_when_valid(
        self,
        dingtalk_channel,
        mock_http_session,
    ):
        """Should use cached token when not expired."""
        dingtalk_channel._http = mock_http_session
        dingtalk_channel._token_value = "cached_token"
        dingtalk_channel._token_expires_at = (
            asyncio.get_running_loop().time() + 3600
        )  # Valid for 1 hour

        token = await dingtalk_channel._get_access_token()

        assert token == "cached_token"
        assert mock_http_session.call_count == 0  # No HTTP call made

    async def test_get_access_token_refreshes_when_expired(
        self,
        dingtalk_channel,
    ):
        """Should fetch new token when cached token is expired."""
        dingtalk_channel._oauth_sdk = _make_oauth_sdk("refreshed_token")
        dingtalk_channel._token_value = "old_token"
        dingtalk_channel._token_expires_at = (
            asyncio.get_running_loop().time() - 100
        )  # Expired

        token = await dingtalk_channel._get_access_token()

        assert token == "refreshed_token"
        assert dingtalk_channel._token_value == "refreshed_token"

    async def test_get_access_token_handles_api_error(
        self,
        dingtalk_channel,
    ):
        """Should handle API error gracefully."""
        sdk = AsyncMock()
        sdk.get_access_token_async = AsyncMock(
            side_effect=Exception("invalid credential"),
        )
        dingtalk_channel._oauth_sdk = sdk

        with pytest.raises(ChannelError, match="get accessToken failed"):
            await dingtalk_channel._get_access_token()

    async def test_get_access_token_thread_safe(
        self,
        dingtalk_channel,
    ):
        """Token fetching should be thread-safe (using asyncio.Lock)."""
        dingtalk_channel._oauth_sdk = _make_oauth_sdk("token_123")

        # Simulate concurrent calls
        tokens = await asyncio.gather(
            dingtalk_channel._get_access_token(),
            dingtalk_channel._get_access_token(),
            dingtalk_channel._get_access_token(),
        )

        # All should get the same token
        assert all(t == "token_123" for t in tokens)


# =============================================================================
# P1: Message Deduplication (Thread Safety)
# =============================================================================


class TestDingTalkMessageDedup:
    """
    Tests for message deduplication mechanism.

    DingTalk can deliver the same message multiple times.
    We track in-flight message IDs to prevent double-processing.
    """

    def test_try_accept_message_accepts_new_message(self, dingtalk_channel):
        """Should accept message with new ID."""
        result = dingtalk_channel._try_accept_message("msg_123")

        assert result is True
        assert "msg_123" in dingtalk_channel._processing_message_ids

    def test_try_accept_message_rejects_duplicate(self, dingtalk_channel):
        """Should reject message with duplicate ID."""
        # First accept
        dingtalk_channel._try_accept_message("msg_dup")

        # Second accept should fail
        result = dingtalk_channel._try_accept_message("msg_dup")

        assert result is False

    def test_try_accept_message_allows_empty_id(self, dingtalk_channel):
        """Empty message ID should be accepted but not tracked."""
        result = dingtalk_channel._try_accept_message("")

        assert result is True
        assert "" not in dingtalk_channel._processing_message_ids

    def test_release_message_ids_removes_from_set(self, dingtalk_channel):
        """Should remove message ID from tracking set."""
        dingtalk_channel._try_accept_message("msg_release")
        assert "msg_release" in dingtalk_channel._processing_message_ids

        dingtalk_channel._release_message_ids(["msg_release"])

        assert "msg_release" not in dingtalk_channel._processing_message_ids

    def test_release_message_ids_handles_empty_list(self, dingtalk_channel):
        """Should handle empty list gracefully."""
        initial_count = len(dingtalk_channel._processing_message_ids)
        dingtalk_channel._release_message_ids([])

        assert len(dingtalk_channel._processing_message_ids) == initial_count

    def test_release_message_ids_handles_unknown_ids(self, dingtalk_channel):
        """Should handle IDs not in set gracefully."""
        # Should not raise
        dingtalk_channel._release_message_ids(["unknown_id"])

    def test_try_accept_message_is_thread_safe(self, dingtalk_channel):
        """Deduplication should be thread-safe."""
        accepted_count = [0]
        rejected_count = [0]

        def try_accept():
            for i in range(100):
                msg_id = f"batch_msg_{i % 10}"  # 10 unique IDs, 10 times each
                if dingtalk_channel._try_accept_message(msg_id):
                    accepted_count[0] += 1
                else:
                    rejected_count[0] += 1

        threads = [threading.Thread(target=try_accept) for _ in range(5)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        # Should have exactly 10 accepted (one for each unique ID)
        # and 490 rejected (99 duplicates per ID * 5 threads, but some race)
        assert accepted_count[0] == 10


# =============================================================================
# P1: Send Methods (HTTP Mock Tests)
# =============================================================================


@pytest.mark.asyncio
class TestDingTalkSendMethods:
    """
    Tests for send methods using HTTP mocking.

    Covers:
    - send() method
    - _send_via_session_webhook()
    - _send_via_open_api()
    - _send_payload_via_session_webhook()
    """

    async def test_send_via_session_webhook_success(
        self,
        dingtalk_channel,
        mock_http_session,
    ):
        """Successfully send via session webhook."""
        dingtalk_channel._http = mock_http_session
        mock_http_session.expect_post(
            url="https://oapi.dingtalk.com/robot/send",
            response_status=200,
            response_json={"errcode": 0, "errmsg": "ok"},
        )

        result = await dingtalk_channel._send_via_session_webhook(
            session_webhook="https://oapi.dingtalk.com/robot/send?session=abc",
            body="Hello from test",
            bot_prefix="[Bot]",
        )

        assert result is True
        assert mock_http_session.call_count == 1

    async def test_send_via_session_webhook_api_error(
        self,
        dingtalk_channel,
    ):
        """Handle API error response with non-zero errcode."""

        # Mock the response
        class MockResponse:
            status = 200

            async def text(self):
                return '{"errcode": 400001, "errmsg": "invalid session"}'

        class MockClientSession:
            async def __aenter__(self):
                return MockResponse()

            async def __aexit__(self, *args):
                pass

        dingtalk_channel._http = MagicMock()
        dingtalk_channel._http.post = MockClientSession

        result = await dingtalk_channel._send_payload_via_session_webhook(
            session_webhook="https://oapi.dingtalk.com/robot/send?session=abc",
            payload={"msgtype": "text", "text": {"content": "Hello"}},
        )

        assert result is False

    async def test_send_via_session_webhook_http_error(
        self,
        dingtalk_channel,
        mock_http_session,
    ):
        """Handle HTTP error response."""
        dingtalk_channel._http = mock_http_session
        mock_http_session.expect_post(
            url="https://oapi.dingtalk.com/robot/send",
            response_status=500,
            response_text="Internal Server Error",
        )

        result = await dingtalk_channel._send_via_session_webhook(
            session_webhook="https://oapi.dingtalk.com/robot/send?session=abc",
            body="Hello",
        )

        assert result is False

    async def test_send_payload_via_session_webhook_success(
        self,
        dingtalk_channel,
        mock_http_session,
    ):
        """Send custom payload via session webhook."""
        dingtalk_channel._http = mock_http_session
        mock_http_session.expect_post(
            url="https://oapi.dingtalk.com/robot/send",
            response_status=200,
            response_json={"errcode": 0, "errmsg": "ok"},
        )

        payload = {
            "msgtype": "markdown",
            "markdown": {"title": "Test", "text": "Hello"},
        }
        result = await dingtalk_channel._send_payload_via_session_webhook(
            session_webhook="https://oapi.dingtalk.com/robot/send?session=abc",
            payload=payload,
        )

        assert result is True

    async def test_send_via_open_api_group_success(
        self,
        dingtalk_channel,
    ):
        """Send via Open API for group chat."""
        dingtalk_channel._oauth_sdk = _make_oauth_sdk("token_123")
        dingtalk_channel._robot_sdk = _make_robot_sdk()

        result = await dingtalk_channel._send_via_open_api(
            body="Hello group",
            conversation_id="cid_group_123",
            conversation_type="group",
            sender_staff_id="",
        )

        assert result is True
        sdk = dingtalk_channel._robot_sdk
        sdk.org_group_send_with_options_async.assert_called_once()

    async def test_send_via_open_api_dm_success(
        self,
        dingtalk_channel,
    ):
        """Send via Open API for direct message."""
        dingtalk_channel._oauth_sdk = _make_oauth_sdk("token_123")
        dingtalk_channel._robot_sdk = _make_robot_sdk()

        result = await dingtalk_channel._send_via_open_api(
            body="Hello DM",
            conversation_id="cid_dm_123",
            conversation_type="single",
            sender_staff_id="staff_123",
        )

        assert result is True
        sdk = dingtalk_channel._robot_sdk
        sdk.batch_send_otowith_options_async.assert_called_once()

    async def test_send_via_open_api_dm_no_staff_id_fails(
        self,
        dingtalk_channel,
    ):
        """DM should fail without sender_staff_id."""
        dingtalk_channel._oauth_sdk = _make_oauth_sdk("token_123")
        dingtalk_channel._robot_sdk = _make_robot_sdk()

        result = await dingtalk_channel._send_via_open_api(
            body="Hello DM",
            conversation_id="cid_dm_123",
            conversation_type="single",
            sender_staff_id="",  # Empty staff ID
        )

        assert result is False

    async def test_send_disabled_channel_does_nothing(self, dingtalk_channel):
        """Send should return early when channel is disabled."""
        dingtalk_channel.enabled = False

        result = await dingtalk_channel.send(
            to_handle="user123",
            text="Hello",
            meta={},
        )

        # Should not raise, just return
        assert result is None


# =============================================================================
# P2: resolve_session_id and Routing
# =============================================================================


class TestDingTalkResolveSession:
    """Tests for session resolution and routing."""

    def test_resolve_session_id_with_conversation_id(self, dingtalk_channel):
        """resolve_session_id should use conversation_id when available."""
        result = dingtalk_channel.resolve_session_id(
            sender_id="user123",
            channel_meta={"conversation_id": "cid_abc_xyz"},
        )

        # Takes last 8 chars: DINGTALK_SESSION_ID_SUFFIX_LEN = 8
        assert result == "_abc_xyz"

    def test_resolve_session_id_without_conversation_id(
        self,
        dingtalk_channel,
    ):
        """resolve_session_id should fallback to sender_id format."""
        result = dingtalk_channel.resolve_session_id(
            sender_id="user456",
            channel_meta={},
        )

        assert result == "dingtalk:user456"

    def test_to_handle_from_target_formats_correctly(self, dingtalk_channel):
        """to_handle_from_target should include user_id prefix for DM."""
        result = dingtalk_channel.to_handle_from_target(
            user_id="user123",
            session_id="sess_abc",
        )
        assert result == "dingtalk:sw:user123_sess_abc"

        # Without user_id, falls back to suffix-only key (group chat)
        result_no_user = dingtalk_channel.to_handle_from_target(
            user_id="",
            session_id="sess_abc",
        )
        assert result_no_user == "dingtalk:sw:sess_abc"

    def test_route_from_handle_sw(self, dingtalk_channel):
        """_route_from_handle should parse 'dingtalk:sw:' format."""
        result = dingtalk_channel._route_from_handle("dingtalk:sw:abc123")

        assert result == {"webhook_key": "dingtalk:sw:abc123"}

    def test_route_from_handle_webhook(self, dingtalk_channel):
        """_route_from_handle should parse 'dingtalk:webhook:' format."""
        result = dingtalk_channel._route_from_handle(
            "dingtalk:webhook:http://webhook.url",
        )

        assert result == {"session_webhook": "http://webhook.url"}

    def test_route_from_handle_direct_url(self, dingtalk_channel):
        """_route_from_handle should accept direct webhook URL."""
        result = dingtalk_channel._route_from_handle(
            "https://oapi.dingtalk.com/robot",
        )

        assert result == {"session_webhook": "https://oapi.dingtalk.com/robot"}

    def test_route_from_handle_empty(self, dingtalk_channel):
        """_route_from_handle should handle empty string."""
        result = dingtalk_channel._route_from_handle("")

        assert result == {}


# =============================================================================
# P2: Open API Fallback (Critical for CHAN-D02)
# =============================================================================


@pytest.mark.asyncio
class TestDingTalkOpenAPIFallback:
    """
    Tests for Open API fallback when sessionWebhook is expired.

    CHAN-D02: Cron jobs should still work after webhook expires
    """

    async def test_try_open_api_fallback_with_stored_webhook(
        self,
        dingtalk_channel_with_workspace,
    ):
        """Fallback should use stored webhook entry for metadata."""
        channel = dingtalk_channel_with_workspace
        channel._oauth_sdk = _make_oauth_sdk("token_123")
        channel._robot_sdk = _make_robot_sdk()

        # Store webhook entry
        channel._session_webhook_store["dingtalk:sw:storedkey"] = {
            "webhook": "http://stored.webhook",
            "conversation_id": "stored_conv_id",
            "conversation_type": "group",
            "sender_staff_id": "stored_staff",
        }

        result = await channel._try_open_api_fallback(
            text="Fallback message",
            to_handle="dingtalk:sw:storedkey",
            meta={},
        )

        assert result is True

    async def test_try_open_api_fallback_no_conversation_id(
        self,
        dingtalk_channel,
    ):
        """Fallback should fail without conversation_id."""
        result = await dingtalk_channel._try_open_api_fallback(
            text="Test",
            to_handle="dingtalk:sw:unknown",
            meta={},
        )

        assert result is False

    def test_resolve_open_api_params_from_meta(self, dingtalk_channel):
        """Should extract params from meta with priority."""
        meta = {
            "conversation_id": "meta_conv",
            "conversation_type": "meta_type",
            "sender_staff_id": "meta_staff",
        }
        entry = {
            "conversation_id": "entry_conv",
            "conversation_type": "entry_type",
            "sender_staff_id": "entry_staff",
        }

        result = dingtalk_channel._resolve_open_api_params(meta, entry)

        # Meta takes priority
        assert result["conversation_id"] == "meta_conv"
        assert result["conversation_type"] == "meta_type"
        assert result["sender_staff_id"] == "meta_staff"

    def test_resolve_open_api_params_from_entry(self, dingtalk_channel):
        """Should fallback to entry when meta is empty."""
        meta = {}
        entry = {
            "conversation_id": "entry_conv",
            "conversation_type": "entry_type",
            "sender_staff_id": "entry_staff",
        }

        result = dingtalk_channel._resolve_open_api_params(meta, entry)

        assert result["conversation_id"] == "entry_conv"
        assert result["conversation_type"] == "entry_type"
        assert result["sender_staff_id"] == "entry_staff"


# =============================================================================
# P2: Parts to Text Conversion
# =============================================================================


class TestDingTalkPartsToText:
    """Tests for _parts_to_single_text method."""

    def test_parts_to_single_text_with_text(self, dingtalk_channel):
        """Should combine text parts."""
        from qwenpaw.app.channels.base import TextContent, ContentType

        parts = [
            TextContent(type=ContentType.TEXT, text="Hello"),
            TextContent(type=ContentType.TEXT, text="World"),
        ]

        result = dingtalk_channel._parts_to_single_text(parts)

        assert "Hello" in result
        assert "World" in result

    def test_parts_to_single_text_with_prefix(self, dingtalk_channel):
        """Should include bot_prefix."""
        from qwenpaw.app.channels.base import TextContent, ContentType

        parts = [TextContent(type=ContentType.TEXT, text="Message")]

        result = dingtalk_channel._parts_to_single_text(
            parts,
            bot_prefix="[Bot]",
        )

        assert "[Bot]" in result
        assert "Message" in result

    def test_parts_to_single_text_with_refusal(self, dingtalk_channel):
        """Should handle refusal content."""
        from qwenpaw.app.channels.base import RefusalContent, ContentType

        parts = [RefusalContent(type=ContentType.REFUSAL, refusal="I cannot")]

        result = dingtalk_channel._parts_to_single_text(parts)

        assert "I cannot" in result

    def test_parts_to_single_text_with_image(self, dingtalk_channel):
        """Media parts should be skipped (delivered separately)."""
        from qwenpaw.app.channels.base import ImageContent, ContentType

        parts = [
            ImageContent(type=ContentType.IMAGE, image_url="http://img.jpg"),
        ]

        result = dingtalk_channel._parts_to_single_text(parts)

        assert result == ""

    def test_parts_to_single_text_empty_list(self, dingtalk_channel):
        """Should handle empty parts list."""
        result = dingtalk_channel._parts_to_single_text([])

        assert result == ""


# =============================================================================
# P2: Session Webhook from Meta
# =============================================================================


class TestDingTalkGetSessionWebhook:
    """Tests for _get_session_webhook method."""

    def test_get_session_webhook_from_meta(self, dingtalk_channel):
        """Should get webhook from meta dict."""
        result = dingtalk_channel._get_session_webhook(
            {"session_webhook": "http://meta.webhook"},
        )

        assert result == "http://meta.webhook"

    def test_get_session_webhook_none_meta(self, dingtalk_channel):
        """Should handle None meta."""
        result = dingtalk_channel._get_session_webhook(None)

        assert result is None

    def test_get_session_webhook_empty_meta(self, dingtalk_channel):
        """Should handle empty meta."""
        result = dingtalk_channel._get_session_webhook({})

        assert result is None


# =============================================================================
# P2: Build Agent Request
# =============================================================================


class TestDingTalkBuildAgentRequest:
    """Tests for build_agent_request_from_native method."""

    def test_build_agent_request_creates_request(self, dingtalk_channel):
        """Should create AgentRequest from native payload."""
        from qwenpaw.app.channels.base import TextContent, ContentType

        payload = {
            "channel_id": "dingtalk",
            "sender_id": "user123",
            "content_parts": [
                TextContent(type=ContentType.TEXT, text="Hello"),
            ],
            "meta": {"session_webhook": "http://webhook.url"},
        }

        request = dingtalk_channel.build_agent_request_from_native(payload)

        assert request.user_id == "user123"
        assert request.channel == "dingtalk"
        assert len(request.input) == 1


# =============================================================================
# P2: Reply Sync Methods
# =============================================================================


# =============================================================================
# P2: Utility Functions
# =============================================================================


class TestDingTalkUtils:
    """Tests for utility functions in utils.py."""

    def test_guess_suffix_from_file_content_pdf(self, tmp_path):
        """Should detect PDF files by magic bytes."""
        from qwenpaw.app.channels.dingtalk.utils import (
            guess_suffix_from_file_content,
        )

        pdf_file = tmp_path / "test.pdf"
        pdf_file.write_bytes(b"%PDF-1.4 test content")

        result = guess_suffix_from_file_content(pdf_file)

        assert result == ".pdf"

    def test_guess_suffix_from_file_content_png(self, tmp_path):
        """Should detect PNG files by magic bytes."""
        from qwenpaw.app.channels.dingtalk.utils import (
            guess_suffix_from_file_content,
        )

        png_file = tmp_path / "test.png"
        png_file.write_bytes(b"\x89PNG\r\n\x1a\n test content")

        result = guess_suffix_from_file_content(png_file)

        assert result == ".png"

    def test_guess_suffix_from_file_content_jpg(self, tmp_path):
        """Should detect JPG files by magic bytes."""
        from qwenpaw.app.channels.dingtalk.utils import (
            guess_suffix_from_file_content,
        )

        jpg_file = tmp_path / "test.jpg"
        jpg_file.write_bytes(b"\xff\xd8\xff test content")

        result = guess_suffix_from_file_content(jpg_file)

        assert result == ".jpg"

    def test_guess_suffix_from_file_content_unknown(self, tmp_path):
        """Should return None for unknown file types."""
        from qwenpaw.app.channels.dingtalk.utils import (
            guess_suffix_from_file_content,
        )

        unknown_file = tmp_path / "test.unknown"
        unknown_file.write_bytes(
            b"unknown content that doesn't match any magic",
        )

        result = guess_suffix_from_file_content(unknown_file)

        assert result is None

    def test_guess_suffix_from_nonexistent_file(self, tmp_path):
        """Should handle non-existent file."""
        from qwenpaw.app.channels.dingtalk.utils import (
            guess_suffix_from_file_content,
        )

        result = guess_suffix_from_file_content(tmp_path / "nonexistent.bin")

        assert result is None


# =============================================================================
# P2: Media Upload (HTTP Mock)
# =============================================================================


@pytest.mark.asyncio
class TestDingTalkMediaUpload:
    """Tests for media upload functionality."""

    async def test_upload_media_success(
        self,
        dingtalk_channel,
        mock_http_session,
    ):
        """Successfully upload media file."""
        dingtalk_channel._oauth_sdk = _make_oauth_sdk("token_123")
        dingtalk_channel._http = mock_http_session

        # Upload media via HTTP (oapi endpoint)
        mock_http_session.expect_post(
            url="https://oapi.dingtalk.com/media/upload",
            response_status=200,
            response_json={
                "errcode": 0,
                "media_id": "media_abc123",
            },
        )

        result = await dingtalk_channel._upload_media(
            data=b"file content",
            media_type="image",
            filename="test.jpg",
        )

        assert result == "media_abc123"

    async def test_upload_media_api_error(
        self,
        dingtalk_channel,
        mock_http_session,
    ):
        """Handle media upload API error."""
        dingtalk_channel._oauth_sdk = _make_oauth_sdk("token_123")
        dingtalk_channel._http = mock_http_session

        # Upload call returns error
        mock_http_session.expect_post(
            url="https://oapi.dingtalk.com/media/upload",
            response_status=200,
            response_json={"errcode": 40001, "errmsg": "upload failed"},
        )

        result = await dingtalk_channel._upload_media(
            data=b"file content",
            media_type="image",
            filename="test.jpg",
        )

        assert result is None

    async def test_upload_media_http_error(
        self,
        dingtalk_channel,
        mock_http_session,
    ):
        """Handle media upload HTTP error."""
        dingtalk_channel._oauth_sdk = _make_oauth_sdk("token_123")
        dingtalk_channel._http = mock_http_session

        # Upload call returns HTTP error
        mock_http_session.expect_post(
            url="https://oapi.dingtalk.com/media/upload",
            response_status=500,
            response_text="Internal Server Error",
        )

        result = await dingtalk_channel._upload_media(
            data=b"file content",
            media_type="image",
            filename="test.jpg",
        )

        assert result is None


# =============================================================================
# P2: AI Card Store
# =============================================================================


class TestDingTalkAICardStore:
    """Tests for AICardPendingStore."""

    def test_load_empty_store(self, tmp_path):
        """Loading from non-existent file returns empty list."""
        from qwenpaw.app.channels.dingtalk.ai_card import AICardPendingStore

        store = AICardPendingStore(tmp_path / "nonexistent.json")
        result = store.load()

        assert result == []

    def test_load_existing_cards(self, tmp_path):
        """Loading from existing file returns cards."""
        from qwenpaw.app.channels.dingtalk.ai_card import AICardPendingStore

        card_file = tmp_path / "cards.json"
        card_file.write_text(
            json.dumps(
                {
                    "version": 1,
                    "pending_cards": [
                        {"account_id": "user1", "card_instance_id": "card1"},
                        {"account_id": "user2", "card_instance_id": "card2"},
                    ],
                },
            ),
            encoding="utf-8",
        )

        store = AICardPendingStore(card_file)
        result = store.load()

        assert len(result) == 2
        assert result[0]["account_id"] == "user1"

    def test_save_cards(self, tmp_path):
        """Saving cards writes to file."""
        from qwenpaw.app.channels.dingtalk.ai_card import (
            AICardPendingStore,
            ActiveAICard,
        )

        store = AICardPendingStore(tmp_path / "cards.json")

        cards = {
            "card1": ActiveAICard(
                card_instance_id="card1",
                access_token="token123",
                conversation_id="conv1",
                account_id="user1",
                store_path="/tmp/card1",
                created_at=1234567890,
                last_updated=1234567890,
                state="2",  # INPUTING
            ),
        }

        store.save(cards)

        saved_file = tmp_path / "cards.json"
        assert saved_file.exists()

        data = json.loads(saved_file.read_text(encoding="utf-8"))
        assert data["version"] == 1
        assert len(data["pending_cards"]) == 1
        # access_token should be stripped
        assert "access_token" not in data["pending_cards"][0]

    def test_save_skips_terminal_states(self, tmp_path):
        """Saving should skip cards in terminal states."""
        from qwenpaw.app.channels.dingtalk.ai_card import (
            AICardPendingStore,
            ActiveAICard,
            FINISHED,
            FAILED,
        )

        store = AICardPendingStore(tmp_path / "cards.json")

        cards = {
            "finished_card": ActiveAICard(
                card_instance_id="finished_card",
                access_token="token",
                conversation_id="conv1",
                account_id="user1",
                store_path="/tmp",
                created_at=1234567890,
                last_updated=1234567890,
                state=FINISHED,
            ),
            "failed_card": ActiveAICard(
                card_instance_id="failed_card",
                access_token="token",
                conversation_id="conv2",
                account_id="user2",
                store_path="/tmp",
                created_at=1234567890,
                last_updated=1234567890,
                state=FAILED,
            ),
            "active_card": ActiveAICard(
                card_instance_id="active_card",
                access_token="token",
                conversation_id="conv3",
                account_id="user3",
                store_path="/tmp",
                created_at=1234567890,
                last_updated=1234567890,
                state="2",  # INPUTING
            ),
        }

        store.save(cards)

        data = json.loads(
            (tmp_path / "cards.json").read_text(encoding="utf-8"),
        )
        assert len(data["pending_cards"]) == 1
        assert data["pending_cards"][0]["card_instance_id"] == "active_card"


# =============================================================================
# P2: Ack Early (Streaming)
# =============================================================================


# =============================================================================
# Additional Edge Case Tests
# =============================================================================


# =============================================================================
# P1: Workspace Integration Tests (Mock Workspace)
# =============================================================================


# Helper function for async empty generator
async def async_empty_generator():
    """Helper to create async empty generator."""
    return
    yield  # Make it a generator


@pytest.mark.asyncio
class TestDingTalkWorkspaceIntegration:
    """
    Tests requiring mock workspace.

    Covers _consume_with_tracker, _stream_with_tracker, and
    integration with ChatManager and TaskTracker.
    """

    async def _mock_stream_from_queue(self, *args, **kwargs):
        """Async generator for stream_from_queue mock."""
        if False:  # Never yields, just for async generator type
            yield None

    @pytest.fixture
    def mock_workspace(self):
        """Create a fully mocked workspace."""
        workspace = MagicMock()

        # Mock chat_manager
        chat_manager = AsyncMock()
        mock_chat = MagicMock()
        mock_chat.id = "chat_123"
        chat_manager.get_or_create_chat.return_value = mock_chat
        workspace.chat_manager = chat_manager

        # Mock task_tracker - use simple MagicMock,
        task_tracker = MagicMock()
        workspace.task_tracker = task_tracker

        return workspace

    @pytest.fixture
    def dingtalk_with_workspace(self, dingtalk_channel, mock_workspace):
        """Channel with mock workspace set."""
        dingtalk_channel.set_workspace(mock_workspace)
        return dingtalk_channel

    async def test_stream_with_tracker_yields_sse_events(
        self,
        dingtalk_with_workspace,
    ):
        """_stream_with_tracker should yield SSE formatted events."""
        from qwenpaw.schemas import (
            RunStatus,
            Event,
            Message,
            MessageType,
            Role,
            TextContent,
            ContentType,
        )

        mock_event = Event(
            object="message",
            status=RunStatus.Completed,
            type="message.completed",
            id="ev-1",
            created_at=1234567890,
            message=Message(
                type=MessageType.MESSAGE,
                role=Role.ASSISTANT,
                content=[TextContent(type=ContentType.TEXT, text="Hello")],
            ),
        )

        async def mock_process(request):
            yield mock_event

        dingtalk_with_workspace._process = mock_process

        payload = {
            "sender_id": "user123",
            "content_parts": [
                TextContent(type=ContentType.TEXT, text="Query"),
            ],
        }

        events = []
        async for event in dingtalk_with_workspace._stream_with_tracker(
            payload,
        ):
            events.append(event)
            break  # Just check first event

        assert len(events) == 1
        assert "data:" in events[0]


@pytest.mark.asyncio
class TestDingTalkSendContentParts:
    """
    Tests for send_content_parts method.

    DingTalk-specific behavior for sending various content types.
    """

    async def test_send_content_parts_empty_parts_skipped(
        self,
        dingtalk_channel,
    ):
        """Empty parts list should not send anything."""
        with patch.object(
            dingtalk_channel,
            "send",
            new_callable=AsyncMock,
        ) as mock_send:
            await dingtalk_channel.send_content_parts(
                to_handle="user123",
                parts=[],
                meta={},
            )

            mock_send.assert_not_called()

    async def test_send_content_parts_whitespace_text_skipped(
        self,
        dingtalk_channel,
    ):
        """Whitespace-only text should not send a prefix-only message."""
        from qwenpaw.app.channels.base import TextContent, ContentType

        parts = [TextContent(type=ContentType.TEXT, text="   \n\t")]

        with patch.object(
            dingtalk_channel,
            "send",
            new_callable=AsyncMock,
        ) as mock_send:
            await dingtalk_channel.send_content_parts(
                to_handle="dingtalk:sw:test",
                parts=parts,
                meta={"bot_prefix": "[TestBot] "},
            )

            mock_send.assert_not_called()

    async def test_send_content_parts_text_failure_logs_without_strict_meta(
        self,
        dingtalk_channel,
    ):
        """Conversation replies should not fail the whole agent pipeline."""
        from qwenpaw.app.channels.base import TextContent, ContentType

        parts = [TextContent(type=ContentType.TEXT, text="Hello")]

        with (
            patch.object(
                dingtalk_channel,
                "_send_via_session_webhook",
                new_callable=AsyncMock,
                return_value=False,
            ) as mock_webhook,
            patch.object(
                dingtalk_channel,
                "_try_open_api_fallback",
                new_callable=AsyncMock,
                return_value=False,
            ) as mock_fallback,
        ):
            # Should complete without raising even when both hops fail.
            await dingtalk_channel.send_content_parts(
                to_handle="dingtalk:sw:test",
                parts=parts,
                meta={"session_webhook": "http://webhook.url"},
            )

            # Verify the full webhook -> Open API fallback chain ran.
            mock_webhook.assert_awaited()
            mock_fallback.assert_awaited()

    async def test_send_content_parts_text_failure_raises_for_api_send(
        self,
        dingtalk_channel,
    ):
        """Explicit API delivery failure should surface to callers."""
        from qwenpaw.app.channels.base import TextContent, ContentType

        parts = [TextContent(type=ContentType.TEXT, text="Hello")]

        with (
            patch.object(
                dingtalk_channel,
                "_send_via_session_webhook",
                new_callable=AsyncMock,
                return_value=False,
            ),
            patch.object(
                dingtalk_channel,
                "_try_open_api_fallback",
                new_callable=AsyncMock,
                return_value=False,
            ),
        ):
            with pytest.raises(ChannelError, match="Open API fallback"):
                await dingtalk_channel.send_content_parts(
                    to_handle="dingtalk:sw:test",
                    parts=parts,
                    meta={
                        "_api_send": True,
                        "session_webhook": "http://webhook.url",
                    },
                )

    async def test_send_content_parts_with_file(
        self,
        dingtalk_channel,
        mock_http_session,
        tmp_path,
    ):
        """Send with file content uploads file and sends via webhook."""
        dingtalk_channel._http = mock_http_session

        from qwenpaw.app.channels.base import FileContent, ContentType

        # Create a test file
        test_file = tmp_path / "test.pdf"
        test_file.write_bytes(b"%PDF-1.4 test content")

        with patch.object(
            dingtalk_channel,
            "_upload_media",
            return_value="media_file_123",
        ) as mock_upload:
            with patch.object(
                dingtalk_channel,
                "_send_payload_via_session_webhook",
                return_value=True,
            ):
                parts = [
                    FileContent(
                        type=ContentType.FILE,
                        file_url=str(test_file),
                    ),
                ]

                await dingtalk_channel.send_content_parts(
                    to_handle="user123",
                    parts=parts,
                    meta={"session_webhook": "http://webhook.url"},
                )

                mock_upload.assert_called_once()


# =============================================================================
# Additional Edge Case Tests
# =============================================================================


@pytest.mark.asyncio
class TestDingTalkEdgeCases:
    """Additional edge case tests."""

    async def test_start_disabled_channel(self, dingtalk_channel):
        """Starting disabled channel should succeed without action."""
        dingtalk_channel.enabled = False

        # Should not raise
        await dingtalk_channel.start()

    async def test_stop_disabled_channel(self, dingtalk_channel):
        """Stopping disabled channel should succeed without action."""
        dingtalk_channel.enabled = False

        # Should not raise
        await dingtalk_channel.stop()

    async def test_stop_without_start(self, dingtalk_channel):
        """Stopping without prior start should succeed."""
        # Should not raise
        await dingtalk_channel.stop()


# =============================================================================
# P1: Callback Handler Tests
# =============================================================================


@pytest.mark.asyncio
class TestDingTalkCallbackHandler:
    """Tests for DingTalk callback handler (handler.py)."""

    @pytest.fixture
    def mock_download_fetcher(self):
        """Mock download URL fetcher."""

        async def fetcher(*, download_code, robot_code, filename_hint):
            return f"http://download.url/{download_code}"

        return fetcher

    @pytest.fixture
    def handler(self, mock_download_fetcher, mock_process_handler):
        """Create a DingTalkChannelHandler instance."""
        from qwenpaw.app.channels.dingtalk.handler import (
            DingTalkChannelHandler,
        )

        loop = asyncio.new_event_loop()
        handler = DingTalkChannelHandler(
            main_loop=loop,
            enqueue_callback=MagicMock(),
            bot_prefix="[Test] ",
            download_url_fetcher=mock_download_fetcher,
            try_accept_message=lambda msg_id: True,
        )
        yield handler
        loop.close()

    @pytest.fixture
    def mock_callback_message(self):
        """Create a mock CallbackMessage."""
        callback = MagicMock()
        callback.data = {
            "msgId": "test_msg_123",
            "senderStaffId": "user123",
            "conversationId": "cid_group_abc",
            "conversationType": "2",  # Group chat
            "text": {"content": "Hello, this is a test message"},
            "isInAtList": True,
            "sessionWebhook": "https://oapi.dingtalk.com/robot/send",
            "sessionWebhookExpiredTime": 1893456000000,
        }
        return callback

    @pytest.fixture
    def mock_incoming_message(self):
        """Create a mock ChatbotMessage."""
        msg = MagicMock()
        msg.text.content = "Hello, this is a test message"
        msg.sender_staff_id = "user123"
        msg.senderStaffId = "user123"
        msg.conversation_id = "cid_group_abc"
        msg.conversation_type = "2"
        msg.sessionWebhook = "https://oapi.dingtalk.com/robot/send?session=abc"
        msg.sessionWebhookExpiredTime = 1893456000000
        msg.robot_code = "robot_123"
        msg.robotCode = "robot_123"
        msg.to_dict.return_value = {
            "text": {"content": "Hello, this is a test message"},
        }
        return msg

    def test_extract_filename_hint(self, handler):
        """Should extract filename from various payload formats."""
        # Test fileName key
        payload = {"fileName": "test.pdf"}
        result = handler._extract_filename_hint(payload)
        assert result == "test.pdf"

        # Test file_name key
        payload = {"file_name": "test2.pdf"}
        result = handler._extract_filename_hint(payload)
        assert result == "test2.pdf"

        # Test filename key
        payload = {"filename": "test3.pdf"}
        result = handler._extract_filename_hint(payload)
        assert result == "test3.pdf"

        # Test name key
        payload = {"name": "test4.pdf"}
        result = handler._extract_filename_hint(payload)
        assert result == "test4.pdf"

        # Test title key
        payload = {"title": "test5.pdf"}
        result = handler._extract_filename_hint(payload)
        assert result == "test5.pdf"

        # Test empty/whitespace value
        payload = {"fileName": "   "}
        result = handler._extract_filename_hint(payload)
        assert result is None

        # Test no valid key
        payload = {"other": "value"}
        result = handler._extract_filename_hint(payload)
        assert result is None

    def test_emit_native_threadsafe(self, handler):
        """Should emit native message via callback."""
        handler._enqueue_callback = MagicMock()
        handler._main_loop.call_soon_threadsafe = MagicMock()
        handler._emit_native_threadsafe({"test": "data"})

        assert handler._main_loop.call_soon_threadsafe.called

    def test_handler_require_mention_flag(self, mock_download_fetcher):
        """Handler should store require_mention flag."""
        from qwenpaw.app.channels.dingtalk.handler import (
            DingTalkChannelHandler,
        )

        loop = asyncio.new_event_loop()
        handler = DingTalkChannelHandler(
            main_loop=loop,
            enqueue_callback=MagicMock(),
            bot_prefix="[Test] ",
            download_url_fetcher=mock_download_fetcher,
            require_mention=True,
        )
        assert handler._require_mention is True
        loop.close()


@pytest.mark.asyncio
class TestDingTalkConsumeErrorHandling:
    """Tests for _on_consume_error hook."""

    async def test_on_consume_error_sends_emoji_and_message(
        self,
        dingtalk_channel,
    ):
        """Should recall thinking, send error emoji and text."""
        dingtalk_channel._robot_sdk = MagicMock()
        dingtalk_channel._get_access_token = AsyncMock(
            return_value="token_123",
        )

        with (
            patch.object(
                dingtalk_channel,
                "_send_emotion",
                new_callable=AsyncMock,
            ) as mock_emotion,
            patch.object(
                dingtalk_channel,
                "_send_via_session_webhook",
                new_callable=AsyncMock,
            ) as mock_webhook,
        ):
            request = MagicMock()
            request.channel_meta = {
                "message_id": "msg_123",
                "conversation_id": "cid_123",
                "session_webhook": "http://webhook.url",
            }

            await dingtalk_channel._on_consume_error(
                request,
                "dingtalk:sw:test",
                "Something went wrong",
            )

            # Should recall thinking and send error emoji
            assert mock_emotion.call_count == 2
            assert mock_emotion.call_args_list[0][0] == (
                "msg_123",
                "cid_123",
                "🤔Thinking",
            )
            assert mock_emotion.call_args_list[1][0] == (
                "msg_123",
                "cid_123",
                "☹️Error",
            )
            # Should send error text via webhook
            mock_webhook.assert_called_once()

    async def test_on_consume_error_releases_dedup(
        self,
        dingtalk_channel,
    ):
        """_on_consume_error should release message IDs for dedup."""
        with (
            patch.object(
                dingtalk_channel,
                "_send_emotion",
                new_callable=AsyncMock,
            ),
            patch.object(
                dingtalk_channel,
                "_release_message_ids",
            ) as mock_release,
        ):
            request = MagicMock()
            request.channel_meta = {
                "message_id": "msg_123",
                "conversation_id": "cid_123",
                "_message_ids": ["msg_123", "msg_456"],
            }

            await dingtalk_channel._on_consume_error(
                request,
                "dingtalk:sw:test",
                "Error",
            )

            mock_release.assert_called_once_with(["msg_123", "msg_456"])


# =============================================================================
# P2: AI Card Tests (Stream Mode)
# =============================================================================


@pytest.mark.asyncio
class TestDingTalkAICardMethods:
    """Tests for AI Card streaming methods."""

    async def test_create_ai_card_success(
        self,
        dingtalk_channel,
    ):
        """Successfully create AI card."""
        # Configure channel for AI card
        dingtalk_channel.message_type = "card"
        dingtalk_channel.card_template_id = "template_123"
        dingtalk_channel.card_template_key = "content"
        dingtalk_channel.robot_code = "robot_123"
        dingtalk_channel.card_auto_layout = True
        dingtalk_channel._oauth_sdk = _make_oauth_sdk("token_123")
        dingtalk_channel._card_sdk = _make_card_sdk()

        card = await dingtalk_channel._create_ai_card(
            conversation_id="cid_test_123",
            meta={"sender_staff_id": "user123", "is_group": True},
        )

        assert card is not None
        assert card.conversation_id == "cid_test_123"
        assert card.card_instance_id.startswith("card_")

    async def test_create_ai_card_group_conversation(
        self,
        dingtalk_channel,
    ):
        """Create AI card for group conversation."""
        dingtalk_channel.message_type = "card"
        dingtalk_channel.card_template_id = "template_123"
        dingtalk_channel.card_template_key = "content"
        dingtalk_channel.robot_code = "robot_123"
        dingtalk_channel._oauth_sdk = _make_oauth_sdk("token_123")
        dingtalk_channel._card_sdk = _make_card_sdk()

        card = await dingtalk_channel._create_ai_card(
            conversation_id="cid_group_123",
            meta={"is_group": True},
        )

        assert card is not None

    async def test_create_ai_card_dm_requires_staff_id(
        self,
        dingtalk_channel,
    ):
        """DM card creation requires sender_staff_id."""
        dingtalk_channel.message_type = "card"
        dingtalk_channel.card_template_id = "template_123"
        dingtalk_channel.card_template_key = "content"
        dingtalk_channel.robot_code = "robot_123"
        dingtalk_channel._oauth_sdk = _make_oauth_sdk("token_123")
        dingtalk_channel._card_sdk = _make_card_sdk()

        with pytest.raises(ChannelError, match="missing sender_staff_id"):
            await dingtalk_channel._create_ai_card(
                conversation_id="cid_single_123",
                meta={"is_group": False},
            )

    async def test_create_ai_card_api_error(
        self,
        dingtalk_channel,
    ):
        """Handle API error when creating card."""
        dingtalk_channel.message_type = "card"
        dingtalk_channel.card_template_id = "template_123"
        dingtalk_channel.robot_code = "robot_123"
        dingtalk_channel._oauth_sdk = _make_oauth_sdk("token_123")
        card_sdk = _make_card_sdk()
        card_sdk.create_card_with_options_async = AsyncMock(
            side_effect=Exception("invalid_template"),
        )
        dingtalk_channel._card_sdk = card_sdk

        with pytest.raises(ChannelError, match="create ai card failed"):
            await dingtalk_channel._create_ai_card(
                conversation_id="cid_test",
                meta={},
            )

    async def test_stream_ai_card_success(
        self,
        dingtalk_channel,
    ):
        """Successfully stream content to AI card."""
        from qwenpaw.app.channels.dingtalk.ai_card import (
            ActiveAICard,
            PROCESSING,
        )

        dingtalk_channel.message_type = "card"
        dingtalk_channel.card_template_key = "content"
        dingtalk_channel._card_sdk = _make_card_sdk()

        card = ActiveAICard(
            card_instance_id="card_test_123",
            access_token="token_123",
            conversation_id="cid_test",
            account_id="user123",
            store_path="/tmp",
            created_at=int(time.time() * 1000),
            last_updated=0,
            state=PROCESSING,
        )

        result = await dingtalk_channel._stream_ai_card(
            card,
            "Test content",
            finalize=False,
        )

        assert result is True

    async def test_stream_ai_card_finalize(
        self,
        dingtalk_channel,
    ):
        """Finalize AI card streaming."""
        from qwenpaw.app.channels.dingtalk.ai_card import (
            ActiveAICard,
            PROCESSING,
        )

        dingtalk_channel.message_type = "card"
        dingtalk_channel.card_template_key = "content"
        dingtalk_channel._card_sdk = _make_card_sdk()

        card = ActiveAICard(
            card_instance_id="card_test_123",
            access_token="token_123",
            conversation_id="cid_test",
            account_id="user123",
            store_path="/tmp",
            created_at=int(time.time() * 1000),
            last_updated=0,
            last_streamed_content="Previous content",
            state=PROCESSING,
        )

        result = await dingtalk_channel._stream_ai_card(
            card,
            "Final content",
            finalize=True,
        )

        assert result is True
        assert card.state == "3"  # FINISHED

    async def test_stream_ai_card_duplicate_content_skipped(
        self,
        dingtalk_channel,
        mock_http_session,
    ):
        """Skip streaming if content hasn't changed."""
        from qwenpaw.app.channels.dingtalk.ai_card import (
            ActiveAICard,
            PROCESSING,
        )

        dingtalk_channel.message_type = "card"
        dingtalk_channel.card_template_key = "content"
        dingtalk_channel._http = mock_http_session

        card = ActiveAICard(
            card_instance_id="card_test_123",
            access_token="token_123",
            conversation_id="cid_test",
            account_id="user123",
            store_path="/tmp",
            created_at=1234567890,
            last_updated=1234567890,
            last_streamed_content="Same content",
            state=PROCESSING,
        )

        # Should return False without making HTTP call
        result = await dingtalk_channel._stream_ai_card(
            card,
            "Same content",
            finalize=False,
        )

        assert result is False

    async def test_stream_ai_card_token_refresh(
        self,
        dingtalk_channel,
    ):
        """Refresh token on 401 response from SDK."""
        from Tea.exceptions import TeaException
        from qwenpaw.app.channels.dingtalk.ai_card import (
            ActiveAICard,
            PROCESSING,
        )

        dingtalk_channel.message_type = "card"
        dingtalk_channel.card_template_key = "content"
        dingtalk_channel._oauth_sdk = _make_oauth_sdk("new_token_2")

        # First streaming call raises 401, second succeeds
        card_sdk = _make_card_sdk()
        exc_401 = TeaException(
            {"data": {"statusCode": 401}, "message": "Unauthorized"},
        )
        card_sdk.streaming_update_with_options_async = AsyncMock(
            side_effect=[exc_401, None],
        )
        dingtalk_channel._card_sdk = card_sdk

        card = ActiveAICard(
            card_instance_id="card_test_123",
            access_token="old_token",
            conversation_id="cid_test",
            account_id="user123",
            store_path="/tmp",
            created_at=int(time.time() * 1000),
            last_updated=0,
            state=PROCESSING,
        )

        result = await dingtalk_channel._stream_ai_card(
            card,
            "New content",
            finalize=False,
        )

        assert result is True

    async def test_recover_active_cards(
        self,
        dingtalk_channel,
        mock_http_session,
    ):
        """Recover active cards on startup."""
        dingtalk_channel.message_type = "card"
        dingtalk_channel.card_template_id = "template_123"
        dingtalk_channel.robot_code = "robot_123"
        dingtalk_channel._http = mock_http_session

        # Mock stored cards
        dingtalk_channel._card_store.load = MagicMock(
            return_value=[
                {
                    "card_instance_id": "card_old_1",
                    "conversation_id": "cid_old_1",
                    "state": "2",  # PROCESSING
                    "created_at": 1234567890,
                    "last_updated": 1234567890,
                },
            ],
        )

        mock_http_session.expect_post(
            url="https://api.dingtalk.com/v1.0/oauth2/accessToken",
            response_status=200,
            response_json={"accessToken": "token_123", "expireIn": 7200},
        )
        mock_http_session.expect_put(
            url="https://api.dingtalk.com/v1.0/card/streaming",
            response_status=200,
            response_json={"success": True},
        )

        await dingtalk_channel._recover_active_cards()

        # Card should be removed from active cards after recovery + finalize
        assert "cid_old_1" not in dingtalk_channel._active_cards

    async def test_ai_card_disabled_returns_none(self, dingtalk_channel):
        """AI card methods return None when disabled."""
        dingtalk_channel.message_type = "text"  # Not card

        result = await dingtalk_channel._create_ai_card(
            conversation_id="cid_test",
            meta={},
        )

        assert result is None

    def test_build_ai_card_initial_text(self, dingtalk_channel):
        """Build initial text for AI card."""
        dingtalk_channel.bot_prefix = "[Bot] "

        result = dingtalk_channel._build_ai_card_initial_text()

        assert result.startswith("[Bot] ")


# =============================================================================
# P2: File Download Tests
# =============================================================================


@pytest.mark.asyncio
class TestDingTalkFileDownload:
    """Tests for media download methods."""

    async def test_fetch_bytes_from_url_success(
        self,
        dingtalk_channel,
        mock_http_session,
    ):
        """Successfully fetch bytes from URL."""
        dingtalk_channel._http = mock_http_session
        mock_http_session.expect_get(
            url="https://example.com/file.pdf",
            response_status=200,
            response_data=b"PDF content",
        )

        result = await dingtalk_channel._fetch_bytes_from_url(
            "https://example.com/file.pdf",
        )

        assert result == b"PDF content"

    async def test_fetch_bytes_from_url_http_error(
        self,
        dingtalk_channel,
        mock_http_session,
    ):
        """Handle HTTP error when fetching bytes."""
        dingtalk_channel._http = mock_http_session
        mock_http_session.expect_get(
            url="https://example.com/file.pdf",
            response_status=404,
            response_text="Not Found",
        )

        result = await dingtalk_channel._fetch_bytes_from_url(
            "https://example.com/file.pdf",
        )

        assert result is None

    async def test_fetch_bytes_from_url_file_protocol(
        self,
        dingtalk_channel,
        tmp_path,
    ):
        """Fetch bytes from file:// URL."""
        test_file = tmp_path / "test.txt"
        test_file.write_bytes(b"file content")

        result = await dingtalk_channel._fetch_bytes_from_url(
            f"file://{test_file}",
        )

        assert result == b"file content"

    async def test_fetch_bytes_from_url_empty_file(
        self,
        dingtalk_channel,
        tmp_path,
    ):
        """Handle empty file - returns empty bytes not None."""
        test_file = tmp_path / "empty.txt"
        test_file.write_bytes(b"")

        result = await dingtalk_channel._fetch_bytes_from_url(
            f"file://{test_file}",
        )

        # Empty files return empty bytes, not None
        assert result == b""

    async def test_download_media_to_local_success(
        self,
        dingtalk_channel,
        mock_http_session,
        tmp_path,
    ):
        """Successfully download media to local."""
        dingtalk_channel._http = mock_http_session
        dingtalk_channel._media_dir = tmp_path

        mock_http_session.expect_get(
            url="https://download.example.com/file.jpg",
            response_status=200,
            response_data=b"\xff\xd8\xff\xe0\x00\x10JFIF",  # JPEG magic
            headers={"Content-Type": "image/jpeg"},
        )

        result = await dingtalk_channel._download_media_to_local(
            url="https://download.example.com/file.jpg",
            safe_key="test_key_123",
            filename_hint="image.jpg",
        )

        assert result is not None
        assert Path(result).exists()

    async def test_download_media_to_local_with_content_disposition(
        self,
        dingtalk_channel,
        mock_http_session,
        tmp_path,
    ):
        """Extract filename from Content-Disposition header."""
        dingtalk_channel._http = mock_http_session
        dingtalk_channel._media_dir = tmp_path

        mock_http_session.expect_get(
            url="https://download.example.com/file",
            response_status=200,
            response_data=b"content",
            headers={
                "Content-Disposition": 'attachment; filename="document.pdf"',
            },
        )

        result = await dingtalk_channel._download_media_to_local(
            url="https://download.example.com/file",
            safe_key="test_key",
            filename_hint="unknown.bin",
        )

        assert result is not None
        assert result.endswith(".pdf")

    async def test_download_media_to_local_magic_suffix(
        self,
        dingtalk_channel,
        mock_http_session,
        tmp_path,
    ):
        """Detect real suffix from magic bytes for .file extension."""
        dingtalk_channel._http = mock_http_session
        dingtalk_channel._media_dir = tmp_path

        # PNG magic bytes
        mock_http_session.expect_get(
            url="https://download.example.com/image.file",
            response_status=200,
            response_data=b"\x89PNG\r\n\x1a\n" + b"png data",
            headers={"Content-Type": "application/octet-stream"},
        )

        result = await dingtalk_channel._download_media_to_local(
            url="https://download.example.com/image.file",
            safe_key="test_key",
            filename_hint="image.file",
        )

        assert result is not None
        assert result.endswith(".png")

    async def test_download_media_to_local_http_error(
        self,
        dingtalk_channel,
        mock_http_session,
    ):
        """Handle HTTP error when downloading media."""
        dingtalk_channel._http = mock_http_session

        mock_http_session.expect_get(
            url="https://download.example.com/file.jpg",
            response_status=500,
            response_text="Internal Server Error",
        )

        result = await dingtalk_channel._download_media_to_local(
            url="https://download.example.com/file.jpg",
            safe_key="test_key",
            filename_hint="file.jpg",
        )

        assert result is None

    async def test_download_media_to_local_invalid_url(
        self,
        dingtalk_channel,
    ):
        """Handle invalid URL."""
        result = await dingtalk_channel._download_media_to_local(
            url="not-a-url",
            safe_key="test_key",
        )

        assert result is None

    async def test_fetch_and_download_media_success(
        self,
        dingtalk_channel,
        mock_http_session,
        tmp_path,
    ):
        """Successfully fetch and download media."""
        dingtalk_channel._http = mock_http_session
        dingtalk_channel._media_dir = tmp_path
        dingtalk_channel._oauth_sdk = _make_oauth_sdk("token_123")

        # Mock robot SDK download URL response
        robot_sdk = _make_robot_sdk()
        body = MagicMock()
        body.download_url = "https://cdn.example.com/file.pdf"
        sdk_response = MagicMock()
        sdk_response.body = body
        robot_sdk.robot_message_file_download_with_options_async = AsyncMock(
            return_value=sdk_response,
        )
        dingtalk_channel._robot_sdk = robot_sdk

        # Download file via HTTP
        mock_http_session.expect_get(
            url="https://cdn.example.com/file.pdf",
            response_status=200,
            response_data=b"%PDF-1.4 content",
            headers={"Content-Type": "application/pdf"},
        )

        result = await dingtalk_channel._fetch_and_download_media(
            download_code="dl_code_123",
            robot_code="robot_123",
            filename_hint="document.pdf",
        )

        assert result is not None
        assert Path(result).exists()

    async def test_get_message_file_download_url(
        self,
        dingtalk_channel,
    ):
        """Get download URL for message file."""
        dingtalk_channel._oauth_sdk = _make_oauth_sdk("token_123")

        robot_sdk = _make_robot_sdk()
        body = MagicMock()
        body.download_url = "https://cdn.example.com/file.pdf"
        sdk_response = MagicMock()
        sdk_response.body = body
        robot_sdk.robot_message_file_download_with_options_async = AsyncMock(
            return_value=sdk_response,
        )
        dingtalk_channel._robot_sdk = robot_sdk

        result = await dingtalk_channel._get_message_file_download_url(
            download_code="dl_code_123",
            robot_code="robot_123",
        )

        assert result == "https://cdn.example.com/file.pdf"

    async def test_get_message_file_download_url_api_error(
        self,
        dingtalk_channel,
        mock_http_session,
    ):
        """Handle API error when getting download URL."""
        dingtalk_channel._http = mock_http_session

        mock_http_session.expect_post(
            url="https://api.dingtalk.com/v1.0/oauth2/accessToken",
            response_status=200,
            response_json={"accessToken": "token_123", "expireIn": 7200},
        )
        mock_http_session.expect_post(
            url="https://api.dingtalk.com/v1.0/robot/messageFiles/download",
            response_status=200,
            response_json={"errcode": 40001, "errmsg": "invalid code"},
        )

        result = await dingtalk_channel._get_message_file_download_url(
            download_code="invalid_code",
            robot_code="robot_123",
        )

        assert result is None


# =============================================================================
# P2: Stream Mode Tests
# =============================================================================


@pytest.mark.asyncio
class TestDingTalkStreamMode:
    """Tests for Stream/WebSocket mode."""

    async def test_stream_loop_starts_client(self, dingtalk_channel):
        """Stream loop should start client and handle stop event."""
        mock_client = MagicMock()
        mock_client.start = AsyncMock()
        mock_client.websocket = None

        dingtalk_channel._client = mock_client
        dingtalk_channel._stop_event.set()  # Signal stop immediately

        # Should complete without error
        await dingtalk_channel._stream_loop()

        assert mock_client.start.called

    async def test_stream_loop_no_client(self, dingtalk_channel):
        """Stream loop should exit early if no client."""
        dingtalk_channel._client = None

        # Should complete without error
        await dingtalk_channel._stream_loop()

    async def test_run_stream_forever(self, dingtalk_channel):
        """Run stream forever should execute stream loop."""
        dingtalk_channel._stop_event.set()  # Stop immediately

        mock_client = MagicMock()
        mock_client.start = AsyncMock()
        mock_client.websocket = None
        dingtalk_channel._client = mock_client

        # Should complete without hanging
        dingtalk_channel._run_stream_forever()

    def test_ai_card_enabled(self, dingtalk_channel):
        """Check if AI card is enabled."""
        # Not enabled by default
        assert dingtalk_channel._ai_card_enabled() is False

        # Enable
        dingtalk_channel.message_type = "card"
        dingtalk_channel.card_template_id = "template_123"
        dingtalk_channel.robot_code = "robot_123"

        assert dingtalk_channel._ai_card_enabled() is True

    def test_ai_card_disabled_no_template(self, dingtalk_channel):
        """AI card disabled without template."""
        dingtalk_channel.message_type = "card"
        dingtalk_channel.card_template_id = ""
        dingtalk_channel.robot_code = "robot_123"

        assert dingtalk_channel._ai_card_enabled() is False


# =============================================================================
# P2: Request Processing Tests
# =============================================================================


@pytest.mark.asyncio
class TestDingTalkRequestProcessing:
    """Tests for request processing flow."""

    # Note: allowlist blocking flow is tested via _check_allowlist unit tests
    # Integration test with _run_process_loop requires complex async mock setup

    async def test_before_consume_process_sends_thinking_emoji(
        self,
        dingtalk_channel,
    ):
        """_before_consume_process should send thinking emoji."""
        from unittest.mock import MagicMock

        request = MagicMock()
        request.channel_meta = {
            "message_id": "msg_123",
            "conversation_id": "cid_456",
        }
        request.session_id = None

        with patch.object(
            dingtalk_channel,
            "_send_emotion",
            new_callable=AsyncMock,
        ) as mock_emotion:
            await dingtalk_channel._before_consume_process(request)

            mock_emotion.assert_called_once_with(
                "msg_123",
                "cid_456",
                "🤔Thinking",
            )


# =============================================================================
# P2: Send Method Tests
# =============================================================================


@pytest.mark.asyncio
class TestDingTalkSendMethodsExtended:
    """Extended tests for send methods."""

    async def test_get_session_webhook_for_send_from_meta(
        self,
        dingtalk_channel,
    ):
        """Get webhook from meta when available."""
        meta = {"session_webhook": "http://meta.webhook"}

        result = await dingtalk_channel._get_session_webhook_for_send(
            to_handle="dingtalk:sw:test",
            meta=meta,
        )

        assert result == "http://meta.webhook"

    async def test_get_session_webhook_for_send_from_store(
        self,
        dingtalk_channel,
    ):
        """Get webhook from store when not in meta."""
        # Pre-populate store
        dingtalk_channel._session_webhook_store["dingtalk:sw:storedkey"] = {
            "webhook": "http://stored.webhook",
            "expired_time": 9999999999999,
        }

        result = await dingtalk_channel._get_session_webhook_for_send(
            to_handle="dingtalk:sw:storedkey",
            meta={},
        )

        assert result == "http://stored.webhook"

    async def test_get_session_webhook_for_send_current_request_no_webhook(
        self,
        dingtalk_channel,
    ):
        """Don't use store if request has no webhook (could be expired)."""
        # Pre-populate store
        dingtalk_channel._session_webhook_store["dingtalk:sw:testkey"] = {
            "webhook": "http://stored.webhook",
            "expired_time": 9999999999999,
        }

        # Current request has conversation_id but no webhook
        result = await dingtalk_channel._get_session_webhook_for_send(
            to_handle="dingtalk:sw:testkey",
            meta={"conversation_id": "cid_test"},
        )

        assert result is None

    async def test_send_with_fallback_to_open_api(
        self,
        dingtalk_channel,
        mock_http_session,
    ):
        """Send with fallback to Open API when webhook fails."""
        dingtalk_channel._http = mock_http_session
        dingtalk_channel._oauth_sdk = _make_oauth_sdk("token_123")
        dingtalk_channel._robot_sdk = _make_robot_sdk()

        # Store webhook entry
        dingtalk_channel._session_webhook_store["dingtalk:sw:test"] = {
            "webhook": "http://expired.webhook",
            "conversation_id": "cid_test",
            "conversation_type": "group",
            "sender_staff_id": "",
        }

        # Webhook call fails
        mock_http_session.expect_post(
            url="https://oapi.dingtalk.com/robot/send",
            response_status=400,
            response_json={"errcode": 40014, "errmsg": "invalid session"},
        )

        await dingtalk_channel.send(
            to_handle="dingtalk:sw:test",
            text="Test message",
            meta={},
        )

    async def test_send_no_delivery_target_logs_without_strict_meta(
        self,
        dingtalk_channel,
        mock_http_session,
    ):
        """Conversation replies should not raise on missing target metadata."""
        dingtalk_channel._http = mock_http_session

        with patch(
            "qwenpaw.app.channels.dingtalk.channel.logger.warning",
        ) as mock_warning:
            # Should return quietly (no raise) for non-API sends.
            await dingtalk_channel.send(
                to_handle="unknown_handle",
                text="Test message",
                meta={},
            )

        assert any(
            "no sessionWebhook" in str(call)
            for call in mock_warning.call_args_list
        )

    async def test_send_no_delivery_target_raises_for_api_send(
        self,
        dingtalk_channel,
        mock_http_session,
    ):
        """Explicit send should fail when no DingTalk target is reachable."""
        dingtalk_channel._http = mock_http_session

        with pytest.raises(ChannelError, match="no sessionWebhook"):
            await dingtalk_channel.send(
                to_handle="unknown_handle",
                text="Test message",
                meta={"_api_send": True},
            )

    async def test_send_open_api_fallback_failure_raises_for_api_send(
        self,
        dingtalk_channel,
        mock_http_session,
    ):
        """Open API transport failure should fail explicit sends."""
        dingtalk_channel._http = mock_http_session

        with patch.object(
            dingtalk_channel,
            "_send_robot_message",
            new_callable=AsyncMock,
            return_value=False,
        ):
            with pytest.raises(ChannelError, match="Open API send failed"):
                await dingtalk_channel.send(
                    to_handle="unknown_handle",
                    text="Test message",
                    meta={
                        "_api_send": True,
                        "conversation_id": "cid_test",
                    },
                )


# =============================================================================
# P2: Media Part Sending Tests
# =============================================================================


@pytest.mark.asyncio
class TestDingTalkMediaPartSending:
    """Tests for sending media parts via webhook."""

    async def test_send_media_part_via_webhook_with_media_id(
        self,
        dingtalk_channel,
        mock_http_session,
    ):
        """Send media part that already has media_id."""
        dingtalk_channel._http = mock_http_session

        mock_http_session.expect_post(
            url="https://oapi.dingtalk.com/robot/send",
            response_status=200,
            response_json={"errcode": 0, "errmsg": "ok"},
        )

        from qwenpaw.app.channels.base import ImageContent, ContentType

        part = ImageContent(
            type=ContentType.IMAGE,
            image_url="http://example.com/img.jpg",
            media_id="existing_media_123",
        )

        result = await dingtalk_channel._send_media_part_via_webhook(
            session_webhook="https://oapi.dingtalk.com/robot/send?session=abc",
            part=part,
        )

        assert result is True

    async def test_send_media_part_via_webhook_fetch_and_upload(
        self,
        dingtalk_channel,
        mock_http_session,
        tmp_path,
    ):
        """Fetch and upload when no media_id."""
        dingtalk_channel._http = mock_http_session
        dingtalk_channel._media_dir = tmp_path

        # Mock file download
        mock_http_session.expect_get(
            url="http://example.com/img.jpg",
            response_status=200,
            response_data=b"\xff\xd8\xff\xe0image data",
        )

        # Mock token and upload
        mock_http_session.expect_post(
            url="https://api.dingtalk.com/v1.0/oauth2/accessToken",
            response_status=200,
            response_json={"accessToken": "token_123", "expireIn": 7200},
        )
        mock_http_session.expect_post(
            url="https://oapi.dingtalk.com/media/upload",
            response_status=200,
            response_json={"media_id": "uploaded_media_123"},
        )

        # Mock send
        mock_http_session.expect_post(
            url="https://oapi.dingtalk.com/robot/send",
            response_status=200,
            response_json={"errcode": 0, "errmsg": "ok"},
        )

        from qwenpaw.app.channels.base import ImageContent, ContentType

        part = ImageContent(
            type=ContentType.IMAGE,
            image_url="http://example.com/img.jpg",
            # No media_id, should fetch and upload
        )

        result = await dingtalk_channel._send_media_part_via_webhook(
            session_webhook="https://oapi.dingtalk.com/robot/send?session=abc",
            part=part,
        )

        assert result is True

    async def test_send_media_part_empty_media_id_skipped(
        self,
        dingtalk_channel,
    ):
        """Skip sending if media_id is empty after stripping."""
        from qwenpaw.app.channels.base import ImageContent, ContentType

        part = ImageContent(
            type=ContentType.IMAGE,
            image_url="http://example.com/img.jpg",
            media_id="   ",  # Whitespace only
        )

        result = await dingtalk_channel._send_media_part_via_webhook(
            session_webhook="https://oapi.dingtalk.com/robot/send?session=abc",
            part=part,
        )

        assert result is False

    def test_map_upload_type(self, dingtalk_channel):
        """Map content types to DingTalk upload types."""
        from unittest.mock import MagicMock
        from qwenpaw.app.channels.base import ContentType

        # Create mock parts for each type
        text_part = MagicMock()
        text_part.type = ContentType.TEXT
        assert dingtalk_channel._map_upload_type(text_part) is None

        image_part = MagicMock()
        image_part.type = ContentType.IMAGE
        assert dingtalk_channel._map_upload_type(image_part) == "image"

        audio_part = MagicMock()
        audio_part.type = ContentType.AUDIO
        assert dingtalk_channel._map_upload_type(audio_part) == "voice"

        video_part = MagicMock()
        video_part.type = ContentType.VIDEO
        assert dingtalk_channel._map_upload_type(video_part) == "video"

        file_part = MagicMock()
        file_part.type = ContentType.FILE
        assert dingtalk_channel._map_upload_type(file_part) == "file"

        # Unknown type defaults to file
        unknown_part = MagicMock()
        unknown_part.type = "unknown"
        assert dingtalk_channel._map_upload_type(unknown_part) == "file"

    async def test_send_media_part_video_with_pic_media_id(
        self,
        dingtalk_channel,
        mock_http_session,
    ):
        """Send video with pic_media_id for cover image."""
        dingtalk_channel._http = mock_http_session
        dingtalk_channel._oauth_sdk = _make_oauth_sdk("token_123")

        # Mock the video download
        mock_http_session.expect_get(
            url="http://example.com/video.mp4",
            response_status=200,
            response_data=b"fake video content",
        )
        # Mock media upload endpoint (oapi uses HTTP not SDK)
        mock_http_session.expect_post(
            url="https://oapi.dingtalk.com/media/upload",
            response_status=200,
            response_json={"media_id": "uploaded_media_123"},
        )
        # Mock robot send endpoint
        mock_http_session.expect_post(
            url="https://oapi.dingtalk.com/robot/send",
            response_status=200,
            response_json={"errcode": 0, "errmsg": "ok"},
        )

        from qwenpaw.app.channels.base import VideoContent, ContentType

        part = VideoContent(
            type=ContentType.VIDEO,
            video_url="http://example.com/video.mp4",
            media_id="video_media_123",
            pic_media_id="cover_media_456",
            duration=30,
        )

        result = await dingtalk_channel._send_media_part_via_webhook(
            session_webhook="https://oapi.dingtalk.com/robot/send?session=abc",
            part=part,
        )

        assert result is True


# =============================================================================
# P2: Handler Rich Content Tests
# =============================================================================


class TestDingTalkHandlerRichContent:
    """Tests for handler rich content parsing."""

    @pytest.fixture
    def mock_download_fetcher(self):
        """Mock download URL fetcher."""

        async def fetcher(*, download_code, robot_code, filename_hint):
            return f"http://cdn.example.com/{download_code}"

        return fetcher

    @pytest.fixture
    def rich_handler(self, mock_download_fetcher):
        """Create a handler for rich content tests."""
        from qwenpaw.app.channels.dingtalk.handler import (
            DingTalkChannelHandler,
        )

        loop = asyncio.new_event_loop()
        handler = DingTalkChannelHandler(
            main_loop=loop,
            enqueue_callback=MagicMock(),
            bot_prefix="",
            download_url_fetcher=mock_download_fetcher,
        )
        yield handler
        loop.close()

    def test_parse_rich_content_with_text(self, rich_handler):
        """Parse rich text content."""
        incoming = MagicMock()
        incoming.robot_code = "robot_123"
        incoming.to_dict.return_value = {
            "content": {
                "richText": [
                    {"text": "Hello world"},
                ],
            },
        }

        result = rich_handler._parse_rich_content(incoming)

        assert len(result) == 1

    def test_parse_rich_content_with_image(self, rich_handler):
        """Parse rich content with image."""
        incoming = MagicMock()
        incoming.robot_code = "robot_123"
        incoming.to_dict.return_value = {
            "content": {
                "richText": [
                    {
                        "downloadCode": "img_dl_code",
                        "type": "picture",
                    },
                ],
            },
        }

        with patch.object(
            rich_handler,
            "_fetch_download_url_and_content",
            return_value=MagicMock(),
        ):
            result = rich_handler._parse_rich_content(incoming)

            assert len(result) >= 0  # May be 0 or 1 depending on fetch result

    def test_parse_rich_content_single_download_code(self, rich_handler):
        """Parse content with single download code."""
        incoming = MagicMock()
        incoming.robot_code = "robot_123"
        incoming.to_dict.return_value = {
            "msgtype": "image",
            "content": {
                "downloadCode": "single_img_code",
            },
        }

        with patch.object(
            rich_handler,
            "_fetch_download_url_and_content",
            return_value=MagicMock(),
        ):
            result = rich_handler._parse_rich_content(incoming)

            assert len(result) >= 0

    def test_parse_rich_content_empty(self, rich_handler):
        """Parse empty content."""
        incoming = MagicMock()
        incoming.robot_code = "robot_123"
        incoming.to_dict.return_value = {
            "content": {},
        }

        result = rich_handler._parse_rich_content(incoming)

        assert result == []

    def test_parse_rich_content_exception_handled(self, rich_handler):
        """Exceptions in parsing should be handled."""
        incoming = MagicMock()
        incoming.robot_code = "robot_123"
        # to_dict will raise exception
        incoming.to_dict.side_effect = RuntimeError("Test error")

        result = rich_handler._parse_rich_content(incoming)

        assert result == []


# =============================================================================
# P2: Merge Native Tests
# =============================================================================


class TestDingTalkMergeNative:
    """Tests for _merge_native method."""

    def test_merge_native_empty(self, dingtalk_channel):
        """Merge empty list returns empty dict."""
        result = dingtalk_channel._merge_native([])

        assert result == {}

    def test_merge_native_single_item(self, dingtalk_channel):
        """Merge single item."""
        items = [
            {
                "channel_id": "dingtalk",
                "sender_id": "user123",
                "content_parts": [{"type": "text", "text": "Hello"}],
                "meta": {"session_webhook": "http://webhook.url"},
            },
        ]

        result = dingtalk_channel._merge_native(items)

        assert result["channel_id"] == "dingtalk"
        assert result["sender_id"] == "user123"
        assert len(result["content_parts"]) == 1

    def test_merge_native_multiple_items(self, dingtalk_channel):
        """Merge multiple items combines parts and metadata."""
        items = [
            {
                "channel_id": "dingtalk",
                "sender_id": "user1",
                "content_parts": [{"type": "text", "text": "Hello"}],
                "meta": {
                    "session_webhook": "http://webhook1.url",
                    "conversation_id": "cid1",
                },
            },
            {
                "channel_id": "dingtalk",
                "sender_id": "user2",
                "content_parts": [{"type": "text", "text": "World"}],
                "meta": {
                    "session_webhook": "http://webhook2.url",
                    "conversation_id": "cid2",
                },
            },
        ]

        result = dingtalk_channel._merge_native(items)

        assert len(result["content_parts"]) == 2
        assert result["meta"]["batched_count"] == 2
        # Should use webhook from newest (last) item
        assert result["session_webhook"] == "http://webhook2.url"

    def test_merge_native_extracts_message_ids(self, dingtalk_channel):
        """Extract message IDs from items."""
        items = [
            {
                "channel_id": "dingtalk",
                "content_parts": [],
                "meta": {"message_id": "msg_123"},
            },
            {
                "channel_id": "dingtalk",
                "content_parts": [],
                "message_id": "msg_456",  # Can also be at top level
            },
        ]

        result = dingtalk_channel._merge_native(items)

        assert "_message_ids" in result["meta"]
        assert "msg_123" in result["meta"]["_message_ids"]
        assert "msg_456" in result["meta"]["_message_ids"]

    def test_merge_native_prefers_newest_webhook(self, dingtalk_channel):
        """When merging, prefer webhook from newest item."""
        items = [
            {
                "content_parts": [],
                "meta": {"session_webhook": "http://old.url"},
            },
            {
                "content_parts": [],
                "meta": {"session_webhook": "http://new.url"},
            },
            {"content_parts": [], "meta": {}},  # No webhook
        ]

        result = dingtalk_channel._merge_native(items)

        # Should use the most recent webhook found (from items[1])
        assert result["session_webhook"] == "http://new.url"


# =============================================================================
# P2: Load Session Webhook Entry Tests
# =============================================================================


@pytest.mark.asyncio
class TestDingTalkLoadSessionWebhookEntry:
    """Tests for _load_session_webhook_entry method."""

    async def test_load_session_webhook_entry_from_memory(
        self,
        dingtalk_channel,
    ):
        """Load full entry from memory."""
        dingtalk_channel._session_webhook_store["dingtalk:sw:fulltest"] = {
            "webhook": "http://full.webhook",
            "expired_time": 9999999999999,
            "conversation_id": "cid_full",
            "conversation_type": "group",
            "sender_staff_id": "staff_full",
        }

        result = await dingtalk_channel._load_session_webhook_entry(
            "dingtalk:sw:fulltest",
        )

        assert result is not None
        assert result["webhook"] == "http://full.webhook"
        assert result["conversation_id"] == "cid_full"

    async def test_load_session_webhook_entry_not_found(
        self,
        dingtalk_channel,
    ):
        """Non-existent entry returns None."""
        result = await dingtalk_channel._load_session_webhook_entry(
            "dingtalk:sw:nonexistent",
        )

        assert result is None


# =============================================================================
# P2: Additional Coverage Tests
# =============================================================================


@pytest.mark.asyncio
class TestDingTalkAdditionalCoverage:
    """Additional tests to reach 60% coverage."""

    def test_access_control_disabled_allows_all(self, dingtalk_channel):
        """Access control disabled allows all users."""
        dingtalk_channel.access_control_dm = False
        dingtalk_channel.access_control_group = False

        assert dingtalk_channel.access_control_enabled is False

    def test_access_control_dm_enabled(self, dingtalk_channel):
        """Access control dm enabled makes access_control_enabled True."""
        dingtalk_channel.access_control_dm = True
        dingtalk_channel.access_control_group = False

        assert dingtalk_channel.access_control_enabled is True

    def test_access_control_group_enabled(self, dingtalk_channel):
        """Access control group enabled makes access_control_enabled True."""
        dingtalk_channel.access_control_dm = False
        dingtalk_channel.access_control_group = True

        assert dingtalk_channel.access_control_enabled is True

    def test_check_group_mention_not_required(self, dingtalk_channel):
        """Check group mention when not required."""
        dingtalk_channel.require_mention = False

        result = dingtalk_channel._check_group_mention(True, {})
        assert result is True

    def test_check_group_mention_not_group(self, dingtalk_channel):
        """Check group mention when not a group."""
        dingtalk_channel.require_mention = True

        result = dingtalk_channel._check_group_mention(False, {})
        assert result is True

    def test_check_group_mentioned(self, dingtalk_channel):
        """Check group mention when bot is mentioned."""
        dingtalk_channel.require_mention = True

        result = dingtalk_channel._check_group_mention(
            True,
            {"bot_mentioned": True},
        )
        assert result is True

    def test_check_group_mention_missing(self, dingtalk_channel):
        """Check group mention when not mentioned."""
        dingtalk_channel.require_mention = True

        result = dingtalk_channel._check_group_mention(True, {})
        assert result is False

    def test_guess_filename_and_ext_with_filename(self, dingtalk_channel):
        """Guess filename from content part with filename."""
        from unittest.mock import MagicMock

        part = MagicMock()
        part.filename = "document.pdf"
        part.file_url = None
        part.image_url = None
        part.video_url = None

        filename, ext = dingtalk_channel._guess_filename_and_ext(
            part,
            "default.txt",
        )

        assert filename == "document.pdf"
        assert ext == "pdf"

    def test_guess_filename_and_ext_from_url(self, dingtalk_channel):
        """Guess filename from content part URL."""
        from unittest.mock import MagicMock

        part = MagicMock()
        part.filename = ""
        part.file_url = "http://example.com/file.pdf"
        part.image_url = None
        part.video_url = None

        filename, ext = dingtalk_channel._guess_filename_and_ext(
            part,
            "default.txt",
        )

        assert filename == "file.pdf"
        assert ext == "pdf"

    def test_guess_filename_and_ext_default(self, dingtalk_channel):
        """Use default filename when nothing else available."""
        from unittest.mock import MagicMock

        part = MagicMock()
        part.filename = ""
        part.file_url = None
        part.image_url = None
        part.video_url = None

        filename, ext = dingtalk_channel._guess_filename_and_ext(
            part,
            "default.txt",
        )

        assert filename == "default.txt"
        assert ext == "txt"

    def test_resolve_open_api_params_meta_priority(self, dingtalk_channel):
        """Meta values take priority over entry values."""
        meta = {
            "conversation_id": "meta_cid",
            "conversation_type": "single",
        }
        entry = {
            "conversation_id": "entry_cid",
            "conversation_type": "group",
        }

        result = dingtalk_channel._resolve_open_api_params(meta, entry)

        assert result["conversation_id"] == "meta_cid"
        assert result["conversation_type"] == "single"

    def test_resolve_open_api_params_entry_fallback(self, dingtalk_channel):
        """Entry values used when meta is empty."""
        meta = {}
        entry = {
            "conversation_id": "entry_cid",
            "conversation_type": "group",
        }

        result = dingtalk_channel._resolve_open_api_params(meta, entry)

        assert result["conversation_id"] == "entry_cid"
        assert result["conversation_type"] == "group"

    def test_resolve_open_api_params_defaults(self, dingtalk_channel):
        """Default values when both meta and entry are empty."""
        result = dingtalk_channel._resolve_open_api_params({}, {})

        assert result["conversation_id"] == ""
        assert result["conversation_type"] == ""
        assert result["sender_staff_id"] == ""

    def test_get_response_error_message(self, dingtalk_channel):
        """Extract error message from response."""
        # Create a response without 'data' attribute to avoid triggering
        # the data path in _get_response_error_message
        response = MagicMock(spec=["error"])
        response.error = MagicMock(spec=["message"])
        response.error.message = "Test error message"

        result = dingtalk_channel._get_response_error_message(response)

        assert result == "Test error message"

    def test_get_response_error_message_none(self, dingtalk_channel):
        """Return None when no error."""
        result = dingtalk_channel._get_response_error_message(None)

        assert result is None

    def test_get_response_error_message_no_error(self, dingtalk_channel):
        """Return None when response has no error."""
        response = MagicMock(spec=["error"])
        response.error = None

        result = dingtalk_channel._get_response_error_message(response)

        assert result is None

    async def test_try_open_api_fallback_with_meta(
        self,
        dingtalk_channel,
    ):
        """Open API fallback using meta directly."""
        dingtalk_channel._oauth_sdk = _make_oauth_sdk("token_123")
        dingtalk_channel._robot_sdk = _make_robot_sdk()

        result = await dingtalk_channel._try_open_api_fallback(
            text="Test message",
            to_handle="dingtalk:sw:test",
            meta={
                "conversation_id": "cid_test",
                "conversation_type": "group",
            },
        )

        assert result is True

    async def test_send_content_parts_text_only(
        self,
        dingtalk_channel,
        mock_http_session,
    ):
        """Send content parts with text only."""
        dingtalk_channel._http = mock_http_session

        from qwenpaw.app.channels.base import TextContent, ContentType

        parts = [TextContent(type=ContentType.TEXT, text="Hello world")]

        # Mock _try_open_api_fallback to avoid real API calls
        with patch.object(
            dingtalk_channel,
            "_try_open_api_fallback",
            return_value=True,
        ) as mock_fallback:
            await dingtalk_channel.send_content_parts(
                to_handle="dingtalk:sw:test",
                parts=parts,
                meta={"session_webhook": "http://webhook.url"},
            )
            # Should try fallback when webhook fails
            mock_fallback.assert_called_once()

    def test_sender_from_chatbot_message_skip_bot(self):
        """Skip messages from bot itself."""
        from qwenpaw.app.channels.dingtalk.content_utils import (
            sender_from_chatbot_message,
        )

        msg = MagicMock()
        msg.sender_staff_id = "bot_staff_id"
        msg.senderStaffId = "bot_staff_id"
        msg.is_bot = True

        _sender, skip = sender_from_chatbot_message(msg)

        assert skip is True
