# 中文注释：
# 文件：echobot/channels/dingtalk.py
# 说明：DingTalk 渠道适配，使用 Stream Mode 接收消息。

"""DingTalk channel implementation using Stream Mode."""

import asyncio
import json
import mimetypes
import re
import time
from pathlib import Path
from typing import Any

import httpx
from loguru import logger

from echobot.bus.events import OutboundMessage
from echobot.bus.queue import MessageBus
from echobot.channels.base import BaseChannel
from echobot.config.schema import DingTalkConfig

try:
    from dingtalk_stream import (
        AckMessage,
        CallbackHandler,
        CallbackMessage,
        Credential,
        DingTalkStreamClient,
    )
    from dingtalk_stream.chatbot import ChatbotMessage

    DINGTALK_AVAILABLE = True
except ImportError:
    DINGTALK_AVAILABLE = False
    CallbackHandler = object  # type: ignore[assignment,misc]
    CallbackMessage = None  # type: ignore[assignment,misc]
    AckMessage = None  # type: ignore[assignment,misc]
    ChatbotMessage = None  # type: ignore[assignment,misc]


def _extract_dingtalk_payload(payload: dict[str, Any]) -> tuple[str, list[str], str]:
    """
    Parse DingTalk payload into (content, image_download_codes, message_type).

    DingTalk picture/richText messages may not contain plain text. In that case,
    return a placeholder content so the message won't be dropped.
    """
    message_type = str(payload.get("msgtype") or "").strip()
    text_parts: list[str] = []
    image_codes: list[str] = []

    text_obj = payload.get("text")
    if isinstance(text_obj, dict):
        text = text_obj.get("content")
        if isinstance(text, str) and text.strip():
            text_parts.append(text.strip())

    content_obj = payload.get("content")
    if isinstance(content_obj, dict):
        code = content_obj.get("downloadCode")
        if isinstance(code, str) and code.strip():
            image_codes.append(code.strip())

        rich_text = content_obj.get("richText")
        if isinstance(rich_text, list):
            for item in rich_text:
                if not isinstance(item, dict):
                    continue
                item_text = item.get("text")
                if isinstance(item_text, str) and item_text.strip():
                    text_parts.append(item_text.strip())
                item_code = item.get("downloadCode")
                if isinstance(item_code, str) and item_code.strip():
                    image_codes.append(item_code.strip())

    # Keep ordering while deduplicating.
    dedup_codes = list(dict.fromkeys(image_codes))
    content = "\n".join(text_parts).strip()

    if not content and dedup_codes:
        content = "[图片消息]"
    elif not content and message_type:
        content = f"[{message_type}消息]"

    return content, dedup_codes, message_type


def _resolve_dingtalk_chat_route(
    sender_id: str,
    conversation_id: str | None,
    conversation_type: str | None,
) -> tuple[str, str]:
    """
    Resolve normalized chat_id/chat_type for DingTalk routing.

    - conversationType == "2": group chat
    - otherwise: private chat
    """
    conv_type = str(conversation_type or "").strip()
    chat_type = "group" if conv_type == "2" else "private"
    chat_id = str(conversation_id or sender_id)
    return chat_id, chat_type


def _is_webhook_expired(expired_time: int | None) -> bool:
    """Return True when webhook expiry is known and already passed."""
    if not expired_time:
        return False
    # DingTalk returns milliseconds timestamp for sessionWebhookExpiredTime.
    return int(time.time() * 1000) >= int(expired_time)


_CID_QUERY_COMMANDS = {
    "cid",
    "/cid",
    "查询cid",
    "查cid",
    "查看cid",
}


def _is_cid_query_command(content: str | None) -> bool:
    """Return True when message asks for direct CID query."""
    normalized = re.sub(r"\s+", "", str(content or "")).lower()
    return normalized in _CID_QUERY_COMMANDS


