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

Comprehensive unit tests for FeishuChannel covering:
- Initialization and configuration (from_env, from_config)
- Session ID resolution and routing
- Receive ID store management (for proactive send)
- Message deduplication
- Nickname caching
- Utility methods (sync and async)
- Send methods

Test Patterns:
- Uses tmp_path fixture for temporary paths
- Uses AsyncMock for async methods
- @pytest.mark.asyncio only on async test methods

Run:
    pytest tests/unit/channels/test_feishu.py -v
    pytest tests/unit/channels/test_feishu.py::TestFeishuChannelInit -v
"""

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


import asyncio
import json
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.app.channels.base import ContentType, OutgoingContentPart

# =============================================================================
# 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 feishu_channel(
    mock_process_handler,
    temp_media_dir,
) -> Generator:
    """Create a FeishuChannel instance for testing."""
    from qwenpaw.app.channels.feishu.channel import FeishuChannel

    channel = FeishuChannel(
        process=mock_process_handler,
        enabled=True,
        app_id="test_app_id_123456",
        app_secret="test_app_secret_abcdef",
        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 feishu_channel_with_workspace(
    mock_process_handler,
    temp_workspace_dir,
) -> Generator:
    """Create a FeishuChannel with workspace for testing."""
    from qwenpaw.app.channels.feishu.channel import FeishuChannel

    channel = FeishuChannel(
        process=mock_process_handler,
        enabled=True,
        app_id="test_app_id_789",
        app_secret="test_app_secret_xyz",
        bot_prefix="[WorkspaceBot] ",
        workspace_dir=temp_workspace_dir,
        display_config=ChannelDisplayConfig(
            show_tool_calls=False,
            show_tool_results=False,
        ),
    )
    yield channel


@pytest.fixture
def mock_lark_client():
    """Mock lark_oapi client."""
    mock_client = MagicMock()
    mock_client._config = MagicMock()
    return mock_client


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


class TestFeishuChannelInit:
    """
    Tests for FeishuChannel 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.feishu.channel import FeishuChannel

        channel = FeishuChannel(
            process=mock_process_handler,
            enabled=True,
            app_id="my_app_id",
            app_secret="my_app_secret",
            bot_prefix="[Bot] ",
            encrypt_key="my_encrypt_key",
            verification_token="my_token",
            media_dir=str(temp_media_dir),
        )

        assert channel.enabled is True
        assert channel.app_id == "my_app_id"
        assert channel.app_secret == "my_app_secret"
        assert channel.bot_prefix == "[Bot] "
        assert channel.encrypt_key == "my_encrypt_key"
        assert channel.verification_token == "my_token"
        assert channel.channel == "feishu"

    def test_init_uses_default_domain(self, mock_process_handler):
        """Constructor should default domain to 'feishu'."""
        from qwenpaw.app.channels.feishu.channel import FeishuChannel

        channel = FeishuChannel(
            process=mock_process_handler,
            enabled=True,
            app_id="test_id",
            app_secret="test_secret",
            bot_prefix="",
        )

        assert channel.domain == "feishu"

    def test_init_accepts_lark_domain(self, mock_process_handler):
        """Constructor should accept 'lark' as domain."""
        from qwenpaw.app.channels.feishu.channel import FeishuChannel

        channel = FeishuChannel(
            process=mock_process_handler,
            enabled=True,
            app_id="test_id",
            app_secret="test_secret",
            bot_prefix="",
            domain="lark",
        )

        assert channel.domain == "lark"

    def test_init_rejects_invalid_domain(self, mock_process_handler):
        """Constructor should fallback to 'feishu' for invalid domain."""
        from qwenpaw.app.channels.feishu.channel import FeishuChannel

        channel = FeishuChannel(
            process=mock_process_handler,
            enabled=True,
            app_id="test_id",
            app_secret="test_secret",
            bot_prefix="",
            domain="invalid_domain",
        )

        assert channel.domain == "feishu"

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

        channel = FeishuChannel(
            process=mock_process_handler,
            enabled=True,
            app_id="test_id",
            app_secret="test_secret",
            bot_prefix="",
        )

        # Message ID deduplication
        assert hasattr(channel, "_processed_message_ids")
        assert isinstance(channel._processed_message_ids, dict)

        # Receive ID store
        assert hasattr(channel, "_receive_id_store")
        assert isinstance(channel._receive_id_store, dict)

        # Nickname cache
        assert hasattr(channel, "_nickname_cache")
        assert isinstance(channel._nickname_cache, dict)

        # Clock offset
        assert hasattr(channel, "_clock_offset")
        assert channel._clock_offset == 0

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

        channel = FeishuChannel(
            process=mock_process_handler,
            enabled=True,
            app_id="test_id",
            app_secret="test_secret",
            bot_prefix="",
        )

        # Receive ID lock
        assert hasattr(channel, "_receive_id_lock")
        lock_type = type(channel._receive_id_lock).__name__
        assert "Lock" in lock_type

        # Nickname cache lock
        assert hasattr(channel, "_nickname_cache_lock")
        lock_type = type(channel._nickname_cache_lock).__name__
        assert "Lock" in lock_type

    def test_channel_type_is_feishu(self, feishu_channel):
        """Channel type must be 'feishu'."""
        assert feishu_channel.channel == "feishu"


class TestFeishuChannelFromEnv:
    """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.feishu.channel import FeishuChannel

        monkeypatch.setenv("FEISHU_CHANNEL_ENABLED", "0")
        monkeypatch.setenv("FEISHU_APP_ID", "env_app_id")
        monkeypatch.setenv("FEISHU_APP_SECRET", "env_app_secret")
        monkeypatch.setenv("FEISHU_BOT_PREFIX", "[EnvBot] ")
        monkeypatch.setenv("FEISHU_ENCRYPT_KEY", "env_encrypt_key")
        monkeypatch.setenv("FEISHU_VERIFICATION_TOKEN", "env_token")

        channel = FeishuChannel.from_env(mock_process_handler)

        assert channel.enabled is False
        assert channel.app_id == "env_app_id"
        assert channel.app_secret == "env_app_secret"
        assert channel.bot_prefix == "[EnvBot] "
        assert channel.encrypt_key == "env_encrypt_key"
        assert channel.verification_token == "env_token"

    def test_from_env_reads_domain(self, mock_process_handler, monkeypatch):
        """from_env should read FEISHU_DOMAIN environment variable."""
        from qwenpaw.app.channels.feishu.channel import FeishuChannel

        monkeypatch.setenv("FEISHU_APP_ID", "test_id")
        monkeypatch.setenv("FEISHU_APP_SECRET", "test_secret")
        monkeypatch.setenv("FEISHU_DOMAIN", "lark")

        channel = FeishuChannel.from_env(mock_process_handler)

        assert channel.domain == "lark"

    def test_from_env_allow_from_parsing(
        self,
        mock_process_handler,
        monkeypatch,
    ):
        """from_env should parse FEISHU_ALLOW_FROM correctly."""
        from qwenpaw.app.channels.feishu.channel import FeishuChannel

        monkeypatch.setenv("FEISHU_APP_ID", "test_id")
        monkeypatch.setenv("FEISHU_APP_SECRET", "test_secret")
        monkeypatch.setenv("FEISHU_ALLOW_FROM", "user1,user2,user3")

        channel = FeishuChannel.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 FEISHU_ALLOW_FROM."""
        from qwenpaw.app.channels.feishu.channel import FeishuChannel

        monkeypatch.setenv("FEISHU_APP_ID", "test_id")
        monkeypatch.setenv("FEISHU_APP_SECRET", "test_secret")
        monkeypatch.setenv("FEISHU_ALLOW_FROM", "")

        channel = FeishuChannel.from_env(mock_process_handler)

        assert channel.allow_from == set()

    def test_from_env_require_mention(self, mock_process_handler, monkeypatch):
        """from_env should parse FEISHU_REQUIRE_MENTION correctly."""
        from qwenpaw.app.channels.feishu.channel import FeishuChannel

        monkeypatch.setenv("FEISHU_APP_ID", "test_id")
        monkeypatch.setenv("FEISHU_APP_SECRET", "test_secret")
        monkeypatch.setenv("FEISHU_REQUIRE_MENTION", "1")

        channel = FeishuChannel.from_env(mock_process_handler)

        assert channel.require_mention is True

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

        monkeypatch.setenv("FEISHU_APP_ID", "test_id")
        monkeypatch.setenv("FEISHU_APP_SECRET", "test_secret")
        monkeypatch.delenv("FEISHU_CHANNEL_ENABLED", raising=False)
        monkeypatch.delenv("FEISHU_BOT_PREFIX", raising=False)
        monkeypatch.delenv("FEISHU_REQUIRE_MENTION", raising=False)
        monkeypatch.delenv("FEISHU_DOMAIN", raising=False)

        channel = FeishuChannel.from_env(mock_process_handler)

        assert channel.enabled is False  # Default disabled
        assert channel.bot_prefix == ""  # Default empty
        assert channel.require_mention is False  # Default False
        assert channel.domain == "feishu"  # Default domain


