"""钉钉 IM 适配器 (U12)。

实现 :class:`MessageAdapter` 协议，对接钉钉企业内机器人 outgoing/webhook 回调。

关键设计决策：
- 签名校验 fail-closed：``Sign`` + ``Timestamp`` 头缺失时仅当 token 校验通过才放行。
- 时间戳窗口 3600s（钉钉官方窗口 1 小时）。
- ``accessToken`` 简单 TTL 缓存（7200s）；钉钉 token 有效期 2 小时。
- httpx 客户端懒构造，避免未使用的适配器持有连接池。
- 钉钉无 URL verification challenge 流程 — 合法签名请求直接 200。
"""

from __future__ import annotations

import base64
import hashlib
import hmac
import json
import logging
import re
import time

import httpx

from agentkit.channels.base import (
    ChannelType,
    IncomingMessage,
    MessageAdapter,
    OutgoingMessage,
    header_get,
)

logger = logging.getLogger(__name__)

# 签名时间戳允许的最大偏移（秒）— 钉钉官方窗口 1 小时
_SIGNATURE_MAX_AGE_SECONDS = 3600
# accessToken 缓存 TTL（秒）— 钉钉 token 有效期 2 小时
_TOKEN_CACHE_TTL = 7200.0

# 钉钉 API 端点
_ACCESS_TOKEN_URL = "https://api.dingtalk.com/v1.0/oauth2/accessToken"
_SEND_MESSAGE_URL = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend"

# 钉钉 @ 机器人前缀剥离 — 内容形如 "@robotName 实际消息"
# ponytail: 简单正则剥离首个 @token。天花板：无法区分 @ 机器人与 @ 用户；
# 升级路径：从 config 注入 robot_name 精确匹配。
_MENTION_PREFIX_RE = re.compile(r"^@\S+\s*")