def _build_cid_query_reply(
    chat_id: str,
    chat_type: str,
    conversation_id: str | None,
) -> str:
    """Build a ready-to-copy CID binding help message."""
    cid = str(chat_id or "")
    conv_id = str(conversation_id or "")
    conv_display = conv_id or "(empty)"
    return (
        "CID 查询结果\n"
        f"- chat_id: {cid}\n"
        f"- conversation_id: {conv_display}\n"
        f"- chat_type: {chat_type}\n\n"
        "绑定示例（将 <agent_id> 替换为你的 Agent ID）：\n"
        f'echobot agents bind <agent_id> --channel dingtalk --chat-type {chat_type} --chat-id "{cid}"'
    )


class EchobotDingTalkHandler(CallbackHandler):
    """DingTalk Stream SDK callback handler."""

    def __init__(self, channel: "DingTalkChannel"):
        super().__init__()
        self.channel = channel

    async def process(self, message: CallbackMessage):
        """Process inbound stream message and forward to channel callback."""
        try:
            chatbot_msg = ChatbotMessage.from_dict(message.data)
            robot_code = (
                str((message.data or {}).get("robotCode") or "")
                or str(getattr(chatbot_msg, "robot_code", "") or "")
            )

            content, image_codes, message_type = _extract_dingtalk_payload(message.data or {})
            if not content and not image_codes:
                logger.warning("Received empty or unsupported DingTalk message")
                return AckMessage.STATUS_OK, "OK"

            sender_id = chatbot_msg.sender_staff_id or chatbot_msg.sender_id
            sender_name = chatbot_msg.sender_nick or "Unknown"
            conversation_id = str(getattr(chatbot_msg, "conversation_id", "") or "")
            conversation_type = str(getattr(chatbot_msg, "conversation_type", "") or "")
            session_webhook = str(getattr(chatbot_msg, "session_webhook", "") or "")
            webhook_expired_time = getattr(chatbot_msg, "session_webhook_expired_time", None)
            if webhook_expired_time is not None:
                try:
                    webhook_expired_time = int(webhook_expired_time)
                except (TypeError, ValueError):
                    webhook_expired_time = None

            task = asyncio.create_task(
                self.channel._on_message(
                    content=content,
                    sender_id=sender_id,
                    sender_name=sender_name,
                    image_codes=image_codes,
                    message_type=message_type,
                    robot_code=robot_code,
                    conversation_id=conversation_id,
                    conversation_type=conversation_type,
                    session_webhook=session_webhook,
                    webhook_expired_time=webhook_expired_time,
                )
            )
            self.channel._background_tasks.add(task)
            task.add_done_callback(self.channel._background_tasks.discard)

            return AckMessage.STATUS_OK, "OK"
        except Exception as e:
            logger.error(f"Error processing DingTalk message: {e}")
            # Return OK to avoid DingTalk retry storm.
            return AckMessage.STATUS_OK, "Error"