class TestFeishuChannelFromConfig:
    """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.feishu.channel import FeishuChannel
        from qwenpaw.config.config import FeishuConfig

        config = FeishuConfig(
            enabled=False,
            app_id="config_app_id",
            app_secret="config_app_secret",
            bot_prefix="[ConfigBot] ",
            encrypt_key="config_key",
            verification_token="config_token",
            dm_policy="allowlist",
            group_policy="allowlist",
            require_mention=True,
            domain="lark",
        )

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

        assert channel.enabled is False
        assert channel.app_id == "config_app_id"
        assert channel.app_secret == "config_app_secret"
        assert channel.bot_prefix == "[ConfigBot] "
        assert channel.encrypt_key == "config_key"
        assert channel.verification_token == "config_token"
        assert channel.dm_policy == "allowlist"
        assert channel.group_policy == "allowlist"
        assert channel.require_mention is True
        assert channel.domain == "lark"

    def test_from_config_with_workspace(self, mock_process_handler, tmp_path):
        """from_config should use workspace_dir when provided."""
        from qwenpaw.app.channels.feishu.channel import FeishuChannel
        from qwenpaw.config.config import FeishuConfig

        config = FeishuConfig(
            enabled=True,
            app_id="test_id",
            app_secret="test_secret",
        )

        workspace_dir = tmp_path / "workspace"
        workspace_dir.mkdir()

        channel = FeishuChannel.from_config(
            process=mock_process_handler,
            config=config,
            workspace_dir=workspace_dir,
        )

        assert channel._workspace_dir == workspace_dir


# =============================================================================
# P0: Session ID Resolution
# =============================================================================


class TestFeishuChannelResolveSessionId:
    """Tests for session ID resolution."""

    def test_resolve_session_id_with_group_chat(self, feishu_channel):
        """Should use chat_id for group chat session ID."""
        meta = {
            "feishu_chat_id": "oc_1234567890abcdef",
            "feishu_chat_type": "group",
        }

        session_id = feishu_channel.resolve_session_id("sender_123", meta)

        # Should be: last4(app_id) + _ + last8(chat_id)
        assert "3456" in session_id  # last 4 of "test_app_id_123456"
        assert "0abcdef" in session_id  # last 8 of chat_id

    def test_resolve_session_id_with_p2p_chat(self, feishu_channel):
        """Should use sender_id for p2p chat session ID."""
        meta = {
            "feishu_chat_id": "",
            "feishu_chat_type": "p2p",
        }

        session_id = feishu_channel.resolve_session_id(
            "ou_abcdef1234567890",
            meta,
        )

        assert "567890" in session_id  # last 8 of sender_id

    def test_resolve_session_id_fallback_to_chat_id(self, feishu_channel):
        """Should fallback to chat_id when no sender_id."""
        meta = {
            "feishu_chat_id": "oc_fallback12345",
            "feishu_chat_type": "p2p",
        }

        session_id = feishu_channel.resolve_session_id("", meta)

        assert "ack12345" in session_id  # last 8 of chat_id

    def test_resolve_session_id_no_chat_no_sender(self, feishu_channel):
        """Should use channel prefix when no chat_id or sender_id."""
        meta = {
            "feishu_chat_id": "",
            "feishu_chat_type": "p2p",
        }

        session_id = feishu_channel.resolve_session_id("", meta)

        assert session_id.startswith("feishu:")


# =============================================================================
# P1: Receive ID Store Management (Critical for proactive send)
# =============================================================================


class TestFeishuChannelReceiveIdStore:
    """
    Tests for receive_id store storage and retrieval.

    The receive_id store is used for proactive send (cron jobs).
    It persists to disk so send can work after restart.
    """

    @pytest.mark.asyncio
    async def test_save_receive_id_stores_in_memory(self, feishu_channel):
        """Saving receive_id should store it in memory."""
        await feishu_channel._save_receive_id(
            session_id="test_session_123",
            receive_id="ou_receiver456",
            receive_id_type="open_id",
        )

        assert "test_session_123" in feishu_channel._receive_id_store
        stored = feishu_channel._receive_id_store["test_session_123"]
        assert stored == ("open_id", "ou_receiver456")

    @pytest.mark.asyncio
    async def test_save_receive_id_persists_to_disk(
        self,
        feishu_channel_with_workspace,
        temp_workspace_dir,
    ):
        """Saving receive_id should persist to disk for recovery."""
        channel = feishu_channel_with_workspace

        await channel._save_receive_id(
            session_id="disk_test_session",
            receive_id="ou_disk_user",
            receive_id_type="open_id",
        )

        # Check file exists
        store_path = temp_workspace_dir / "feishu_receive_ids.json"
        assert store_path.exists()

        # Verify content
        with open(store_path, "r", encoding="utf-8") as f:
            data = json.load(f)

        assert "disk_test_session" in data
        assert data["disk_test_session"] == ["open_id", "ou_disk_user"]

    @pytest.mark.asyncio
    async def test_load_receive_id_from_memory(self, feishu_channel):
        """Loading receive_id should first check memory."""
        # Pre-populate memory store
        feishu_channel._receive_id_store["mem_test"] = (
            "chat_id",
            "oc_mem_chat",
        )

        result = await feishu_channel._load_receive_id("mem_test")

        assert result == ("chat_id", "oc_mem_chat")

    @pytest.mark.asyncio
    async def test_load_receive_id_from_disk(
        self,
        feishu_channel_with_workspace,
        temp_workspace_dir,
    ):
        """Loading receive_id should fallback to disk if not in memory."""
        channel = feishu_channel_with_workspace

        # Create file manually
        store_path = temp_workspace_dir / "feishu_receive_ids.json"
        data = {
            "disk_test": ["open_id", "ou_from_disk"],
        }
        with open(store_path, "w", encoding="utf-8") as f:
            json.dump(data, f)

        result = await channel._load_receive_id("disk_test")

        assert result == ("open_id", "ou_from_disk")

    @pytest.mark.asyncio
    async def test_load_receive_id_not_found_returns_none(
        self,
        feishu_channel,
    ):
        """Loading non-existent receive_id should return None."""
        result = await feishu_channel._load_receive_id("nonexistent")

        assert result is None

    @pytest.mark.asyncio
    async def test_load_receive_id_empty_session_returns_none(
        self,
        feishu_channel,
    ):
        """Loading with empty session_id should return None."""
        result = await feishu_channel._load_receive_id("")

        assert result is None

    @pytest.mark.asyncio
    async def test_save_receive_id_empty_session_skips(self, feishu_channel):
        """Saving with empty session_id should be skipped."""
        await feishu_channel._save_receive_id(
            session_id="",
            receive_id="ou_test",
            receive_id_type="open_id",
        )

        assert "" not in feishu_channel._receive_id_store

    @pytest.mark.asyncio
    async def test_save_receive_id_empty_receive_id_skips(
        self,
        feishu_channel,
    ):
        """Saving with empty receive_id should be skipped."""
        await feishu_channel._save_receive_id(
            session_id="test_session",
            receive_id="",
            receive_id_type="open_id",
        )

        assert "test_session" not in feishu_channel._receive_id_store

    @pytest.mark.asyncio
    async def test_save_receive_id_also_keys_by_open_id(self, feishu_channel):
        """Saving should also key by open_id for direct lookup."""
        await feishu_channel._save_receive_id(
            session_id="session_abc",
            receive_id="ou_direct_user",
            receive_id_type="open_id",
        )

        # Should be accessible by open_id too
        result = await feishu_channel._load_receive_id("ou_direct_user")
        assert result == ("open_id", "ou_direct_user")


# =============================================================================
# P1: Route from Handle
# =============================================================================


class TestFeishuChannelRouteFromHandle:
    """Tests for _route_from_handle method."""

    def test_route_from_handle_session_key(self, feishu_channel):
        """Should parse feishu:sw: prefix as session_key."""
        result = feishu_channel._route_from_handle("feishu:sw:abc123")

        assert result["session_key"] == "abc123"

    def test_route_from_handle_chat_id(self, feishu_channel):
        """Should parse feishu:chat_id: prefix."""
        result = feishu_channel._route_from_handle("feishu:chat_id:oc_test123")

        assert result["receive_id_type"] == "chat_id"
        assert result["receive_id"] == "oc_test123"

    def test_route_from_handle_open_id(self, feishu_channel):
        """Should parse feishu:open_id: prefix."""
        result = feishu_channel._route_from_handle(
            "feishu:open_id:ou_user456",
        )

        assert result["receive_id_type"] == "open_id"
        assert result["receive_id"] == "ou_user456"

    def test_route_from_handle_direct_chat_id(self, feishu_channel):
        """Should recognize raw chat_id starting with oc_."""
        result = feishu_channel._route_from_handle("oc_direct_chat")

        assert result["receive_id_type"] == "chat_id"
        assert result["receive_id"] == "oc_direct_chat"

    def test_route_from_handle_direct_open_id(self, feishu_channel):
        """Should recognize raw open_id starting with ou_."""
        result = feishu_channel._route_from_handle("ou_direct_user")

        assert result["receive_id_type"] == "open_id"
        assert result["receive_id"] == "ou_direct_user"

    def test_route_from_handle_fallback(self, feishu_channel):
        """Should default to open_id for unknown formats."""
        result = feishu_channel._route_from_handle("random_id")

        assert result["receive_id_type"] == "open_id"
        assert result["receive_id"] == "random_id"


# =============================================================================
# P1: To Handle from Target
# =============================================================================


class TestFeishuChannelToHandleFromTarget:
    """Tests for to_handle_from_target method."""

    def test_to_handle_from_target_with_session(self, feishu_channel):
        """Should create handle with session_id."""
        result = feishu_channel.to_handle_from_target(
            user_id="ou_user123",
            session_id="session_abc",
        )

        assert result == "feishu:sw:session_abc"

    def test_to_handle_from_target_without_session(self, feishu_channel):
        """Should fallback to user_id when no session."""
        result = feishu_channel.to_handle_from_target(
            user_id="ou_user456",
            session_id="",
        )

        assert result == "feishu:open_id:ou_user456"

    def test_to_handle_from_target_empty_user_and_session(
        self,
        feishu_channel,
    ):
        """Should return empty string when both are empty."""
        result = feishu_channel.to_handle_from_target(
            user_id="",
            session_id="",
        )

        assert result == "feishu:open_id:"


# =============================================================================
# P0: Message Deduplication
# =============================================================================


class TestFeishuChannelMessageDeduplication:
    """
    Tests for message deduplication.

    Feishu may retry message delivery; we need to dedup by message_id.
    """

    def test_message_id_tracked(self, feishu_channel):
        """Processed message IDs should be tracked."""
        from qwenpaw.app.channels.feishu.constants import (
            FEISHU_PROCESSED_IDS_MAX,
        )

        # Initially empty
        assert len(feishu_channel._processed_message_ids) == 0

        # Add a message ID
        feishu_channel._processed_message_ids["msg_123"] = None

        assert "msg_123" in feishu_channel._processed_message_ids

    def test_message_id_trims_when_over_limit(self, feishu_channel):
        """Old message IDs should be trimmed when over limit."""
        from qwenpaw.app.channels.feishu.constants import (
            FEISHU_PROCESSED_IDS_MAX,
        )

        max_size = FEISHU_PROCESSED_IDS_MAX

        # Add more IDs than the limit
        for i in range(max_size + 10):
            feishu_channel._processed_message_ids[f"msg_{i}"] = None
            # Simulate the trimming behavior
            while len(feishu_channel._processed_message_ids) > max_size:
                feishu_channel._processed_message_ids.popitem(last=False)

        # Should be at most max_size
        assert len(feishu_channel._processed_message_ids) <= max_size


# =============================================================================
# P2: Utility Functions - Synchronous
# =============================================================================


class TestFeishuChannelSyncUtilities:
    """Tests for synchronous utility methods."""

    def test_build_post_content_text_only(self, feishu_channel):
        """Should build post content with text only."""
        result = feishu_channel._build_post_content("Hello World", [])

        assert "zh_cn" in result
        assert "content" in result["zh_cn"]
        assert result["zh_cn"]["content"][0][0]["tag"] == "md"
        assert result["zh_cn"]["content"][0][0]["text"] == "Hello World"

    def test_build_post_content_with_images(self, feishu_channel):
        """Should build post content with images."""
        result = feishu_channel._build_post_content(
            "See this:",
            ["img_key_1", "img_key_2"],
        )

        content = result["zh_cn"]["content"]
        assert len(content) == 3  # text + 2 images
        assert content[0][0]["tag"] == "md"
        assert content[1][0]["tag"] == "img"
        assert content[1][0]["image_key"] == "img_key_1"
        assert content[2][0]["tag"] == "img"

    def test_build_post_content_empty(self, feishu_channel):
        """Should handle empty content gracefully."""
        result = feishu_channel._build_post_content("", [])

        # Should have a placeholder
        assert result["zh_cn"]["content"][0][0]["text"] == "[empty]"

    def test_receive_id_store_path_with_workspace(
        self,
        feishu_channel_with_workspace,
        temp_workspace_dir,
    ):
        """Should use workspace directory when available."""
        path = feishu_channel_with_workspace._receive_id_store_path()

        assert path == temp_workspace_dir / "feishu_receive_ids.json"

    def test_get_on_reply_sent_args(self, feishu_channel):
        """Should return user_id and session_id."""
        mock_request = MagicMock()
        mock_request.user_id = "user_123"
        mock_request.session_id = "session_456"

        result = feishu_channel.get_on_reply_sent_args(
            mock_request,
            "to_handle",
        )

        assert result == ("user_123", "session_456")


# =============================================================================
# P0: Enabled Check
# =============================================================================


class TestFeishuChannelEnabledCheck:
    """Tests for enabled/disabled behavior."""

    @pytest.mark.asyncio
    async def test_send_content_parts_returns_none_when_disabled(
        self,
        mock_process_handler,
        temp_media_dir,
    ):
        """send_content_parts should return None when channel disabled."""
        from qwenpaw.app.channels.feishu.channel import FeishuChannel

        channel = FeishuChannel(
            process=mock_process_handler,
            enabled=False,
            app_id="test_id",
            app_secret="test_secret",
            bot_prefix="",
            media_dir=str(temp_media_dir),
        )

        result = await channel.send_content_parts(
            to_handle="feishu:sw:test",
            parts=[],
        )

        assert result is None

    @pytest.mark.asyncio
    async def test_send_returns_none_when_disabled(
        self,
        mock_process_handler,
        temp_media_dir,
    ):
        """send should return None when channel disabled."""
        from qwenpaw.app.channels.feishu.channel import FeishuChannel

        channel = FeishuChannel(
            process=mock_process_handler,
            enabled=False,
            app_id="test_id",
            app_secret="test_secret",
            bot_prefix="",
            media_dir=str(temp_media_dir),
        )

        # Should not raise and should return silently
        await channel.send(
            to_handle="feishu:sw:test",
            text="Hello",
        )

        # If we get here without error, the test passed
        assert True


# =============================================================================
# P1: Build Agent Request
# =============================================================================


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

    def test_build_agent_request_from_native_basic(self, feishu_channel):
        """Should build AgentRequest from native payload."""
        payload = {
            "channel_id": "feishu",
            "sender_id": "sender#1234",
            "user_id": "user#5678",
            "session_id": "session_abc",
            "content_parts": [{"type": "text", "text": "Hello"}],
            "meta": {"feishu_chat_id": "oc_test"},
        }

        result = feishu_channel.build_agent_request_from_native(payload)

        assert hasattr(result, "channel_meta")
        assert result.channel_meta.get("feishu_chat_id") == "oc_test"

    def test_build_agent_request_uses_payload_session_id(self, feishu_channel):
        """Should use session_id from payload when available."""
        payload = {
            "channel_id": "feishu",
            "sender_id": "sender#1234",
            "session_id": "explicit_session",
            "content_parts": [],
            "meta": {},
        }

        result = feishu_channel.build_agent_request_from_native(payload)

        assert result.session_id == "explicit_session"

    def test_build_agent_request_extracts_sender_from_meta(
        self,
        feishu_channel,
    ):
        """Should prefer feishu_sender_id from meta for user_id."""
        payload = {
            "channel_id": "feishu",
            "sender_id": "display#1234",
            "user_id": "fallback#5678",
            "session_id": "session_abc",
            "content_parts": [],
            "meta": {"feishu_sender_id": "ou_real_sender"},
        }

        result = feishu_channel.build_agent_request_from_native(payload)

        assert result.user_id == "ou_real_sender"


# =============================================================================
# P1: Merge Native Items
# =============================================================================


class TestFeishuChannelMergeNativeItems:
    """Tests for merge_native_items method."""

    def test_merge_native_items_concat_content_parts(self, feishu_channel):
        """Should concatenate content_parts from multiple items."""
        items = [
            {
                "channel_id": "feishu",
                "sender_id": "user1",
                "content_parts": [{"type": "text", "text": "Hello "}],
                "meta": {"seq": 1},
            },
            {
                "channel_id": "feishu",
                "sender_id": "user1",
                "content_parts": [{"type": "text", "text": "World"}],
                "meta": {"seq": 2},
            },
        ]

        result = feishu_channel.merge_native_items(items)

        assert len(result["content_parts"]) == 2
        assert result["content_parts"][0]["text"] == "Hello "
        assert result["content_parts"][1]["text"] == "World"

    def test_merge_native_items_empty_list(self, feishu_channel):
        """Should return None for empty list."""
        result = feishu_channel.merge_native_items([])

        assert result is None

    def test_merge_native_items_single_item(self, feishu_channel):
        """Should return merged item for single item."""
        items = [
            {
                "channel_id": "feishu",
                "sender_id": "user1",
                "content_parts": [{"type": "image", "url": "img.jpg"}],
                "meta": {"id": "msg_1"},
            },
        ]

        result = feishu_channel.merge_native_items(items)

        assert len(result["content_parts"]) == 1
        assert result["sender_id"] == "user1"

    def test_merge_native_items_uses_last_sender(self, feishu_channel):
        """Should use sender_id from last item."""
        items = [
            {
                "channel_id": "feishu",
                "sender_id": "old_sender",
                "content_parts": [],
                "meta": {},
            },
            {
                "channel_id": "feishu",
                "sender_id": "new_sender",
                "content_parts": [],
                "meta": {},
            },
        ]

        result = feishu_channel.merge_native_items(items)

        assert result["sender_id"] == "new_sender"


# =============================================================================
# P1: Get Receive for Send
# =============================================================================


class TestFeishuChannelGetReceiveForSend:
    """Tests for _get_receive_for_send method."""

    @pytest.mark.asyncio
    async def test_get_receive_from_meta(self, feishu_channel):
        """Should prefer receive_id from meta."""
        meta = {
            "feishu_receive_id": "ou_from_meta",
            "feishu_receive_id_type": "open_id",
        }

        result = await feishu_channel._get_receive_for_send(
            "feishu:sw:any",
            meta,
        )

        assert result == ("open_id", "ou_from_meta")

    @pytest.mark.asyncio
    async def test_get_receive_from_store(self, feishu_channel):
        """Should load from store when session_key provided."""
        # Pre-populate store
        feishu_channel._receive_id_store["my_session"] = (
            "chat_id",
            "oc_stored_chat",
        )

        result = await feishu_channel._get_receive_for_send(
            "feishu:sw:my_session",
            {},
        )

        assert result == ("chat_id", "oc_stored_chat")

    @pytest.mark.asyncio
    async def test_get_receive_from_direct_chat_id(self, feishu_channel):
        """Should handle direct chat_id."""
        result = await feishu_channel._get_receive_for_send(
            "feishu:chat_id:oc_direct123",
            {},
        )

        assert result == ("chat_id", "oc_direct123")

    @pytest.mark.asyncio
    async def test_get_receive_returns_none_when_not_found(
        self,
        feishu_channel,
    ):
        """Should return None when receive_id cannot be resolved."""
        result = await feishu_channel._get_receive_for_send(
            "feishu:sw:unknown_session",
            {},
        )

        assert result is None


# =============================================================================
# P2: File Upload Size Check
# =============================================================================


class TestFeishuChannelFileUpload:
    """Tests for file upload size checking."""

    @pytest.mark.asyncio
    async def test_upload_file_rejects_too_large(
        self,
        feishu_channel,
        tmp_path,
    ):
        """Should return None for files exceeding max size."""
        from qwenpaw.app.channels.feishu.constants import FEISHU_FILE_MAX_BYTES

        # Create a file just over the limit
        large_file = tmp_path / "large.bin"
        large_file.write_bytes(b"x" * (FEISHU_FILE_MAX_BYTES + 1))

        # Mock client to avoid SDK calls
        feishu_channel._client = MagicMock()

        result = await feishu_channel._upload_file(str(large_file))

        assert result is None


# =============================================================================
# P1: Part to Image Bytes
# =============================================================================


class TestFeishuChannelPartToImageBytes:
    """Tests for _part_to_image_bytes method."""

    @pytest.mark.asyncio
    async def test_part_to_image_bytes_from_base64(self, feishu_channel):
        """Should decode base64 image data."""
        import base64

        part = MagicMock()
        part.image_url = "data:image/png;base64,aGVsbG8="
        part.filename = "test.png"

        data, filename = await feishu_channel._part_to_image_bytes(part)

        assert data == b"hello"
        assert filename == "test.png"

    @pytest.mark.asyncio
    async def test_part_to_image_bytes_invalid_base64(self, feishu_channel):
        """Should handle invalid base64 gracefully."""
        part = MagicMock()
        part.image_url = "data:image/png;base64,!!!invalid!!!"
        part.filename = "test.png"

        data, filename = await feishu_channel._part_to_image_bytes(part)

        assert data is None
        assert filename == "test.png"

    @pytest.mark.asyncio
    async def test_part_to_image_bytes_no_url(self, feishu_channel):
        """Should return (None, filename) when no image_url."""
        part = MagicMock()
        part.image_url = None
        part.filename = "none.png"

        data, filename = await feishu_channel._part_to_image_bytes(part)

        assert data is None
        assert filename == "none.png"


# =============================================================================
# P2: Part to File Path or URL
# =============================================================================


class TestFeishuChannelPartToFilePathOrUrl:
    """Tests for _part_to_file_path_or_url method."""

    @pytest.mark.asyncio
    async def test_part_to_file_path_from_base64(
        self,
        feishu_channel,
        tmp_path,
    ):
        """Should save base64 data to temp file and return path."""
        import base64

        # Use actual media dir
        feishu_channel._media_dir = tmp_path / "media"
        feishu_channel._media_dir.mkdir(parents=True, exist_ok=True)

        test_data = b"test file content"
        b64_data = base64.b64encode(test_data).decode()

        part = MagicMock()
        part.file_url = f"data:application/octet-stream;base64,{b64_data}"
        part.filename = "test.txt"

        result = await feishu_channel._part_to_file_path_or_url(part)

        assert result is not None
        assert Path(result).exists()
        assert Path(result).read_bytes() == test_data

    @pytest.mark.asyncio
    async def test_part_to_file_path_invalid_base64(self, feishu_channel):
        """Should return None for invalid base64."""
        part = MagicMock()
        part.file_url = "data:application/octet-stream;base64,!!!invalid!!!"
        part.filename = "test.txt"

        result = await feishu_channel._part_to_file_path_or_url(part)

        assert result is None

    def test_part_to_file_path_with_local_path(self, feishu_channel, tmp_path):
        """Should return path for existing local file."""
        test_file = tmp_path / "local.txt"
        test_file.write_text("local content")

        part = MagicMock()
        part.file_url = str(test_file)
        part.filename = "local.txt"

        result = asyncio.run(
            feishu_channel._part_to_file_path_or_url(part),
        )

        assert result == str(test_file)

    def test_part_to_file_path_with_http_url(self, feishu_channel):
        """Should return HTTP URL directly."""
        part = MagicMock()
        part.file_url = "https://example.com/file.pdf"
        part.filename = "file.pdf"

        result = asyncio.run(
            feishu_channel._part_to_file_path_or_url(part),
        )

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

    @pytest.mark.asyncio
    async def test_part_to_file_path_with_file_url(
        self,
        feishu_channel,
        tmp_path,
    ):
        """Should handle file:// URLs."""
        test_file = tmp_path / "file_url_test.txt"
        test_file.write_text("content via file url")

        part = MagicMock()
        part.file_url = f"file://{test_file}"
        part.filename = "file_url_test.txt"

        result = await feishu_channel._part_to_file_path_or_url(part)

        assert result == str(test_file)