class DingTalkMessageAdapter(MessageAdapter):
    """钉钉 IM 适配器。

    生命周期：
        ``__init__`` → :meth:`verify_signature` → :meth:`receive_message`
        → :meth:`send_message` → :meth:`close`

    Args:
        app_key: 钉钉应用 App Key。
        app_secret: 钉钉应用 App Secret（同时作为签名密钥）。
        robot_code: 机器人 robotCode（发送消息时使用）。
        token: 可选 token 校验值（部分机器人通过 ``Token`` 头校验）。
    """

    def __init__(
        self,
        app_key: str,
        app_secret: str,
        robot_code: str,
        token: str | None = None,
    ) -> None:
        super().__init__()
        self.app_key = app_key
        self.app_secret = app_secret
        self.robot_code = robot_code
        self.token = token
        # ponytail: 简单 TTL 缓存 (token, expiry)。天花板：单实例内存；
        # 升级路径：Redis 缓存共享给多实例。
        self._token_cache: tuple[str, float] | None = None

    # ------------------------------------------------------------------
    # 签名验证
    # ------------------------------------------------------------------

    async def verify_signature(self, headers: dict[str, str], body: bytes) -> bool:
        """验证钉钉 webhook 签名。

        - 若配置了 ``token``：校验 ``Token`` 头，不匹配返回 False。
        - 若存在 ``Sign`` + ``Timestamp`` 头：校验 HMAC-SHA256 签名与时间戳新鲜度。
        - 两者皆无：仅当 token 已校验通过才放行（token-only 模式）。

        Args:
            headers: HTTP 请求头（键大小写不敏感查找）。
            body: 原始请求体字节（钉钉签名不依赖 body）。

        Returns:
            True 表示签名校验通过。
        """
        # Token 校验（若配置）
        if self.token is not None:
            token_header = header_get(headers, "Token")
            if token_header is None or not hmac.compare_digest(token_header, self.token):
                return False

        sign = header_get(headers, "Sign")
        timestamp_str = header_get(headers, "Timestamp")

        if sign is None and timestamp_str is None:
            # 无签名头：仅当 token 已校验通过才放行
            return self.token is not None

        if sign is None or timestamp_str is None:
            return False

        # 时间戳新鲜度（毫秒 → 秒）
        try:
            ts_ms = int(timestamp_str)
        except ValueError:
            return False
        ts_sec = ts_ms / 1000.0
        now = time.time()
        if abs(now - ts_sec) > _SIGNATURE_MAX_AGE_SECONDS:
            logger.warning("钉钉 webhook 时间戳超出 %ds 窗口 — 拒绝", _SIGNATURE_MAX_AGE_SECONDS)
            return False

        # 计算签名：base64(hmac-sha256(key=app_secret, msg="{timestamp}\n{app_secret}"))
        string_to_sign = f"{timestamp_str}\n{self.app_secret}"
        expected = base64.b64encode(
            hmac.new(
                self.app_secret.encode("utf-8"),
                string_to_sign.encode("utf-8"),
                hashlib.sha256,
            ).digest()
        ).decode("utf-8")
        return hmac.compare_digest(sign, expected)

    # ------------------------------------------------------------------
    # 消息接收 / 解析
    # ------------------------------------------------------------------

    async def receive_message(self, headers: dict[str, str], body: bytes) -> IncomingMessage:
        """解析钉钉 webhook 事件为标准化 :class:`IncomingMessage`。

        钉钉无 URL verification challenge 流程 — 合法请求直接解析为消息。

        Raises:
            ValueError: 事件 body 不是合法 JSON。
        """
        try:
            data: dict[str, object] = json.loads(body)
        except json.JSONDecodeError as exc:
            raise ValueError(f"钉钉事件 body 不是合法 JSON: {exc}") from exc

        chat_id = data.get("conversationId", "")
        user_id = data.get("senderStaffId") or data.get("senderId") or data.get("staffId") or ""
        msg_id = data.get("msgId", "")
        session_expired = data.get("sessionWebhookExpiredTime", "")
        timestamp = str(session_expired) if session_expired else ""

        content = self._extract_content(data)

        return IncomingMessage(
            channel=ChannelType.DINGTALK,
            platform_message_id=msg_id,
            user_id=user_id,
            chat_id=chat_id,
            content=content,
            raw_event=data,
            timestamp=timestamp,
        )

    def _extract_content(self, data: dict[str, object]) -> str:
        """从钉钉事件提取文本内容。

        - text 类型：解析 ``text.content``，剥离 @ 机器人前缀。
        - 其他类型：返回 ``[unsupported message type: {type}]``。
        """
        msgtype = data.get("msgtype", "")
        if msgtype == "text":
            text_obj = data.get("text", {})
            content = text_obj.get("content", "") if isinstance(text_obj, dict) else ""
            # 剥离 @ 机器人前缀
            return _MENTION_PREFIX_RE.sub("", content, count=1).strip()
        return f"[unsupported message type: {msgtype}]"

    # ------------------------------------------------------------------
    # 消息发送
    # ------------------------------------------------------------------

    async def send_message(self, message: OutgoingMessage) -> bool:
        """向钉钉发送文本消息（oToMessages/batchSend 单聊）。

        Returns:
            True 表示 HTTP 200。
        """
        try:
            token = await self._get_access_token()
            if not token:
                return False

            client = self._get_client()
            payload = {
                "robotCode": self.robot_code,
                "conversationId": message.chat_id,
                "msgKey": "sampleText",
                "msgParam": json.dumps({"content": message.content}),
            }
            resp = await client.post(
                _SEND_MESSAGE_URL,
                json=payload,
                headers={"x-acs-dingtalk-access-token": token},
            )
            if resp.status_code != 200:
                logger.error("钉钉 send_message HTTP %d: %s", resp.status_code, resp.text[:200])
                return False
            return True
        except httpx.HTTPError as exc:
            logger.error("钉钉 send_message 网络错误: %s", exc)
            return False

    async def _get_access_token(self) -> str | None:
        """获取并缓存钉钉 ``accessToken``。"""
        # 命中缓存
        if self._token_cache is not None:
            token, expiry = self._token_cache
            if time.monotonic() < expiry:
                return token

        try:
            client = self._get_client()
            resp = await client.post(
                _ACCESS_TOKEN_URL,
                json={"appKey": self.app_key, "appSecret": self.app_secret},
            )
            if resp.status_code != 200:
                logger.error("钉钉 accessToken HTTP %d: %s", resp.status_code, resp.text[:200])
                return None
            data = resp.json()
            token = data.get("accessToken", "")
            if not token:
                return None
            self._token_cache = (token, time.monotonic() + _TOKEN_CACHE_TTL)
            return token
        except httpx.HTTPError as exc:
            logger.error("钉钉 accessToken 网络错误: %s", exc)
            return None