class DingTalkChannel(BaseChannel):
    """
    DingTalk channel (Stream Mode).

    - inbound: DingTalk Stream SDK callback
    - outbound: HTTP OpenAPI send
    """

    name = "dingtalk"

    def __init__(self, config: DingTalkConfig, bus: MessageBus):
        super().__init__(config, bus)
        self.config: DingTalkConfig = config
        self._client: Any = None
        self._http: httpx.AsyncClient | None = None
        self._access_token: str | None = None
        self._token_expiry: float = 0
        self._background_tasks: set[asyncio.Task] = set()
        # chat_id -> webhook/session context for correct reply target (group/private)
        self._reply_contexts: dict[str, dict[str, Any]] = {}

    async def start(self) -> None:
        """Start DingTalk stream listener."""
        if not DINGTALK_AVAILABLE:
            logger.error("DingTalk SDK not installed. Run: pip install dingtalk-stream")
            return
        if not self.config.client_id or not self.config.client_secret:
            logger.error("DingTalk client_id/client_secret not configured")
            return

        self._running = True
        self._http = httpx.AsyncClient()
        credential = Credential(self.config.client_id, self.config.client_secret)
        self._client = DingTalkStreamClient(credential)
        self._client.register_callback_handler(ChatbotMessage.TOPIC, EchobotDingTalkHandler(self))

        logger.info("Starting DingTalk bot (stream mode)...")

        while self._running:
            try:
                await self._client.start()
            except Exception as e:
                logger.warning(f"DingTalk stream error: {e}")
            if self._running:
                logger.info("Reconnecting DingTalk stream in 5 seconds...")
                await asyncio.sleep(5)

    async def stop(self) -> None:
        """Stop channel and cleanup resources."""
        self._running = False
        if self._http:
            await self._http.aclose()
            self._http = None
        for task in list(self._background_tasks):
            task.cancel()
        self._background_tasks.clear()

    async def _get_access_token(self) -> str | None:
        """Fetch/refresh DingTalk access token."""
        if self._access_token and time.time() < self._token_expiry:
            return self._access_token

        if not self._http:
            return None

        try:
            resp = await self._http.post(
                "https://api.dingtalk.com/v1.0/oauth2/accessToken",
                json={
                    "appKey": self.config.client_id,
                    "appSecret": self.config.client_secret,
                },
            )
            resp.raise_for_status()
            data = resp.json()
            self._access_token = data.get("accessToken")
            self._token_expiry = time.time() + int(data.get("expireIn", 7200)) - 60
            return self._access_token
        except Exception as e:
            logger.error(f"Failed to get DingTalk access token: {e}")
            return None

    async def send(self, msg: OutboundMessage) -> None:
        """Send outbound message to DingTalk user."""
        if not self._http:
            return

        # Prefer replying via sessionWebhook so group mentions reply in group.
        context = self._reply_contexts.get(str(msg.chat_id), {})
        webhook = str(context.get("session_webhook") or "")
        webhook_expired_time = context.get("webhook_expired_time")
        if webhook and not _is_webhook_expired(webhook_expired_time):
            try:
                resp = await self._http.post(
                    webhook,
                    headers={
                        "Content-Type": "application/json",
                        "Accept": "*/*",
                    },
                    json={
                        "msgtype": "text",
                        "text": {"content": msg.content},
                    },
                )
                if resp.status_code == 200:
                    return
                logger.warning(
                    "DingTalk sessionWebhook reply failed "
                    f"(status={resp.status_code}, body={resp.text[:300]})"
                )
            except Exception as e:
                logger.warning(f"DingTalk sessionWebhook reply error: {e}")

        token = await self._get_access_token()
        if not token:
            return

        # Fallback: one-to-one OpenAPI send.
        target_user_id = str(context.get("sender_id") or msg.chat_id)
        try:
            resp = await self._http.post(
                "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend",
                headers={"x-acs-dingtalk-access-token": token},
                json={
                    "robotCode": self.config.client_id,
                    "userIds": [target_user_id],
                    "msgKey": "sampleMarkdown",
                    "msgParam": json.dumps(
                        {"title": "echobot", "text": msg.content},
                        ensure_ascii=False,
                    ),
                },
            )
            if resp.status_code != 200:
                logger.error(f"DingTalk send failed: {resp.text}")
        except Exception as e:
            logger.error(f"Error sending DingTalk message: {e}")

    async def _download_image_by_code(self, download_code: str, robot_code: str | None = None) -> str | None:
        """Download DingTalk image by downloadCode and return local file path."""
        if not download_code or not self._http:
            return None

        token = await self._get_access_token()
        if not token:
            return None

        candidates = [robot_code, self.config.client_id]
        tried: list[tuple[str, int, str]] = []

        try:
            download_url = None
            for candidate in dict.fromkeys([c for c in candidates if c]):
                url_resp = await self._http.post(
                    "https://api.dingtalk.com/v1.0/robot/messageFiles/download",
                    headers={"x-acs-dingtalk-access-token": token},
                    json={
                        "robotCode": candidate,
                        "downloadCode": download_code,
                    },
                )
                if url_resp.status_code == 200:
                    download_url = (url_resp.json() or {}).get("downloadUrl")
                    if download_url:
                        break
                tried.append((candidate, url_resp.status_code, url_resp.text[:500]))

            if not download_url:
                if tried:
                    # Log the last failed attempt for fast debugging.
                    rc, st, body = tried[-1]
                    logger.error(
                        "DingTalk messageFiles/download failed "
                        f"(robotCode={rc}, status={st}, body={body})"
                    )
                return None

            if not download_url:
                logger.warning("DingTalk image downloadUrl missing")
                return None

            file_resp = await self._http.get(download_url)
            if file_resp.status_code != 200:
                logger.error(
                    "DingTalk image file download failed "
                    f"(status={file_resp.status_code}, url={download_url})"
                )
                return None

            content_type = (file_resp.headers.get("content-type") or "").split(";")[0].strip().lower()
            ext = mimetypes.guess_extension(content_type) or ".jpg"

            media_dir = Path.home() / ".echobot" / "media"
            media_dir.mkdir(parents=True, exist_ok=True)
            filename = f"dingtalk_{int(time.time() * 1000)}_{download_code[:8]}{ext}"
            file_path = media_dir / filename
            file_path.write_bytes(file_resp.content)
            return str(file_path)
        except Exception as e:
            logger.error(f"Failed to download DingTalk image ({download_code[:8]}...): {e}")
            return None

    async def _download_images(self, image_codes: list[str], robot_code: str | None = None) -> list[str]:
        """Download all DingTalk images and return local file paths."""
        media_paths: list[str] = []
        for code in image_codes:
            path = await self._download_image_by_code(code, robot_code=robot_code)
            if path:
                media_paths.append(path)
        return media_paths

    async def _on_message(
        self,
        content: str,
        sender_id: str,
        sender_name: str,
        image_codes: list[str] | None = None,
        message_type: str | None = None,
        robot_code: str | None = None,
        conversation_id: str | None = None,
        conversation_type: str | None = None,
        session_webhook: str | None = None,
        webhook_expired_time: int | None = None,
    ) -> None:
        """
        Handle inbound DingTalk message and publish to bus.

        Notes:
        - chat_id uses sender_id for 1:1 reply routing.
        - mention metadata follows existing multi-agent routing convention.
        """
        media_paths = await self._download_images(image_codes or [], robot_code=robot_code)
        if not content and media_paths:
            content = "[图片消息]"

        mention = None
        match = re.search(r"@([a-zA-Z0-9_\-]+)", content or "")
        if match:
            mention = match.group(1)

        chat_id, chat_type = _resolve_dingtalk_chat_route(
            sender_id=sender_id,
            conversation_id=conversation_id,
            conversation_type=conversation_type,
        )
        # Save context for downstream reply routing.
        self._reply_contexts[chat_id] = {
            "sender_id": sender_id,
            "conversation_id": conversation_id or "",
            "conversation_type": conversation_type or "",
            "session_webhook": session_webhook or "",
            "webhook_expired_time": webhook_expired_time,
            "robot_code": robot_code or "",
        }

        if _is_cid_query_command(content):
            await self.bus.publish_outbound(
                OutboundMessage(
                    channel=self.name,
                    chat_id=chat_id,
                    content=_build_cid_query_reply(
                        chat_id=chat_id,
                        chat_type=chat_type,
                        conversation_id=conversation_id,
                    ),
                )
            )
            return

        await self._handle_message(
            sender_id=sender_id,
            chat_id=chat_id,
            content=str(content),
            media=media_paths,
            metadata={
                "sender_name": sender_name,
                "chat_type": chat_type,
                "mention": mention,
                "platform": "dingtalk",
                "message_type": message_type or "unknown",
                "image_count": len(media_paths),
                "conversation_id": conversation_id or "",
                "conversation_type": conversation_type or "",
            },
        )