# =============================================================================
# P2: Load/Store File Backward Compatibility
# =============================================================================


class TestFeishuChannelReceiveIdStoreBackwardCompat:
    """Tests for backward compatibility in receive_id store."""

    @pytest.mark.asyncio
    async def test_load_receive_id_store_backward_compat(
        self,
        feishu_channel_with_workspace,
        temp_workspace_dir,
    ):
        """Should handle old format [receive_id, receive_id_type]."""
        channel = feishu_channel_with_workspace

        # Create file with old format (reversed order)
        store_path = temp_workspace_dir / "feishu_receive_ids.json"
        old_data = {
            "old_session": ["ou_old_user", "open_id"],  # [id, type] old format
        }
        with open(store_path, "w", encoding="utf-8") as f:
            json.dump(old_data, f)

        # Load should normalize to (type, id) format
        channel._load_receive_id_store_from_disk()

        assert "old_session" in channel._receive_id_store
        # Should be stored as (type, id)
        assert channel._receive_id_store["old_session"] == (
            "open_id",
            "ou_old_user",
        )


# =============================================================================
# P2: Fetch Bytes from URL
# =============================================================================


class TestFeishuChannelFetchBytesFromUrl:
    """Tests for _fetch_bytes_from_url method."""

    @pytest.mark.asyncio
    async def test_fetch_bytes_no_http_client(self, feishu_channel):
        """Should return None when http_client not initialized."""
        feishu_channel._http_client = None

        result = await feishu_channel._fetch_bytes_from_url(
            "https://example.com/file.txt",
        )

        assert result is None

    @pytest.mark.asyncio
    async def test_fetch_bytes_from_local_file(self, feishu_channel, tmp_path):
        """Should read local file directly."""
        # Use actual media dir
        feishu_channel._media_dir = tmp_path / "media"
        feishu_channel._media_dir.mkdir(parents=True, exist_ok=True)

        test_file = tmp_path / "test.txt"
        test_file.write_text("file content")

        # Mock http_client to verify it's not used
        feishu_channel._http_client = MagicMock()

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

        assert result == b"file content"
        feishu_channel._http_client.get.assert_not_called()


# =============================================================================
# P0: Get On Reply Sent Args
# =============================================================================


class TestFeishuChannelGetOnReplySentArgs:
    """Tests for get_on_reply_sent_args method."""

    def test_returns_user_id_and_session_id(self, feishu_channel):
        """Should return tuple of (user_id, session_id)."""
        mock_request = MagicMock()
        mock_request.user_id = "u123"
        mock_request.session_id = "s456"

        result = feishu_channel.get_on_reply_sent_args(mock_request, "handle")

        assert result == ("u123", "s456")

    def test_handles_empty_values(self, feishu_channel):
        """Should handle empty values gracefully."""
        mock_request = MagicMock()
        mock_request.user_id = ""
        mock_request.session_id = ""

        result = feishu_channel.get_on_reply_sent_args(mock_request, "handle")

        assert result == ("", "")


# =============================================================================
# P0: Get To Handle From Request
# =============================================================================


class TestFeishuChannelGetToHandleFromRequest:
    """Tests for get_to_handle_from_request method."""

    def test_returns_session_based_handle(self, feishu_channel):
        """Should return feishu:sw: prefix with session_id."""
        mock_request = MagicMock()
        mock_request.session_id = "abc123"
        mock_request.user_id = "user456"

        result = feishu_channel.get_to_handle_from_request(mock_request)

        assert result == "feishu:sw:abc123"

    def test_fallbacks_to_user_id(self, feishu_channel):
        """Should fallback to feishu:open_id: when no session_id."""
        mock_request = MagicMock()
        mock_request.session_id = ""
        mock_request.user_id = "user789"

        result = feishu_channel.get_to_handle_from_request(mock_request)

        assert result == "feishu:open_id:user789"

    def test_returns_empty_when_both_empty(self, feishu_channel):
        """Should return empty string when both are empty."""
        mock_request = MagicMock()
        mock_request.session_id = ""
        mock_request.user_id = ""

        result = feishu_channel.get_to_handle_from_request(mock_request)

        assert result == ""


# =============================================================================
# P1: Complex Method Tests - _on_message
# =============================================================================


class TestFeishuChannelOnMessageComplex:
    """P1: Complex method tests for _on_message.

    Method characteristics:
    - Multi-branch logic for different message types
    - Message deduplication
    - Media download integration
    - Bot mention handling
    """

    @pytest.fixture
    def mock_message_data(self):
        """Create mock message data structure."""
        data = MagicMock()
        data.event = MagicMock()
        data.event.message = MagicMock()
        data.event.message.message_id = "msg_12345"
        data.event.message.chat_id = "chat_67890"
        data.event.message.chat_type = "p2p"
        data.event.message.message_type = "text"
        data.event.message.content = '{"text": "Hello world"}'
        data.event.message.mentions = []
        data.event.sender = MagicMock()
        data.event.sender.sender_type = "user"
        data.event.sender.sender_id = MagicMock()
        data.event.sender.sender_id.open_id = "user_open_id_123"
        data.event.sender.name = "Test User"
        return data

    @pytest.mark.asyncio
    async def test_on_message_text_basic(
        self,
        feishu_channel,
        mock_message_data,
    ):
        """Test handling basic text message."""
        # _process is async generator, verify message marked as processed
        await feishu_channel._on_message(mock_message_data)
        assert "msg_12345" in feishu_channel._processed_message_ids

    @pytest.mark.asyncio
    async def test_on_message_duplicated_message_skipped(
        self,
        feishu_channel,
        mock_message_data,
    ):
        """Test deduplication: same message_id marked as processed."""
        msg_id = "msg_duplicate_test"
        mock_message_data.event.message.message_id = msg_id

        # First call
        await feishu_channel._on_message(mock_message_data)
        # Verify message was tracked
        assert msg_id in feishu_channel._processed_message_ids

        # Second call with same id should still work but not re-process
        await feishu_channel._on_message(mock_message_data)

    @pytest.mark.asyncio
    async def test_on_message_self_bot_sender_skipped(
        self,
        feishu_channel,
        mock_message_data,
    ):
        """Test messages sent by the bot itself are ignored."""
        captured = {}

        def capture_enqueue(native):
            captured["native"] = native

        feishu_channel._enqueue = capture_enqueue
        feishu_channel._bot_open_id = "user_open_id_123"
        mock_message_data.event.sender.sender_type = "bot"

        await feishu_channel._on_message(mock_message_data)

        assert "native" not in captured

    @pytest.mark.asyncio
    async def test_on_message_other_bot_sender_processed(
        self,
        feishu_channel,
        mock_message_data,
    ):
        """Test messages sent by another bot are processed."""
        captured = {}

        def capture_enqueue(native):
            captured["native"] = native

        feishu_channel._enqueue = capture_enqueue
        feishu_channel._bot_open_id = "self_bot_open_id"
        mock_message_data.event.sender.sender_type = "bot"
        mock_message_data.event.sender.sender_id.open_id = "other_bot_open_id"

        await feishu_channel._on_message(mock_message_data)

        assert "native" in captured

    @pytest.mark.asyncio
    async def test_on_message_empty_data_returns_early(self, feishu_channel):
        """Test None data returns early."""
        feishu_channel._process = AsyncMock()
        await feishu_channel._on_message(None)
        feishu_channel._process.assert_not_called()

    @pytest.mark.asyncio
    async def test_on_message_no_event_returns_early(
        self,
        feishu_channel,
        mock_message_data,
    ):
        """Test missing event returns early."""
        feishu_channel._process = AsyncMock()
        mock_message_data.event = None
        await feishu_channel._on_message(mock_message_data)
        feishu_channel._process.assert_not_called()

    @pytest.mark.asyncio
    async def test_on_message_image_type(
        self,
        feishu_channel,
        mock_message_data,
    ):
        """Test image message handling."""
        feishu_channel._download_image_resource = AsyncMock(
            return_value="/path/to/image.jpg",
        )
        mock_message_data.event.message.message_type = "image"
        mock_message_data.event.message.content = '{"image_key": "img_123"}'

        await feishu_channel._on_message(mock_message_data)

        feishu_channel._download_image_resource.assert_called_once_with(
            "msg_12345",
            "img_123",
        )
        # Verify message was tracked
        assert "msg_12345" in feishu_channel._processed_message_ids

    @pytest.mark.asyncio
    async def test_on_message_image_download_failure(
        self,
        feishu_channel,
        mock_message_data,
    ):
        """Test image download failure handling."""
        feishu_channel._download_image_resource = AsyncMock(return_value=None)
        mock_message_data.event.message.message_type = "image"
        mock_message_data.event.message.content = '{"image_key": "img_123"}'

        await feishu_channel._on_message(mock_message_data)

        # Message should still be processed with failure text
        feishu_channel._download_image_resource.assert_called_once()
        assert "msg_12345" in feishu_channel._processed_message_ids

    @pytest.mark.asyncio
    async def test_on_message_file_type(
        self,
        feishu_channel,
        mock_message_data,
    ):
        """Test file message handling."""
        feishu_channel._download_file_resource = AsyncMock(
            return_value="/path/to/file.pdf",
        )
        mock_message_data.event.message.message_type = "file"
        mock_message_data.event.message.content = (
            '{"file_key": "file_123", "file_name": "test.pdf"}'
        )

        await feishu_channel._on_message(mock_message_data)

        feishu_channel._download_file_resource.assert_called_once()
        assert "msg_12345" in feishu_channel._processed_message_ids

    @pytest.mark.asyncio
    async def test_on_message_audio_type(
        self,
        feishu_channel,
        mock_message_data,
    ):
        """Test audio message handling."""
        feishu_channel._download_file_resource = AsyncMock(
            return_value="/path/to/audio.opus",
        )
        mock_message_data.event.message.message_type = "audio"
        mock_message_data.event.message.content = '{"file_key": "audio_123"}'

        await feishu_channel._on_message(mock_message_data)

        feishu_channel._download_file_resource.assert_called_once()
        assert "msg_12345" in feishu_channel._processed_message_ids

    @pytest.mark.asyncio
    async def test_on_message_unknown_type(
        self,
        feishu_channel,
        mock_message_data,
    ):
        """Test unknown message type handling."""
        mock_message_data.event.message.message_type = "unknown_type"

        await feishu_channel._on_message(mock_message_data)

        # Message should still be processed with placeholder text
        assert "msg_12345" in feishu_channel._processed_message_ids

    @pytest.mark.asyncio
    async def test_on_message_with_bot_mention(
        self,
        feishu_channel,
        mock_message_data,
    ):
        """Test bot mention detection and removal."""
        feishu_channel._bot_open_id = "bot_open_id_456"
        mock_message_data.event.message.content = '{"text": "@_user_1 Hello"}'
        # Create proper mock structure for mentions
        mention_mock = MagicMock()
        mention_mock.id = MagicMock()
        mention_mock.id.open_id = "bot_open_id_456"
        mention_mock.key = "@_user_1"
        mock_message_data.event.message.mentions = [mention_mock]

        await feishu_channel._on_message(mock_message_data)

        # Message should still be processed
        assert "msg_12345" in feishu_channel._processed_message_ids

    @pytest.mark.asyncio
    async def test_on_message_post_with_images(
        self,
        feishu_channel,
        mock_message_data,
    ):
        """Test post message handling."""
        feishu_channel._download_image_resource = AsyncMock(
            return_value="/path/to/img.jpg",
        )
        mock_message_data.event.message.message_type = "post"
        # Use a simple post format that actually works with extract_post_text
        content = '{"title": "Test", "content": [[{"tag": "text"}]]}'
        mock_message_data.event.message.content = content

        await feishu_channel._on_message(mock_message_data)

        assert "msg_12345" in feishu_channel._processed_message_ids


# =============================================================================
# P1: Send Methods Tests (Fixed)
# =============================================================================


class TestFeishuChannelSendMethodsFixed:
    """P1: Fixed tests for send methods with correct signatures."""

    @pytest.mark.asyncio
    async def test_send_text_success(self, feishu_channel):
        """Test successful text message send."""
        feishu_channel._send_message = AsyncMock(return_value="msg_id_123")
        feishu_channel._get_tenant_access_token = AsyncMock(
            return_value="token_123",
        )

        body = "Hello world"
        result = await feishu_channel._send_text(
            "open_id",
            "user_open_id",
            body,
        )

        assert result is not None
        feishu_channel._send_message.assert_called_once()

    @pytest.mark.asyncio
    async def test_send_text_with_table(self, feishu_channel):
        """Test text with markdown table sends as card."""
        feishu_channel._send_message = AsyncMock(return_value="msg_id_123")
        feishu_channel._get_tenant_access_token = AsyncMock(
            return_value="token_123",
        )

        body = "| Header |\n|--------|\n| Value |"
        result = await feishu_channel._send_text(
            "open_id",
            "user_open_id",
            body,
        )

        # Should still succeed (method chunks tables into cards)
        assert result is not None


# =============================================================================
# P0: Download Image Resource
# =============================================================================


class TestFeishuChannelDownloadImageResource:
    """Tests for _download_image_resource method.

    Covers:
    - Download success with different image formats
    - SDK failure handling
    - Empty response handling
    - File extension detection
    """

    @pytest.fixture
    def mock_get_message_resource_request(self):
        """Mock GetMessageResourceRequest builder."""
        mock_builder = MagicMock()
        mock_request = MagicMock()
        mock_builder.message_id.return_value = mock_builder
        mock_builder.file_key.return_value = mock_builder
        mock_builder.type.return_value = mock_builder
        mock_builder.build.return_value = mock_request

        with patch(
            "qwenpaw.app.channels.feishu.channel.GetMessageResourceRequest",
        ) as mock_class:
            mock_class.builder.return_value = mock_builder
            yield mock_class, mock_request

    @pytest.mark.asyncio
    async def test_download_image_success(
        self,
        feishu_channel,
        tmp_path,
        mock_get_message_resource_request,
    ):
        """Should download image successfully and return path."""
        from io import BytesIO

        feishu_channel._media_dir = tmp_path / "media"
        feishu_channel._media_dir.mkdir(parents=True, exist_ok=True)

        # Mock SDK response
        mock_response = MagicMock()
        mock_response.success.return_value = True
        mock_response.file = BytesIO(b"fake_image_data_jpg_content")

        mock_client = MagicMock()
        mock_client.im.v1.message_resource.aget = AsyncMock(
            return_value=mock_response,
        )
        feishu_channel._client = mock_client

        result = await feishu_channel._download_image_resource(
            "msg_123",
            "img_key_456",
        )

        assert result is not None
        assert "msg_123" in result
        assert "img_key_456" in result
        assert Path(result).exists()
        assert Path(result).read_bytes() == b"fake_image_data_jpg_content"

    @pytest.mark.asyncio
    async def test_download_image_sdk_failure(
        self,
        feishu_channel,
        mock_get_message_resource_request,
    ):
        """Should return None when SDK call fails."""
        mock_response = MagicMock()
        mock_response.success.return_value = False
        mock_response.code = 400
        mock_response.msg = "Bad Request"

        mock_client = MagicMock()
        mock_client.im.v1.message_resource.aget = AsyncMock(
            return_value=mock_response,
        )
        feishu_channel._client = mock_client

        result = await feishu_channel._download_image_resource(
            "msg_123",
            "img_key_456",
        )

        assert result is None

    @pytest.mark.asyncio
    async def test_download_image_empty_response(
        self,
        feishu_channel,
        mock_get_message_resource_request,
    ):
        """Should return None when response file is empty."""
        from io import BytesIO

        mock_response = MagicMock()
        mock_response.success.return_value = True
        mock_response.file = BytesIO(b"")  # Empty content

        mock_client = MagicMock()
        mock_client.im.v1.message_resource.aget = AsyncMock(
            return_value=mock_response,
        )
        feishu_channel._client = mock_client

        result = await feishu_channel._download_image_resource(
            "msg_123",
            "img_key_456",
        )

        assert result is None

    @pytest.mark.asyncio
    async def test_download_image_exception_handling(
        self,
        feishu_channel,
        mock_get_message_resource_request,
    ):
        """Should handle exceptions gracefully and return None."""
        mock_client = MagicMock()
        mock_client.im.v1.message_resource.aget = AsyncMock(
            side_effect=Exception("Network error"),
        )
        feishu_channel._client = mock_client

        result = await feishu_channel._download_image_resource(
            "msg_123",
            "img_key_456",
        )

        assert result is None

    @pytest.mark.asyncio
    async def test_download_image_sanitizes_key(
        self,
        feishu_channel,
        tmp_path,
        mock_get_message_resource_request,
    ):
        """Should sanitize image key for safe filename."""
        from io import BytesIO

        feishu_channel._media_dir = tmp_path / "media"
        feishu_channel._media_dir.mkdir(parents=True, exist_ok=True)

        mock_response = MagicMock()
        mock_response.success.return_value = True
        mock_response.file = BytesIO(b"fake_image_data")

        mock_client = MagicMock()
        mock_client.im.v1.message_resource.aget = AsyncMock(
            return_value=mock_response,
        )
        feishu_channel._client = mock_client

        # Use key with special characters
        result = await feishu_channel._download_image_resource(
            "msg_123",
            "img<key>with/special:chars",
        )

        assert result is not None
        # Check that special chars are removed or replaced
        assert Path(result).exists()


# =============================================================================
# P0: Download File Resource
# =============================================================================


class TestFeishuChannelDownloadFileResource:
    """Tests for _download_file_resource method.

    Covers:
    - Download success with filename hint
    - SDK failure handling
    - Extension detection from content
    - Empty response handling
    """

    @pytest.fixture
    def mock_get_message_resource_request(self):
        """Mock GetMessageResourceRequest builder."""
        mock_builder = MagicMock()
        mock_request = MagicMock()
        mock_builder.message_id.return_value = mock_builder
        mock_builder.file_key.return_value = mock_builder
        mock_builder.type.return_value = mock_builder
        mock_builder.build.return_value = mock_request

        with patch(
            "qwenpaw.app.channels.feishu.channel.GetMessageResourceRequest",
        ) as mock_class:
            mock_class.builder.return_value = mock_builder
            yield mock_class, mock_request

    @pytest.mark.asyncio
    async def test_download_file_success(
        self,
        feishu_channel,
        tmp_path,
        mock_get_message_resource_request,
    ):
        """Should download file successfully with filename hint."""
        from io import BytesIO

        feishu_channel._media_dir = tmp_path / "media"
        feishu_channel._media_dir.mkdir(parents=True, exist_ok=True)

        mock_response = MagicMock()
        mock_response.success.return_value = True
        mock_response.file = BytesIO(b"pdf_file_content_with_pdf_signature")

        mock_client = MagicMock()
        mock_client.im.v1.message_resource.aget = AsyncMock(
            return_value=mock_response,
        )
        feishu_channel._client = mock_client

        result = await feishu_channel._download_file_resource(
            "msg_123",
            "file_key_456",
            "document.pdf",
        )

        assert result is not None
        assert "document.pdf" in result
        assert Path(result).exists()

    @pytest.mark.asyncio
    async def test_download_file_sdk_failure(
        self,
        feishu_channel,
        mock_get_message_resource_request,
    ):
        """Should return None when SDK call fails."""
        mock_response = MagicMock()
        mock_response.success.return_value = False
        mock_response.code = 403
        mock_response.msg = "Permission denied"

        mock_client = MagicMock()
        mock_client.im.v1.message_resource.aget = AsyncMock(
            return_value=mock_response,
        )
        feishu_channel._client = mock_client

        result = await feishu_channel._download_file_resource(
            "msg_123",
            "file_key_456",
        )

        assert result is None

    @pytest.mark.asyncio
    async def test_download_file_empty_response(
        self,
        feishu_channel,
        mock_get_message_resource_request,
    ):
        """Should return None when response file is empty."""
        from io import BytesIO

        mock_response = MagicMock()
        mock_response.success.return_value = True
        mock_response.file = BytesIO(b"")

        mock_client = MagicMock()
        mock_client.im.v1.message_resource.aget = AsyncMock(
            return_value=mock_response,
        )
        feishu_channel._client = mock_client

        result = await feishu_channel._download_file_resource(
            "msg_123",
            "file_key_456",
        )

        assert result is None

    @pytest.mark.asyncio
    async def test_download_file_detects_extension(
        self,
        feishu_channel,
        tmp_path,
        mock_get_message_resource_request,
    ):
        """Should detect file extension from content when hint is generic."""
        from io import BytesIO

        feishu_channel._media_dir = tmp_path / "media"
        feishu_channel._media_dir.mkdir(parents=True, exist_ok=True)

        mock_response = MagicMock()
        mock_response.success.return_value = True
        # JPEG signature
        mock_response.file = BytesIO(
            b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00",
        )

        mock_client = MagicMock()
        mock_client.im.v1.message_resource.aget = AsyncMock(
            return_value=mock_response,
        )
        feishu_channel._client = mock_client

        result = await feishu_channel._download_file_resource(
            "msg_123",
            "file_key_456",
            "file.bin",
        )

        assert result is not None
        assert Path(result).exists()
        # Should detect extension from content
        assert result.endswith((".jpg", ".jpeg", ".bin"))

    @pytest.mark.asyncio
    async def test_download_file_exception_handling(
        self,
        feishu_channel,
        mock_get_message_resource_request,
    ):
        """Should handle exceptions gracefully and return None."""
        mock_client = MagicMock()
        mock_client.im.v1.message_resource.aget = AsyncMock(
            side_effect=Exception("Download failed"),
        )
        feishu_channel._client = mock_client

        result = await feishu_channel._download_file_resource(
            "msg_123",
            "file_key_456",
        )

        assert result is None


# =============================================================================
# P0: Upload Image
# =============================================================================


class TestFeishuChannelUploadImage:
    """Tests for _upload_image method.

    Covers:
    - Upload success
    - SDK failure handling
    - Missing client handling
    - Response data extraction
    """

    @pytest.fixture
    def mock_create_image_request(self):
        """Mock CreateImageRequest and CreateImageRequestBody builder."""
        mock_body_builder = MagicMock()
        mock_body = MagicMock()
        mock_body_builder.image_type.return_value = mock_body_builder
        mock_body_builder.image.return_value = mock_body_builder
        mock_body_builder.build.return_value = mock_body

        mock_request_builder = MagicMock()
        mock_request = MagicMock()
        mock_request_builder.request_body.return_value = mock_request_builder
        mock_request_builder.build.return_value = mock_request

        with (
            patch(
                "qwenpaw.app.channels.feishu.channel.CreateImageRequestBody",
            ) as mock_body_class,
            patch(
                "qwenpaw.app.channels.feishu.channel.CreateImageRequest",
            ) as mock_request_class,
        ):
            mock_body_class.builder.return_value = mock_body_builder
            mock_request_class.builder.return_value = mock_request_builder
            yield mock_request_class, mock_request

    @pytest.mark.asyncio
    async def test_upload_image_success(
        self,
        feishu_channel,
        mock_create_image_request,
    ):
        """Should upload image successfully and return image_key."""
        mock_response = MagicMock()
        mock_response.success.return_value = True
        mock_response.data = MagicMock()
        mock_response.data.image_key = "img_key_abc123"

        mock_client = MagicMock()
        mock_client.im.v1.image.acreate = AsyncMock(return_value=mock_response)
        feishu_channel._client = mock_client

        result = await feishu_channel._upload_image(
            b"fake_image_data",
            "test.png",
        )

        assert result == "img_key_abc123"

    @pytest.mark.asyncio
    async def test_upload_image_no_client(self, feishu_channel):
        """Should return None when client is not initialized."""
        feishu_channel._client = None

        result = await feishu_channel._upload_image(
            b"fake_image_data",
            "test.png",
        )

        assert result is None

    @pytest.mark.asyncio
    async def test_upload_image_sdk_failure(
        self,
        feishu_channel,
        mock_create_image_request,
    ):
        """Should return None when SDK upload fails."""
        mock_response = MagicMock()
        mock_response.success.return_value = False
        mock_response.code = 413
        mock_response.msg = "File too large"

        mock_client = MagicMock()
        mock_client.im.v1.image.acreate = AsyncMock(return_value=mock_response)
        feishu_channel._client = mock_client

        result = await feishu_channel._upload_image(
            b"fake_image_data",
            "test.png",
        )

        assert result is None

    @pytest.mark.asyncio
    async def test_upload_image_exception_handling(
        self,
        feishu_channel,
        mock_create_image_request,
    ):
        """Should handle exceptions gracefully and return None."""
        mock_client = MagicMock()
        mock_client.im.v1.image.acreate = AsyncMock(
            side_effect=Exception("Upload failed"),
        )
        feishu_channel._client = mock_client

        result = await feishu_channel._upload_image(
            b"fake_image_data",
            "test.png",
        )

        assert result is None


# =============================================================================
# P0: Upload File
# =============================================================================


class TestFeishuChannelUploadFile:
    """Tests for _upload_file method.

    Covers:
    - Upload success with different file types
    - File type detection
    - Large file rejection
    - URL download and upload
    - SDK failure handling
    """

    @pytest.fixture
    def mock_create_file_request(self):
        """Mock CreateFileRequest and CreateFileRequestBody builder."""
        mock_body_builder = MagicMock()
        mock_body = MagicMock()
        mock_body_builder.file_type.return_value = mock_body_builder
        mock_body_builder.file_name.return_value = mock_body_builder
        mock_body_builder.file.return_value = mock_body_builder
        mock_body_builder.build.return_value = mock_body

        mock_request_builder = MagicMock()
        mock_request = MagicMock()
        mock_request_builder.request_body.return_value = mock_request_builder
        mock_request_builder.build.return_value = mock_request

        with (
            patch(
                "qwenpaw.app.channels.feishu.channel.CreateFileRequestBody",
            ) as mock_body_class,
            patch(
                "qwenpaw.app.channels.feishu.channel.CreateFileRequest",
            ) as mock_request_class,
        ):
            mock_body_class.builder.return_value = mock_body_builder
            mock_request_class.builder.return_value = mock_request_builder
            yield mock_request_class, mock_request

    @pytest.mark.asyncio
    async def test_upload_file_success_doc_types(
        self,
        feishu_channel,
        tmp_path,
        mock_create_file_request,
    ):
        """Should upload local file successfully with correct file_type."""
        feishu_channel._media_dir = tmp_path / "media"
        feishu_channel._media_dir.mkdir(parents=True, exist_ok=True)

        # Create test files with different extensions
        test_files = [
            ("test.pdf", b"pdf content", "pdf"),
            ("test.doc", b"doc content", "doc"),
            ("test.docx", b"docx content", "doc"),
            ("test.xls", b"xls content", "xls"),
            ("test.xlsx", b"xlsx content", "xls"),
            ("test.ppt", b"ppt content", "ppt"),
            ("test.pptx", b"pptx content", "ppt"),
        ]

        for filename, content, expected_type in test_files:
            test_file = tmp_path / filename
            test_file.write_bytes(content)

            mock_response = MagicMock()
            mock_response.success.return_value = True
            mock_response.data = MagicMock()
            mock_response.data.file_key = f"file_key_{filename}"

            mock_client = MagicMock()
            mock_client.im.v1.file.acreate = AsyncMock(
                return_value=mock_response,
            )
            feishu_channel._client = mock_client

            result = await feishu_channel._upload_file(str(test_file))

            assert result == f"file_key_{filename}"

    @pytest.mark.asyncio
    async def test_upload_file_rejects_large_file(
        self,
        feishu_channel,
        tmp_path,
    ):
        """Should return None for files exceeding max size."""
        from qwenpaw.app.channels.feishu.constants import FEISHU_FILE_MAX_BYTES

        feishu_channel._media_dir = tmp_path / "media"
        feishu_channel._media_dir.mkdir(parents=True, exist_ok=True)

        # Create oversized file
        large_file = tmp_path / "large.bin"
        large_file.write_bytes(b"x" * (FEISHU_FILE_MAX_BYTES + 1))

        feishu_channel._client = MagicMock()

        result = await feishu_channel._upload_file(str(large_file))

        assert result is None

    @pytest.mark.asyncio
    async def test_upload_file_from_http_url(
        self,
        feishu_channel,
        tmp_path,
        mock_create_file_request,
    ):
        """Should download from URL and upload."""
        feishu_channel._media_dir = tmp_path / "media"
        feishu_channel._media_dir.mkdir(parents=True, exist_ok=True)

        # Mock HTTP client for URL fetch
        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.content = b"downloaded_file_content"

        mock_http_client = MagicMock()
        mock_http_client.get = AsyncMock(return_value=mock_response)
        feishu_channel._http_client = mock_http_client

        mock_sdk_response = MagicMock()
        mock_sdk_response.success.return_value = True
        mock_sdk_response.data = MagicMock()
        mock_sdk_response.data.file_key = "file_key_from_url"

        mock_client = MagicMock()
        mock_client.im.v1.file.acreate = AsyncMock(
            return_value=mock_sdk_response,
        )
        feishu_channel._client = mock_client

        result = await feishu_channel._upload_file(
            "https://example.com/file.txt",
        )

        assert result == "file_key_from_url"

    @pytest.mark.asyncio
    async def test_upload_file_missing_file(self, feishu_channel):
        """Should return None for non-existent file."""
        feishu_channel._client = MagicMock()
        feishu_channel._http_client = MagicMock()

        result = await feishu_channel._upload_file(
            "/nonexistent/path/file.txt",
        )

        assert result is None

    @pytest.mark.asyncio
    async def test_upload_file_sdk_failure(
        self,
        feishu_channel,
        tmp_path,
        mock_create_file_request,
    ):
        """Should return None when SDK upload fails."""
        feishu_channel._media_dir = tmp_path / "media"
        feishu_channel._media_dir.mkdir(parents=True, exist_ok=True)

        test_file = tmp_path / "test.txt"
        test_file.write_text("test content")

        mock_response = MagicMock()
        mock_response.success.return_value = False
        mock_response.code = 500
        mock_response.msg = "Internal error"

        mock_client = MagicMock()
        mock_client.im.v1.file.acreate = AsyncMock(return_value=mock_response)
        feishu_channel._client = mock_client

        result = await feishu_channel._upload_file(str(test_file))

        assert result is None

    @pytest.mark.asyncio
    async def test_upload_file_stream_type(
        self,
        feishu_channel,
        tmp_path,
        mock_create_file_request,
    ):
        """Should use 'stream' file_type for unknown extensions."""
        feishu_channel._media_dir = tmp_path / "media"
        feishu_channel._media_dir.mkdir(parents=True, exist_ok=True)

        test_file = tmp_path / "unknown.xyz"
        test_file.write_bytes(b"some content")

        mock_response = MagicMock()
        mock_response.success.return_value = True
        mock_response.data = MagicMock()
        mock_response.data.file_key = "file_key_xyz"

        mock_client = MagicMock()
        mock_client.im.v1.file.acreate = AsyncMock(return_value=mock_response)
        feishu_channel._client = mock_client

        result = await feishu_channel._upload_file(str(test_file))

        assert result == "file_key_xyz"


# =============================================================================
# P0: Send Message
# =============================================================================


class TestFeishuChannelSendMessage:
    """Tests for _send_message and _send_text methods.

    Covers:
    - Send success with different message types
    - Failure handling
    - Interactive card message
    - Missing client handling
    """

    @pytest.fixture
    def mock_create_message_request(self):
        """Mock CreateMessageRequest and CreateMessageRequestBody builder."""
        mock_body_builder = MagicMock()
        mock_body = MagicMock()
        mock_body_builder.receive_id.return_value = mock_body_builder
        mock_body_builder.msg_type.return_value = mock_body_builder
        mock_body_builder.content.return_value = mock_body_builder
        mock_body_builder.build.return_value = mock_body

        mock_request_builder = MagicMock()
        mock_request = MagicMock()
        mock_request_builder.receive_id_type.return_value = (
            mock_request_builder
        )
        mock_request_builder.request_body.return_value = mock_request_builder
        mock_request_builder.build.return_value = mock_request

        with (
            patch(
                "qwenpaw.app.channels.feishu.channel.CreateMessageRequestBody",
            ) as mock_body_class,
            patch(
                "qwenpaw.app.channels.feishu.channel.CreateMessageRequest",
            ) as mock_request_class,
        ):
            mock_body_class.builder.return_value = mock_body_builder
            mock_request_class.builder.return_value = mock_request_builder
            yield mock_request_class, mock_request

    @pytest.mark.asyncio
    async def test_send_message_success_post(
        self,
        feishu_channel,
        mock_create_message_request,
    ):
        """Should send post message successfully."""
        mock_response = MagicMock()
        mock_response.success.return_value = True
        mock_response.data = MagicMock()
        mock_response.data.message_id = "msg_id_abc123"

        mock_client = MagicMock()
        mock_client.im.v1.message.acreate = AsyncMock(
            return_value=mock_response,
        )
        feishu_channel._client = mock_client

        content = (
            '{"zh_cn": {"content": [[{"tag": "text", "text": "Hello"}]]}}'
        )
        result = await feishu_channel._send_message(
            "open_id",
            "user_open_id",
            "post",
            content,
        )

        assert result == "msg_id_abc123"

    @pytest.mark.asyncio
    async def test_send_message_success_image(
        self,
        feishu_channel,
        mock_create_message_request,
    ):
        """Should send image message successfully."""
        mock_response = MagicMock()
        mock_response.success.return_value = True
        mock_response.data = MagicMock()
        mock_response.data.message_id = "msg_id_img_123"

        mock_client = MagicMock()
        mock_client.im.v1.message.acreate = AsyncMock(
            return_value=mock_response,
        )
        feishu_channel._client = mock_client

        content = '{"image_key": "img_key_123"}'
        result = await feishu_channel._send_message(
            "open_id",
            "user_open_id",
            "image",
            content,
        )

        assert result == "msg_id_img_123"

    @pytest.mark.asyncio
    async def test_send_message_success_file(
        self,
        feishu_channel,
        mock_create_message_request,
    ):
        """Should send file message successfully."""
        mock_response = MagicMock()
        mock_response.success.return_value = True
        mock_response.data = MagicMock()
        mock_response.data.message_id = "msg_id_file_123"

        mock_client = MagicMock()
        mock_client.im.v1.message.acreate = AsyncMock(
            return_value=mock_response,
        )
        feishu_channel._client = mock_client

        content = '{"file_key": "file_key_123"}'
        result = await feishu_channel._send_message(
            "open_id",
            "user_open_id",
            "file",
            content,
        )

        assert result == "msg_id_file_123"

    @pytest.mark.asyncio
    async def test_send_message_success_interactive(
        self,
        feishu_channel,
        mock_create_message_request,
    ):
        """Should send interactive card message successfully."""
        mock_response = MagicMock()
        mock_response.success.return_value = True
        mock_response.data = MagicMock()
        mock_response.data.message_id = "msg_id_card_123"

        mock_client = MagicMock()
        mock_client.im.v1.message.acreate = AsyncMock(
            return_value=mock_response,
        )
        feishu_channel._client = mock_client

        content = '{"config": {}, "elements": []}'
        result = await feishu_channel._send_message(
            "chat_id",
            "oc_group_123",
            "interactive",
            content,
        )

        assert result == "msg_id_card_123"

    @pytest.mark.asyncio
    async def test_send_message_failure(
        self,
        feishu_channel,
        mock_create_message_request,
    ):
        """Should return None when SDK send fails."""
        mock_response = MagicMock()
        mock_response.success.return_value = False
        mock_response.code = 400
        mock_response.msg = "Invalid receive_id"

        mock_client = MagicMock()
        mock_client.im.v1.message.acreate = AsyncMock(
            return_value=mock_response,
        )
        feishu_channel._client = mock_client

        content = '{"text": "Hello"}'
        result = await feishu_channel._send_message(
            "open_id",
            "invalid_user",
            "text",
            content,
        )

        assert result is None

    @pytest.mark.asyncio
    async def test_send_message_no_client(self, feishu_channel):
        """Should return None when client is not initialized."""
        feishu_channel._client = None

        result = await feishu_channel._send_message(
            "open_id",
            "user_open_id",
            "text",
            '{"text": "Hello"}',
        )

        assert result is None

    @pytest.mark.asyncio
    async def test_send_message_exception_handling(
        self,
        feishu_channel,
        mock_create_message_request,
    ):
        """Should handle exceptions gracefully and return None."""
        mock_client = MagicMock()
        mock_client.im.v1.message.acreate = AsyncMock(
            side_effect=Exception("Send failed"),
        )
        feishu_channel._client = mock_client

        result = await feishu_channel._send_message(
            "open_id",
            "user_open_id",
            "text",
            '{"text": "Hello"}',
        )

        assert result is None


class TestFeishuChannelSendText:
    """Tests for _send_text method specifically."""

    @pytest.mark.asyncio
    async def test_send_text_basic(self, feishu_channel):
        """Should send basic text as post message."""
        feishu_channel._send_message = AsyncMock(return_value="msg_id_123")

        result = await feishu_channel._send_text(
            "open_id",
            "user_open_id",
            "Hello world",
        )

        assert result == "msg_id_123"
        feishu_channel._send_message.assert_called_once()

    @pytest.mark.asyncio
    async def test_send_text_with_markdown_table(self, feishu_channel):
        """Should send text with table as interactive card."""
        feishu_channel._send_message = AsyncMock(return_value="msg_id_card")

        body = "| Header 1 | Header 2 |\n|----------|----------|\n"
        body += "| Cell 1 | Cell 2 |"
        result = await feishu_channel._send_text(
            "open_id",
            "user_open_id",
            body,
        )

        assert result == "msg_id_card"
        # Should be sent as interactive type
        call_args = feishu_channel._send_message.call_args
        assert call_args[0][2] == "interactive"  # msg_type

    @pytest.mark.asyncio
    async def test_send_text_empty_body(self, feishu_channel):
        """Should handle empty body."""
        feishu_channel._send_message = AsyncMock(return_value="msg_id_empty")

        result = await feishu_channel._send_text(
            "open_id",
            "user_open_id",
            "",
        )

        assert result == "msg_id_empty"

    @pytest.mark.asyncio
    async def test_send_text_exception_handling(self, feishu_channel):
        """Should propagate exceptions from _send_message since _send_text
        does not have a try-except block. In real usage, _send_message
        catches its own exceptions and returns None."""
        feishu_channel._send_message = AsyncMock(
            side_effect=Exception("Send failed"),
        )

        # _send_text does not wrap _send_message in try-except,
        # so exception propagates
        with pytest.raises(Exception, match="Send failed"):
            await feishu_channel._send_text(
                "open_id",
                "user_open_id",
                "Hello",
            )


# =============================================================================
# P2: Exception Path Tests
# =============================================================================


class TestFeishuChannelExceptions:
    """P2: Exception handling tests."""

    @pytest.mark.asyncio
    async def test_fetch_bytes_http_error_response(self, feishu_channel):
        """Test fetch bytes with HTTP error response."""
        mock_response = MagicMock()
        mock_response.status_code = 404
        mock_response.text = "Not found"

        mock_client = MagicMock()
        mock_client.get = AsyncMock(return_value=mock_response)

        with patch("httpx.AsyncClient") as mock_client_class:
            mock_client_class.return_value.__aenter__ = AsyncMock(
                return_value=mock_client,
            )
            mock_client_class.return_value.__aexit__ = AsyncMock(
                return_value=None,
            )

            result = await feishu_channel._fetch_bytes_from_url(
                "https://example.com/file.jpg",
            )

            assert result is None


# =============================================================================
# P0: Thread (Topic) Reply Support
# =============================================================================


class TestFeishuChannelThreadReply:
    """Tests for thread/topic reply functionality.

    Covers:
    - _on_message extracts thread_id into meta
    - user_id overridden to thread:{short_id} for topic messages
    - _reply_in_thread method
    - send_content_parts thread reply path (text, image, file)
    - on_streaming_start skips thread messages
    - _before_consume_process skips streaming card pre-creation for threads
    """

    # -------------------------------------------------------------------------
    # _on_message: thread_id extraction and user_id override
    # -------------------------------------------------------------------------

    @pytest.fixture
    def mock_thread_message_data(self):
        """Create mock message data with thread_id."""
        data = MagicMock()
        data.event = MagicMock()
        data.event.message = MagicMock()
        data.event.message.message_id = "msg_thread_001"
        data.event.message.chat_id = "oc_thread_group"
        data.event.message.chat_type = "group"
        data.event.message.message_type = "text"
        data.event.message.content = '{"text": "Hello in thread"}'
        data.event.message.thread_id = "omt_thread_root_abc123"
        data.event.message.parent_id = ""
        data.event.message.mentions = []
        data.event.sender = MagicMock()
        data.event.sender.sender_type = "user"
        data.event.sender.sender_id = MagicMock()
        data.event.sender.sender_id.open_id = "ou_user_in_thread"
        data.event.sender.name = "Thread User"
        return data

    @pytest.fixture
    def mock_reply_message_request(self):
        """Patch ReplyMessageRequest and ReplyMessageRequestBody."""
        mock_builder = MagicMock()
        mock_request = MagicMock()
        mock_builder.message_id.return_value = mock_builder
        mock_builder.request_body.return_value = mock_builder
        mock_builder.build.return_value = mock_request

        mock_body_builder = MagicMock()
        mock_body = MagicMock()
        mock_body_builder.msg_type.return_value = mock_body_builder
        mock_body_builder.content.return_value = mock_body_builder
        mock_body_builder.reply_in_thread.return_value = mock_body_builder
        mock_body_builder.uuid.return_value = mock_body_builder
        mock_body_builder.build.return_value = mock_body

        with (
            patch(
                "qwenpaw.app.channels.feishu.channel.ReplyMessageRequest",
            ) as mock_request_class,
            patch(
                "qwenpaw.app.channels.feishu.channel.ReplyMessageRequestBody",
            ) as mock_body_class,
        ):
            mock_request_class.builder.return_value = mock_builder
            mock_body_class.builder.return_value = mock_body_builder
            yield

    @pytest.mark.asyncio
    async def test_on_message_extracts_thread_id_to_meta(
        self,
        feishu_channel,
        mock_thread_message_data,
    ):
        """Should extract thread_id into channel_meta."""
        # Use a container to capture the enqueued native payload
        captured = {}

        def capture_enqueue(native):
            captured["native"] = native

        feishu_channel._enqueue = capture_enqueue

        await feishu_channel._on_message(mock_thread_message_data)

        assert "native" in captured
        meta = captured["native"].get("meta", {})
        assert meta.get("feishu_thread_id") == "omt_thread_root_abc123"

    @pytest.mark.asyncio
    async def test_on_message_no_thread_id_not_in_meta(
        self,
        feishu_channel,
    ):
        """Should not include thread_id in meta when not present."""
        data = MagicMock()
        data.event = MagicMock()
        data.event.message = MagicMock()
        data.event.message.message_id = "msg_no_thread"
        data.event.message.chat_id = "oc_no_thread"
        data.event.message.chat_type = "group"
        data.event.message.message_type = "text"
        data.event.message.content = '{"text": "Normal message"}'
        data.event.message.thread_id = ""
        data.event.message.mentions = []
        data.event.sender = MagicMock()
        data.event.sender.sender_type = "user"
        data.event.sender.sender_id = MagicMock()
        data.event.sender.sender_id.open_id = "ou_normal_user"
        data.event.sender.name = "Normal User"

        captured = {}

        def capture_enqueue(native):
            captured["native"] = native

        feishu_channel._enqueue = capture_enqueue

        await feishu_channel._on_message(data)

        assert "native" in captured
        meta = captured["native"].get("meta", {})
        assert "feishu_thread_id" not in meta

    @pytest.mark.asyncio
    async def test_on_message_thread_overrides_user_id(
        self,
        feishu_channel,
        mock_thread_message_data,
    ):
        """Should override user_id to thread:{short_id} for thread msgs."""
        captured = {}

        def capture_enqueue(native):
            captured["native"] = native

        feishu_channel._enqueue = capture_enqueue

        await feishu_channel._on_message(mock_thread_message_data)

        assert "native" in captured
        native = captured["native"]
        # user_id should be thread:{shortened_thread_id}
        assert native["user_id"].startswith("thread:")
        assert "abc123" in native["user_id"]

    @pytest.mark.asyncio
    async def test_on_message_thread_overrides_shared_mode(
        self,
        feishu_channel,
        mock_thread_message_data,
    ):
        """Thread override should win over shared group mode."""
        feishu_channel.group_session_mode = "shared"
        captured = {}

        def capture_enqueue(native):
            captured["native"] = native

        feishu_channel._enqueue = capture_enqueue

        await feishu_channel._on_message(mock_thread_message_data)

        assert "native" in captured
        native = captured["native"]
        # Must be thread:..., not group:...
        assert native["user_id"].startswith("thread:")

    # -------------------------------------------------------------------------
    # _reply_in_thread method
    # -------------------------------------------------------------------------

    @pytest.mark.asyncio
    async def test_reply_in_thread_success(
        self,
        feishu_channel,
        mock_reply_message_request,
    ):
        """Should call reply API and return message_id."""
        mock_resp = MagicMock()
        mock_resp.success.return_value = True
        mock_resp.data = MagicMock()
        mock_resp.data.message_id = "reply_msg_001"

        mock_client = MagicMock()
        mock_client.im.v1.message.areply = AsyncMock(
            return_value=mock_resp,
        )
        feishu_channel._client = mock_client

        result = await feishu_channel._reply_in_thread(
            "omt_root_123",
            "post",
            '{"zh_cn": {"content": []}}',
        )

        assert result == "reply_msg_001"
        mock_client.im.v1.message.areply.assert_called_once()

    @pytest.mark.asyncio
    async def test_reply_in_thread_no_client(self, feishu_channel):
        """Should return None when client is not initialized."""
        feishu_channel._client = None

        result = await feishu_channel._reply_in_thread(
            "omt_root_123",
            "post",
            '{"zh_cn": {"content": []}}',
        )

        assert result is None

    @pytest.mark.asyncio
    async def test_reply_in_thread_empty_message_id(self, feishu_channel):
        """Should return None when message_id is empty."""
        feishu_channel._client = MagicMock()

        result = await feishu_channel._reply_in_thread(
            "",
            "post",
            '{"zh_cn": {"content": []}}',
        )

        assert result is None

    @pytest.mark.asyncio
    async def test_reply_in_thread_sdk_failure(self, feishu_channel):
        """Should return None when SDK reply fails."""
        mock_resp = MagicMock()
        mock_resp.success.return_value = False
        mock_resp.code = 10003
        mock_resp.msg = "Bad request"

        mock_client = MagicMock()
        mock_client.im.v1.message.areply = AsyncMock(
            return_value=mock_resp,
        )
        feishu_channel._client = mock_client

        result = await feishu_channel._reply_in_thread(
            "omt_root_123",
            "post",
            '{"zh_cn": {"content": []}}',
        )

        assert result is None

    @pytest.mark.asyncio
    async def test_reply_in_thread_exception(self, feishu_channel):
        """Should handle exceptions gracefully and return None."""
        mock_client = MagicMock()
        mock_client.im.v1.message.areply = AsyncMock(
            side_effect=Exception("Reply failed"),
        )
        feishu_channel._client = mock_client

        result = await feishu_channel._reply_in_thread(
            "omt_root_123",
            "post",
            '{"zh_cn": {"content": []}}',
        )

        assert result is None

    # -------------------------------------------------------------------------
    # send_content_parts: thread reply path
    # -------------------------------------------------------------------------

    @pytest.mark.asyncio
    async def test_send_content_parts_thread_text(self, feishu_channel):
        """Should use _reply_in_thread for text in thread mode."""
        feishu_channel._reply_in_thread = AsyncMock(
            return_value="thread_reply_id",
        )

        result = await feishu_channel.send_content_parts(
            to_handle="feishu:sw:test_session",
            parts=[
                MagicMock(
                    type=ContentType.TEXT,
                    text="Hello in thread",
                    spec=OutgoingContentPart,
                ),
            ],
            meta={
                "feishu_thread_id": "omt_thread_root",
                "feishu_message_id": "msg_root_001",
                "feishu_receive_id": "oc_group_123",
                "feishu_receive_id_type": "chat_id",
            },
        )

        assert result == "thread_reply_id"
        feishu_channel._reply_in_thread.assert_called_once()

    @pytest.mark.asyncio
    async def test_send_content_parts_thread_image(self, feishu_channel):
        """Should upload image and reply in thread for image parts."""
        feishu_channel._part_to_image_bytes = AsyncMock(
            return_value=(b"fake_image_data", "test.png"),
        )
        feishu_channel._upload_image = AsyncMock(
            return_value="img_key_thread",
        )
        feishu_channel._reply_in_thread = AsyncMock(
            return_value="thread_img_reply",
        )

        part = MagicMock(spec=OutgoingContentPart)
        part.type = ContentType.IMAGE
        part.image_url = "data:image/png;base64,aGVsbG8="
        part.filename = "test.png"

        result = await feishu_channel.send_content_parts(
            to_handle="feishu:sw:test_session",
            parts=[part],
            meta={
                "feishu_thread_id": "omt_thread_root",
                "feishu_message_id": "msg_root_001",
                "feishu_receive_id": "oc_group_123",
                "feishu_receive_id_type": "chat_id",
            },
        )

        assert result == "thread_img_reply"
        feishu_channel._upload_image.assert_called_once_with(
            b"fake_image_data",
            "test.png",
        )
        feishu_channel._reply_in_thread.assert_called_once()

    @pytest.mark.asyncio
    async def test_send_content_parts_thread_file(self, feishu_channel):
        """Should upload file and reply in thread for file parts."""
        feishu_channel._part_to_file_path_or_url = AsyncMock(
            return_value="/tmp/test_doc.pdf",
        )
        feishu_channel._upload_file = AsyncMock(
            return_value="file_key_thread",
        )
        feishu_channel._reply_in_thread = AsyncMock(
            return_value="thread_file_reply",
        )

        part = MagicMock(spec=OutgoingContentPart)
        part.type = ContentType.FILE
        part.file_url = "file:///tmp/test_doc.pdf"
        part.filename = "test_doc.pdf"

        result = await feishu_channel.send_content_parts(
            to_handle="feishu:sw:test_session",
            parts=[part],
            meta={
                "feishu_thread_id": "omt_thread_root",
                "feishu_message_id": "msg_root_001",
                "feishu_receive_id": "oc_group_123",
                "feishu_receive_id_type": "chat_id",
            },
        )

        assert result == "thread_file_reply"
        feishu_channel._upload_file.assert_called_once()
        feishu_channel._reply_in_thread.assert_called_once()

    @pytest.mark.asyncio
    async def test_send_content_parts_thread_audio_file(self, feishu_channel):
        """Should use audio msg_type for ogg/opus files in thread."""
        feishu_channel._part_to_file_path_or_url = AsyncMock(
            return_value="/tmp/audio.opus",
        )
        feishu_channel._upload_file = AsyncMock(
            return_value="file_key_audio",
        )
        feishu_channel._reply_in_thread = AsyncMock(
            return_value="thread_audio_reply",
        )

        part = MagicMock(spec=OutgoingContentPart)
        part.type = ContentType.AUDIO
        part.file_url = "file:///tmp/audio.opus"
        part.filename = "audio.opus"

        result = await feishu_channel.send_content_parts(
            to_handle="feishu:sw:test_session",
            parts=[part],
            meta={
                "feishu_thread_id": "omt_thread_root",
                "feishu_message_id": "msg_root_001",
                "feishu_receive_id": "oc_group_123",
                "feishu_receive_id_type": "chat_id",
            },
        )

        assert result == "thread_audio_reply"
        feishu_channel._reply_in_thread.assert_called_once()

    @pytest.mark.asyncio
    async def test_send_content_parts_no_thread_uses_normal_path(
        self,
        feishu_channel,
    ):
        """Without thread_id, normal send path should be used."""
        feishu_channel._send_text = AsyncMock(return_value="normal_msg_id")

        result = await feishu_channel.send_content_parts(
            to_handle="feishu:sw:test_session",
            parts=[
                MagicMock(
                    type=ContentType.TEXT,
                    text="Normal message",
                    spec=OutgoingContentPart,
                ),
            ],
            meta={
                "feishu_receive_id": "oc_group_123",
                "feishu_receive_id_type": "chat_id",
            },
        )

        assert result == "normal_msg_id"
        feishu_channel._send_text.assert_called_once()

    # -------------------------------------------------------------------------
    # Streaming: skip for thread messages
    # -------------------------------------------------------------------------

    @pytest.mark.asyncio
    async def test_on_streaming_start_skips_thread(self, feishu_channel):
        """on_streaming_start should skip when feishu_thread_id is set."""
        feishu_channel.streaming_enabled = True
        feishu_channel._get_receive_for_send = AsyncMock(
            return_value=("chat_id", "oc_test"),
        )

        await feishu_channel.on_streaming_start(
            request=MagicMock(),
            to_handle="feishu:sw:test",
            event=MagicMock(),
            send_meta={"feishu_thread_id": "omt_thread_root"},
            stream_type="message",
        )

        feishu_channel._get_receive_for_send.assert_not_called()

    @pytest.mark.asyncio
    async def test_on_streaming_start_proceeds_without_thread(
        self,
        feishu_channel,
    ):
        """Without thread_id, normal streaming start should proceed."""
        feishu_channel.streaming_enabled = True
        feishu_channel._get_receive_for_send = AsyncMock(
            return_value=("chat_id", "oc_test"),
        )
        feishu_channel._create_streaming_card = AsyncMock(
            return_value={"card_id": "card_001", "message_id": "msg_001"},
        )

        await feishu_channel.on_streaming_start(
            request=MagicMock(),
            to_handle="feishu:sw:test",
            event=MagicMock(),
            send_meta={},
            stream_type="message",
        )

        feishu_channel._get_receive_for_send.assert_called_once()

    @pytest.mark.asyncio
    async def test_before_consume_process_skips_streaming_for_thread(
        self,
        feishu_channel,
    ):
        """_before_consume_process should skip card pre-creation for thread."""
        feishu_channel.streaming_enabled = True
        feishu_channel._create_streaming_card = AsyncMock(
            return_value={"card_id": "card_001"},
        )

        request = MagicMock()
        request.session_id = "test_session"
        request.channel_meta = {
            "feishu_receive_id": "oc_test",
            "feishu_receive_id_type": "chat_id",
            "feishu_thread_id": "omt_thread_root",
        }

        await feishu_channel._before_consume_process(request)

        feishu_channel._create_streaming_card.assert_not_called()


# =============================================================================
# Tests for extract_interactive_text (utils)
# =============================================================================


# pylint: disable=unsupported-membership-test
class TestExtractInteractiveText:
    """Unit tests for extract_interactive_text()."""

    def test_extracts_title_and_elements(self):
        from qwenpaw.app.channels.feishu.utils import extract_interactive_text

        payload = json.dumps(
            {
                "title": "Card Title",
                "elements": [
                    [{"tag": "text", "text": "Hello world"}],
                ],
            },
        )
        result = extract_interactive_text(payload)
        assert result is not None
        assert "Card Title" in result
        assert "Hello world" in result

    def test_cardkit_v2_body_elements(self):
        """CardKit v2 nests elements under body — must still extract."""
        from qwenpaw.app.channels.feishu.utils import extract_interactive_text

        payload = json.dumps(
            {
                "header": {"title": {"content": "V2 Card"}},
                "body": {
                    "elements": [
                        {"tag": "text", "text": "Body content here"},
                    ],
                },
            },
        )
        result = extract_interactive_text(payload)
        assert result is not None
        assert "V2 Card" in result
        assert "Body content here" in result

    def test_extracts_links_as_markdown(self):
        from qwenpaw.app.channels.feishu.utils import extract_interactive_text

        payload = json.dumps(
            {
                "elements": [
                    [
                        {
                            "tag": "a",
                            "text": "Click me",
                            "href": "https://example.com",
                        },
                    ],
                ],
            },
        )
        result = extract_interactive_text(payload)
        assert result is not None
        assert "[Click me](https://example.com)" in result

    def test_returns_none_for_empty_or_invalid(self):
        from qwenpaw.app.channels.feishu.utils import extract_interactive_text

        assert extract_interactive_text(None) is None
        assert extract_interactive_text("") is None
        assert extract_interactive_text("{broken") is None
        assert extract_interactive_text(json.dumps({"other": "data"})) is None

    def test_title_only(self):
        from qwenpaw.app.channels.feishu.utils import extract_interactive_text

        result = extract_interactive_text(json.dumps({"title": "Hello"}))
        assert result == "Hello"

    def test_header_title_content(self):
        from qwenpaw.app.channels.feishu.utils import extract_interactive_text

        payload = json.dumps(
            {
                "header": {"title": {"content": "Header Title"}},
            },
        )
        result = extract_interactive_text(payload)
        assert result == "Header Title"


# =============================================================================
# Tests for _parse_message_content (channel)
# =============================================================================


class TestParseMessageContent:
    """Tests for the shared _parse_message_content engine.

    Returns (main_text, error_hints, content_parts).
    """

    @pytest.mark.asyncio
    async def test_text_basic(self, feishu_channel):
        (
            main_text,
            error_hints,
            content_parts,
        ) = await feishu_channel._parse_message_content(
            "text",
            '{"text": "Hello world"}',
            "msg_001",
        )
        assert main_text == "Hello world"
        assert error_hints == []
        assert content_parts == []

    @pytest.mark.asyncio
    async def test_text_empty(self, feishu_channel):
        (
            main_text,
            error_hints,
            _,
        ) = await feishu_channel._parse_message_content(
            "text",
            '{"text": ""}',
            "msg_002",
        )
        assert main_text is None
        assert error_hints == []

    @pytest.mark.asyncio
    async def test_text_whitespace_only(self, feishu_channel):
        main_text, _, _ = await feishu_channel._parse_message_content(
            "text",
            '{"text": "   "}',
            "msg_003",
        )
        assert main_text is None

    @pytest.mark.asyncio
    async def test_post_basic(self, feishu_channel):
        content = json.dumps(
            {
                "content": [[{"tag": "text", "text": "Post body"}]],
            },
        )
        (
            main_text,
            error_hints,
            _,
        ) = await feishu_channel._parse_message_content(
            "post",
            content,
            "msg_010",
        )
        assert main_text is not None
        assert "Post body" in main_text
        assert error_hints == []

    @pytest.mark.asyncio
    async def test_image_missing_key(self, feishu_channel):
        (
            main_text,
            error_hints,
            content_parts,
        ) = await feishu_channel._parse_message_content(
            "image",
            '{"other": "val"}',
            "msg_020",
        )
        assert main_text is None
        assert "[image: missing key]" in error_hints
        assert content_parts == []

    @pytest.mark.asyncio
    async def test_image_download_success(self, feishu_channel):
        feishu_channel._download_image_resource = AsyncMock(
            return_value="/tmp/img.jpg",
        )
        (
            main_text,
            error_hints,
            content_parts,
        ) = await feishu_channel._parse_message_content(
            "image",
            '{"image_key": "img_abc"}',
            "msg_021",
        )
        assert main_text is None
        assert error_hints == []
        assert len(content_parts) == 1
        assert content_parts[0].image_url == "/tmp/img.jpg"

    @pytest.mark.asyncio
    async def test_file_missing_key(self, feishu_channel):
        _, error_hints, _ = await feishu_channel._parse_message_content(
            "file",
            '{"other": "val"}',
            "msg_030",
        )
        assert "[file: missing key]" in error_hints

    @pytest.mark.asyncio
    async def test_audio_download_success(self, feishu_channel):
        feishu_channel._download_file_resource = AsyncMock(
            return_value="/tmp/audio.opus",
        )
        (
            main_text,
            error_hints,
            content_parts,
        ) = await feishu_channel._parse_message_content(
            "audio",
            '{"file_key": "file_abc"}',
            "msg_031",
        )
        assert main_text is None
        assert error_hints == []
        assert len(content_parts) == 1
        assert content_parts[0].type == ContentType.AUDIO

    @pytest.mark.asyncio
    async def test_interactive_basic(self, feishu_channel):
        content = json.dumps(
            {
                "header": {"title": {"content": "Card Title"}},
                "body": {"elements": [{"tag": "text", "text": "Card body"}]},
            },
        )
        (
            main_text,
            error_hints,
            _,
        ) = await feishu_channel._parse_message_content(
            "interactive",
            content,
            "msg_040",
        )
        assert main_text is not None
        assert "Card Title" in main_text
        assert "Card body" in main_text
        assert error_hints == []

    @pytest.mark.asyncio
    async def test_interactive_empty_returns_none(self, feishu_channel):
        content = json.dumps({"other": "data"})
        main_text, _, _ = await feishu_channel._parse_message_content(
            "interactive",
            content,
            "msg_041",
        )
        assert main_text is None

    @pytest.mark.asyncio
    async def test_unknown_type_returns_empty(self, feishu_channel):
        (
            main_text,
            error_hints,
            content_parts,
        ) = await feishu_channel._parse_message_content(
            "sticker",
            "{}",
            "msg_099",
        )
        assert main_text is None
        assert error_hints == []
        assert content_parts == []


# =============================================================================
# Tests for _process_quoted_message (channel)
# =============================================================================


class TestProcessQuotedMessage:
    """Tests for _process_quoted_message."""

    @pytest.mark.asyncio
    async def test_quoted_text_message(self, feishu_channel):
        feishu_channel._fetch_quoted_message_content = AsyncMock(
            return_value=("text", '{"text": "Original message"}'),
        )
        text_parts = ["My reply"]
        content_parts = []
        await feishu_channel._process_quoted_message(
            "parent_123",
            text_parts,
            content_parts,
        )
        assert text_parts[0] == "[quoted message: Original message]"
        assert text_parts[1] == "My reply"

    @pytest.mark.asyncio
    async def test_quoted_image_with_label(self, feishu_channel):
        feishu_channel._fetch_quoted_message_content = AsyncMock(
            return_value=("image", '{"image_key": "img_abc"}'),
        )
        feishu_channel._download_image_resource = AsyncMock(
            return_value="/tmp/img.jpg",
        )
        text_parts = ["Reply text"]
        content_parts = []
        await feishu_channel._process_quoted_message(
            "parent_456",
            text_parts,
            content_parts,
        )
        # Pure image — should still add a label
        assert text_parts[0] == "[quoted image]"
        assert text_parts[1] == "Reply text"
        assert len(content_parts) == 1

    @pytest.mark.asyncio
    async def test_quoted_interactive_card(self, feishu_channel):
        card_content = json.dumps(
            {
                "header": {"title": {"content": "Card Title"}},
                "body": {"elements": [{"tag": "text", "text": "Card body"}]},
            },
        )
        feishu_channel._fetch_quoted_message_content = AsyncMock(
            return_value=("interactive", card_content),
        )
        text_parts = ["My reply"]
        content_parts = []
        await feishu_channel._process_quoted_message(
            "parent_789",
            text_parts,
            content_parts,
        )
        assert "quoted interactive card:" in text_parts[0]
        assert "Card Title" in text_parts[0]

    @pytest.mark.asyncio
    async def test_quoted_fetch_failure_no_change(self, feishu_channel):
        feishu_channel._fetch_quoted_message_content = AsyncMock(
            return_value=None,
        )
        text_parts = ["My reply"]
        content_parts = []
        await feishu_channel._process_quoted_message(
            "parent_000",
            text_parts,
            content_parts,
        )
        assert text_parts == ["My reply"]
        assert not content_parts

    @pytest.mark.asyncio
    async def test_quoted_error_hints_preserved(self, feishu_channel):
        feishu_channel._fetch_quoted_message_content = AsyncMock(
            return_value=("image", '{"other": "no key"}'),
        )
        text_parts = ["Reply"]
        content_parts = []
        await feishu_channel._process_quoted_message(
            "parent_err",
            text_parts,
            content_parts,
        )
        assert text_parts[0] == "[quoted image]"
        assert any("missing key" in t for t in text_parts)

    @pytest.mark.asyncio
    async def test_quoted_lines_order_preserved(self, feishu_channel):
        """Error hints after post with failed downloads stay ordered."""
        content = json.dumps(
            {
                "content": [[{"tag": "text", "text": "Post text"}]],
            },
        )
        feishu_channel._fetch_quoted_message_content = AsyncMock(
            return_value=("post", content),
        )
        text_parts = ["Reply"]
        content_parts = []
        await feishu_channel._process_quoted_message(
            "parent_order",
            text_parts,
            content_parts,
        )
        # quoted label should be first, reply should be last
        assert text_parts[0].startswith("[quoted message:")
        assert text_parts[-1] == "Reply"
