"""渠道管理端点 — 消息渠道配置的 CRUD + Webhook 入站处理。

端点：
- ``GET    /channels``                       — 列出已配置渠道
- ``POST   /channels``                       — 注册新渠道（凭证加密存储）
- ``GET    /channels/{id}``                  — 获取渠道配置（不返回凭证明文）
- ``PUT    /channels/{id}``                  — 更新渠道配置
- ``DELETE /channels/{id}``                  — 删除渠道
- ``POST   /channels/{id}/webhook``          — 平台 webhook 入站端点

凭证通过 :class:`SecretsStore` 加密存储；响应中绝不返回凭证明文，
仅返回 secret 字段名列表以便前端管理。

Webhook 入站端点的 fail-closed 行为：签名校验失败返回 401，限流超限
返回 429。nonce dedup 用于防重放，重复事件返回 200（飞书要求 3s 内响应）。
"""

from __future__ import annotations

import asyncio
import logging
import re
import time
from collections import OrderedDict
from typing import Any
from uuid import uuid4

from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import Response
from pydantic import BaseModel, Field

from agentkit.channels.base import (
    ChannelType,
    MessageAdapter,
    OutgoingMessage,
    URLVerificationChallenge,
)
from agentkit.channels.dingtalk import DingTalkMessageAdapter
from agentkit.channels.feishu import FeishuMessageAdapter
from agentkit.channels.secrets import SecretsStore
from agentkit.channels.slack import SlackMessageAdapter
from agentkit.channels.wecom import WeComMessageAdapter, WeComURLVerification
from agentkit.chat.skill_routing import ExecutionMode
from agentkit.server.auth.dependencies import require_permission
from agentkit.server.auth.permissions import Permission

logger = logging.getLogger(__name__)

router = APIRouter(tags=["channels"])

# 渠道 ID 校验：小写字母、数字、下划线、连字符，1-64 字符
_CHANNEL_ID_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
# 凭证字段名校验：字母数字下划线，1-64 字符
_SECRET_NAME_RE = re.compile(r"^[a-zA-Z0-9_]{1,64}$")

# ponytail: 模块级单例 store。U3 后支持 Redis 后端（多 worker 共享）。
# _redis_client 由 app.py lifespan 通过 _set_redis_client() 注入。
# redis=None 时 SecretsStore 降级到内存字典（仅开发模式）。
_secrets_store: SecretsStore | None = None
_redis_client: Any = None


def _set_redis_client(redis: Any) -> None:
    """注入 Redis 客户端（由 app.py lifespan 调用）。

    设置后下次 _get_secrets_store() 会用该 redis 客户端构造 SecretsStore。
    """
    global _redis_client
    _redis_client = redis
    # 重置 store 以便下次 _get_secrets_store 用新 redis 重建
    _reset_secrets_store()


def _get_secrets_store() -> SecretsStore:
    """获取全局 secrets store 单例（懒加载，注入 redis）。"""
    global _secrets_store
    if _secrets_store is None:
        _secrets_store = SecretsStore(redis=_redis_client)
    return _secrets_store


def _reset_secrets_store() -> None:
    """重置全局 store（仅供测试使用）。"""
    global _secrets_store
    _secrets_store = None


# ---------------------------------------------------------------------------
# 内存渠道配置存储（PG 迁移预留接口）
# ---------------------------------------------------------------------------

_channels: dict[str, dict[str, Any]] = {}

# ---------------------------------------------------------------------------
# Webhook 限流 + Nonce dedup（模块级单例）
#
# ponytail: 单进程内存实现。天花板：多 worker / 多实例部署下状态不共享，
# 限流与 nonce dedup 仅在单进程内有效。升级路径：
# - 限流：Redis 滑动窗口（INCR + EXPIRE 或 ZSET）
# - Nonce dedup：Redis SETNX with TTL
# ---------------------------------------------------------------------------

# IP -> 请求时间戳列表（滑动窗口）
_rate_limits: dict[str, list[float]] = {}
_RATE_LIMIT_WINDOW = 60.0  # 窗口大小（秒）
_RATE_LIMIT_MAX = 100  # 窗口内最大请求数

# nonce -> 过期时间戳（与飞书签名时间戳窗口一致）
# OrderedDict：按插入顺序遍历，清理过期项时从头部弹出，O(1) 摊销。
_seen_nonces: OrderedDict[str, float] = OrderedDict()
_NONCE_TTL = 300.0

# Webhook 后台处理并发上限 — 防止高流量下 LLM 调用无界并发
_WEBHOOK_MAX_CONCURRENT = 20
_webhook_semaphore: asyncio.Semaphore | None = None
# 持有后台任务引用，防止 GC 回收正在运行的 task
_pending_webhook_tasks: set[asyncio.Task[None]] = set()

# 适配器缓存 — channel_id -> adapter。避免 per-request 重建导致 token TTL 缓存失效。
# 配置变更（PUT/DELETE channel）时通过 _invalidate_adapter_cache 清除对应条目。
_adapter_cache: dict[str, MessageAdapter] = {}


def _get_webhook_semaphore() -> asyncio.Semaphore:
    """懒构造 webhook 并发信号量（事件循环首次需要时创建）。"""
    global _webhook_semaphore
    if _webhook_semaphore is None:
        _webhook_semaphore = asyncio.Semaphore(_WEBHOOK_MAX_CONCURRENT)
    return _webhook_semaphore


def _check_rate_limit(client_ip: str) -> bool:
    """滑动窗口限流。返回 True 表示放行，False 表示超限。

    ponytail: 过滤后 timestamps 为空时清除 IP 条目，避免 _rate_limits 无界增长。
    """
    now = time.monotonic()
    cutoff = now - _RATE_LIMIT_WINDOW
    timestamps = [t for t in _rate_limits.get(client_ip, []) if t > cutoff]
    if not timestamps:
        # 清理过期空条目 — 不活跃 IP 不再占用 dict 槽位
        _rate_limits.pop(client_ip, None)
    if len(timestamps) >= _RATE_LIMIT_MAX:
        _rate_limits[client_ip] = timestamps
        return False
    timestamps.append(now)
    _rate_limits[client_ip] = timestamps
    return True


def _check_nonce_dedup(nonce: str) -> bool:
    """Nonce 去重。返回 True 表示新 nonce（应处理），False 表示重复（跳过）。

    使用 OrderedDict 按插入顺序清理过期项：从头部弹出已过期条目，
    O(1) 摊销（旧实现遍历整个字典为 O(N)）。fail-closed 语义：过期 nonce 拒绝。
    """
    now = time.monotonic()
    # 从头部弹出过期项（nonce 按单调时间插入，头部最旧即最先过期）。
    # ponytail: 摊销 O(1) 清理。旧实现遍历整个 dict 为 O(N)。
    while _seen_nonces:
        _oldest_key, oldest_expiry = next(iter(_seen_nonces.items()))
        if oldest_expiry >= now:
            break
        _seen_nonces.popitem(last=False)
    # 过期 nonce 已被清理 — 视为新 nonce（与旧实现先删全部过期项再查一致）
    if nonce in _seen_nonces:
        return False
    _seen_nonces[nonce] = now + _NONCE_TTL
    return True


def _reset_webhook_state() -> None:
    """重置限流与 nonce 状态（仅供测试使用）。"""
    _rate_limits.clear()
    _seen_nonces.clear()
    _adapter_cache.clear()
    _pending_webhook_tasks.clear()


# ---------------------------------------------------------------------------
# U4: Redis 后端 — nonce dedup / rate limit / backpressure
#
# 多 worker 部署下，内存状态不共享。Redis 后端确保 nonce dedup、限流、
# backpressure 跨 worker 一致。redis=None 时降级到上方内存实现。
# ponytail: 天花板 — _channels 渠道配置 dict 仍为进程内存储，多 worker 下
# 配置变更不可见。升级路径：_channels 迁移到 Redis Hash + read-through cache。
# ---------------------------------------------------------------------------

# Redis key 前缀 — 遵循 codebase 约定（agentkit:<subsystem>:）
_REDIS_NS = "agentkit:channels:webhook:"
_NONCE_KEY = _REDIS_NS + "nonce:{nonce}"
_RATELIMIT_KEY = _REDIS_NS + "ratelimit:{ip}"
_BACKPRESSURE_KEY = _REDIS_NS + "concurrent"
_BACKPRESSURE_TTL = 30  # 秒 — crash 安全网，防止计数不归零


def _get_redis_from_request(request: Request) -> Any:
    """从 request.app.state 提取 working_redis_client（可能为 None）。"""
    return getattr(request.app.state, "working_redis_client", None)


async def _check_rate_limit_redis(redis: Any, client_ip: str) -> bool:
    """Redis ZSET 滑动窗口限流。返回 True 放行，False 超限。"""
    key = _RATELIMIT_KEY.format(ip=client_ip)
    now = time.time()
    # U5c: member 加 uuid 后缀避免同时间戳碰撞（3 评审员一致标记）。
    pipe = redis.pipeline()
    pipe.zremrangebyscore(key, 0, now - _RATE_LIMIT_WINDOW)
    pipe.zadd(key, {f"{now}:{uuid4().hex}": now})
    pipe.zcard(key)
    pipe.expire(key, int(_RATE_LIMIT_WINDOW))
    _, _, count, _ = await pipe.execute()
    return count <= _RATE_LIMIT_MAX


async def _check_nonce_dedup_redis(redis: Any, nonce: str) -> bool:
    """Redis SET NX EX nonce 去重。返回 True 新 nonce，False 重复。"""
    key = _NONCE_KEY.format(nonce=nonce)
    # SET key 1 EX ttl NX — 原子操作，返回 True 表示新增成功
    is_new = await redis.set(key, "1", ex=int(_NONCE_TTL), nx=True)
    return bool(is_new)


async def _acquire_backpressure_slot(redis: Any) -> bool:
    """Redis INCR 共享并发计数。返回 True 获得槽位，False 超限。

    调用方在任务完成后必须调用 _release_backpressure_slot(redis)。
    pipeline 合并 INCR + EXPIRE 为单次往返。
    """
    pipe = redis.pipeline()
    pipe.incr(_BACKPRESSURE_KEY)
    pipe.expire(_BACKPRESSURE_KEY, _BACKPRESSURE_TTL)
    current, _ = await pipe.execute()
    if current > _WEBHOOK_MAX_CONCURRENT * 2:
        await redis.decr(_BACKPRESSURE_KEY)
        return False
    return True


async def _release_backpressure_slot(redis: Any) -> None:
    """释放并发槽位（DECR 计数器）。"""
    await redis.decr(_BACKPRESSURE_KEY)


def _validate_channel_id(channel_id: str) -> str:
    """校验渠道 ID，非法时抛 400。"""
    if not _CHANNEL_ID_RE.match(channel_id):
        raise HTTPException(
            status_code=400,
            detail=(
                f"非法渠道 ID '{channel_id}'：仅允许小写字母、数字、下划线、连字符（1-64 字符）"
            ),
        )
    return channel_id


def _validate_secret_names(names: list[str]) -> list[str]:
    """校验凭证字段名列表。"""
    for name in names:
        if not _SECRET_NAME_RE.match(name):
            raise HTTPException(
                status_code=400,
                detail=f"非法凭证字段名 '{name}'：仅允许字母、数字、下划线（1-64 字符）",
            )
    return names


# ---------------------------------------------------------------------------
# 请求 / 响应模型
# ---------------------------------------------------------------------------


class ChannelCreateRequest(BaseModel):
    """创建渠道请求。secrets 为明文键值对，服务端加密后存储。"""

    channel_id: str = Field(..., description="渠道唯一 ID")
    channel_type: ChannelType
    name: str = Field(..., description="渠道显示名称")
    config: dict[str, Any] = Field(default_factory=dict, description="非敏感配置")
    secrets: dict[str, str] = Field(
        default_factory=dict, description="凭证键值对（明文，服务端加密存储）"
    )


class ChannelUpdateRequest(BaseModel):
    """更新渠道请求。所有字段可选。secrets 提供则覆盖。"""

    name: str | None = None
    config: dict[str, Any] | None = None
    secrets: dict[str, str] | None = None


class ChannelInfo(BaseModel):
    """渠道信息响应 — 不含凭证明文。"""

    channel_id: str
    channel_type: ChannelType
    name: str
    config: dict[str, Any]
    secret_keys: list[str] = Field(default_factory=list, description="已存储的凭证字段名")


# ---------------------------------------------------------------------------
# 端点
# ---------------------------------------------------------------------------


@router.get("/channels")
async def list_channels(
    _user: Any = Depends(require_permission(Permission.SYSTEM_CONFIG)),
) -> dict[str, Any]:
    """列出所有已配置渠道。"""
    items = [
        ChannelInfo(
            channel_id=cid,
            channel_type=ChannelType(cfg["channel_type"]),
            name=cfg["name"],
            config=cfg.get("config", {}),
            secret_keys=cfg.get("secret_keys", []),
        ).model_dump()
        for cid, cfg in _channels.items()
    ]
    return {"channels": items, "total": len(items)}


@router.post("/channels", status_code=201)
async def create_channel(
    payload: ChannelCreateRequest,
    _user: Any = Depends(require_permission(Permission.SYSTEM_CONFIG)),
) -> ChannelInfo:
    """注册新渠道。凭证经 SecretsStore 加密存储。"""
    _validate_channel_id(payload.channel_id)
    _validate_secret_names(list(payload.secrets.keys()))

    if payload.channel_id in _channels:
        raise HTTPException(status_code=409, detail=f"渠道 '{payload.channel_id}' 已存在")

    store = _get_secrets_store()
    secret_keys: list[str] = []
    for name, value in payload.secrets.items():
        secret_key = f"{payload.channel_id}:{name}"
        await store.set_secret(secret_key, value)
        secret_keys.append(name)

    _channels[payload.channel_id] = {
        "channel_type": payload.channel_type.value,
        "name": payload.name,
        "config": payload.config,
        "secret_keys": secret_keys,
    }

    return ChannelInfo(
        channel_id=payload.channel_id,
        channel_type=payload.channel_type,
        name=payload.name,
        config=payload.config,
        secret_keys=secret_keys,
    )


@router.get("/channels/{channel_id}")
async def get_channel(
    channel_id: str,
    _user: Any = Depends(require_permission(Permission.SYSTEM_CONFIG)),
) -> ChannelInfo:
    """获取单个渠道配置（不返回凭证明文）。"""
    cfg = _channels.get(channel_id)
    if cfg is None:
        raise HTTPException(status_code=404, detail=f"渠道 '{channel_id}' 不存在")
    return ChannelInfo(
        channel_id=channel_id,
        channel_type=ChannelType(cfg["channel_type"]),
        name=cfg["name"],
        config=cfg.get("config", {}),
        secret_keys=cfg.get("secret_keys", []),
    )


@router.put("/channels/{channel_id}")
async def update_channel(
    channel_id: str,
    payload: ChannelUpdateRequest,
    _user: Any = Depends(require_permission(Permission.SYSTEM_CONFIG)),
) -> ChannelInfo:
    """更新渠道配置。secrets 提供则覆盖对应凭证。"""
    cfg = _channels.get(channel_id)
    if cfg is None:
        raise HTTPException(status_code=404, detail=f"渠道 '{channel_id}' 不存在")

    if payload.secrets is not None:
        _validate_secret_names(list(payload.secrets.keys()))
        store = _get_secrets_store()
        # 覆盖：先移除旧凭证字段再写入新值
        existing = set(cfg.get("secret_keys", []))
        new_names = set(payload.secrets.keys())
        for name in existing - new_names:
            await store.delete_secret(f"{channel_id}:{name}")
        for name, value in payload.secrets.items():
            await store.set_secret(f"{channel_id}:{name}", value)
        cfg["secret_keys"] = list(new_names)

    if payload.name is not None:
        cfg["name"] = payload.name
    if payload.config is not None:
        cfg["config"] = payload.config

    # 配置变更 — 清除缓存适配器，下次 webhook 重建（凭证可能已更新）
    await _invalidate_adapter_cache(channel_id)

    return ChannelInfo(
        channel_id=channel_id,
        channel_type=ChannelType(cfg["channel_type"]),
        name=cfg["name"],
        config=cfg.get("config", {}),
        secret_keys=cfg.get("secret_keys", []),
    )


@router.delete("/channels/{channel_id}")
async def delete_channel(
    channel_id: str,
    _user: Any = Depends(require_permission(Permission.SYSTEM_CONFIG)),
) -> dict[str, Any]:
    """删除渠道及其全部凭证。"""
    cfg = _channels.pop(channel_id, None)
    if cfg is None:
        raise HTTPException(status_code=404, detail=f"渠道 '{channel_id}' 不存在")

    store = _get_secrets_store()
    for name in cfg.get("secret_keys", []):
        await store.delete_secret(f"{channel_id}:{name}")

    # 清除缓存适配器并关闭旧实例
    await _invalidate_adapter_cache(channel_id)

    return {"deleted": channel_id}


# ---------------------------------------------------------------------------
# Webhook 入站端点（U11 — 飞书 IM 适配器端到端）
# ---------------------------------------------------------------------------


async def _build_adapter(channel_id: str) -> MessageAdapter:
    """根据渠道配置与 secrets 构造适配器实例（带缓存）。

    支持飞书 / 钉钉 / 企微 / Slack 四种渠道类型，按 ``channel_type`` 分发。
    首次构造后缓存到 ``_adapter_cache``，后续请求复用同一实例（token TTL 缓存命中）。
    secrets 获取使用 ``asyncio.gather`` 并行，避免串行 await。

    Raises:
        HTTPException: 渠道不存在（404）、渠道类型不支持（400）、缺少必要凭证（500）。
    """
    # 命中缓存直接返回（per-request 重建会使 token TTL 缓存失效）
    cached = _adapter_cache.get(channel_id)
    if cached is not None:
        return cached

    cfg = _channels.get(channel_id)
    if cfg is None:
        raise HTTPException(status_code=404, detail=f"渠道 '{channel_id}' 不存在")

    store = _get_secrets_store()
    channel_type = cfg["channel_type"]

    if channel_type == ChannelType.FEISHU.value:
        app_id, app_secret, encrypt_key, verification_token = await asyncio.gather(
            store.get_secret(f"{channel_id}:app_id"),
            store.get_secret(f"{channel_id}:app_secret"),
            store.get_secret(f"{channel_id}:encrypt_key"),
            store.get_secret(f"{channel_id}:verification_token"),
        )
        if not app_id or not app_secret:
            raise HTTPException(
                status_code=500, detail=f"渠道 '{channel_id}' 缺少 app_id 或 app_secret"
            )
        adapter = FeishuMessageAdapter(
            app_id=app_id,
            app_secret=app_secret,
            encrypt_key=encrypt_key,
            verification_token=verification_token,
        )
        _adapter_cache[channel_id] = adapter
        return adapter

    if channel_type == ChannelType.DINGTALK.value:
        app_key, app_secret, robot_code, token = await asyncio.gather(
            store.get_secret(f"{channel_id}:app_key"),
            store.get_secret(f"{channel_id}:app_secret"),
            store.get_secret(f"{channel_id}:robot_code"),
            store.get_secret(f"{channel_id}:token"),
        )
        if not all([app_key, app_secret, robot_code]):
            raise HTTPException(status_code=500, detail=f"渠道 '{channel_id}' 缺少 dingtalk 凭证")
        adapter = DingTalkMessageAdapter(
            app_key=app_key,
            app_secret=app_secret,
            robot_code=robot_code,
            token=token,
        )
        _adapter_cache[channel_id] = adapter
        return adapter

    if channel_type == ChannelType.WECOM.value:
        corp_id, corp_secret, token, encoding_aes_key, agent_id_raw = await asyncio.gather(
            store.get_secret(f"{channel_id}:corp_id"),
            store.get_secret(f"{channel_id}:corp_secret"),
            store.get_secret(f"{channel_id}:token"),
            store.get_secret(f"{channel_id}:encoding_aes_key"),
            store.get_secret(f"{channel_id}:agent_id"),
        )
        if not all([corp_id, corp_secret, token, encoding_aes_key, agent_id_raw]):
            raise HTTPException(status_code=500, detail=f"渠道 '{channel_id}' 缺少 wecom 凭证")
        try:
            agent_id = int(agent_id_raw)
        except (TypeError, ValueError) as exc:
            raise HTTPException(
                status_code=500,
                detail=f"渠道 '{channel_id}' agent_id 不是合法整数",
            ) from exc
        adapter = WeComMessageAdapter(
            corp_id=corp_id,
            agent_id=agent_id,
            corp_secret=corp_secret,
            token=token,
            encoding_aes_key=encoding_aes_key,
        )
        _adapter_cache[channel_id] = adapter
        return adapter

    if channel_type == ChannelType.SLACK.value:
        bot_token, signing_secret, verification_token = await asyncio.gather(
            store.get_secret(f"{channel_id}:bot_token"),
            store.get_secret(f"{channel_id}:signing_secret"),
            store.get_secret(f"{channel_id}:verification_token"),
        )
        if not bot_token or not signing_secret:
            raise HTTPException(status_code=500, detail=f"渠道 '{channel_id}' 缺少 slack 凭证")
        adapter = SlackMessageAdapter(
            bot_token=bot_token,
            signing_secret=signing_secret,
            verification_token=verification_token,
        )
        _adapter_cache[channel_id] = adapter
        return adapter

    raise HTTPException(status_code=400, detail=f"不支持的渠道类型: {channel_type}")


async def _invalidate_adapter_cache(channel_id: str) -> None:
    """清除指定渠道的缓存适配器并关闭旧实例（配置变更/删除时调用）。"""
    old = _adapter_cache.pop(channel_id, None)
    if old is not None:
        try:
            await old.close()
        except Exception:  # noqa: BLE001 — 关闭异常不应阻塞配置变更
            logger.debug("关闭旧适配器异常已忽略: channel_id=%s", channel_id)


async def close_all_adapters() -> None:
    """关闭所有缓存的适配器（供 app shutdown 调用）。"""
    for channel_id, adapter in list(_adapter_cache.items()):
        try:
            await adapter.close()
        except Exception:  # noqa: BLE001
            logger.debug("关闭适配器异常已忽略: channel_id=%s", channel_id)
    _adapter_cache.clear()


async def _process_inbound_message(app_state: Any, adapter: MessageAdapter, message: Any) -> None:
    """后台处理入站消息 — 调用 chat 链路并通过适配器回复。

    整个流程 try/except 包裹，任何异常仅记录日志，不向上抛出
    （webhook 必须保持响应能力）。处理逻辑受全局信号量限流
    （``_WEBHOOK_MAX_CONCURRENT``），防止高流量下 LLM 调用无界并发。
    适配器由 ``_adapter_cache`` 管理，不在 per-request 关闭（关闭会清空 token 缓存）。
    适配器类型不限 — 出站消息的 ``channel`` 取自入站消息以匹配平台。
    """

    async def _direct_chat(llm_gateway: Any, routing: Any) -> str:
        """DIRECT_CHAT 路径 — 直接调用 LLM（主路径与 ReAct 回退共用）。"""
        response = await llm_gateway.chat(
            messages=[{"role": "user", "content": message.content}],
            model=routing.model or "default",
        )
        return response.content

    async with _get_webhook_semaphore():
        try:
            request_preprocessor = getattr(app_state, "request_preprocessor", None)
            llm_gateway = getattr(app_state, "llm_gateway", None)
            if request_preprocessor is None or llm_gateway is None:
                logger.warning("app.state 缺少 request_preprocessor 或 llm_gateway — 跳过消息处理")
                return

            # 路由预处理 — IM 场景使用默认 agent，无需技能注册表
            routing = await request_preprocessor.preprocess(
                content=message.content, default_agent_name="default"
            )

            final_content = ""
            execution_mode = getattr(routing, "execution_mode", None)
            if execution_mode == ExecutionMode.DIRECT_CHAT:
                final_content = await _direct_chat(llm_gateway, routing)
            else:
                # REACT 或其他模式 — 优先使用 ReActEngine，失败回退到 DIRECT_CHAT
                try:
                    from agentkit.core.react import ReActEngine

                    engine = ReActEngine(llm_gateway=llm_gateway)
                    result = await engine.execute(
                        messages=[{"role": "user", "content": message.content}],
                        tools=getattr(routing, "tools", None) or None,
                        model=routing.model or "default",
                    )
                    final_content = getattr(result, "content", "") or ""
                except Exception as exc:  # noqa: BLE001 — 回退路径需捕获全部异常
                    logger.warning("ReActEngine 执行失败，回退到 DIRECT_CHAT: %s", exc)
                    final_content = await _direct_chat(llm_gateway, routing)

            if not final_content:
                logger.warning("消息处理未产生内容 — 不发送回复")
                return

            outgoing = OutgoingMessage(
                channel=message.channel,
                chat_id=message.chat_id,
                content=final_content,
            )
            await adapter.send_message(outgoing)
        except Exception as exc:  # noqa: BLE001 — webhook 必须保持响应能力
            logger.exception("处理入站消息失败: %s", exc)


def _get_client_ip(request: Request) -> str:
    """获取客户端 IP — 优先取 X-Forwarded-For 首个 IP，否则取 request.client.host。"""
    forwarded = request.headers.get("x-forwarded-for")
    if forwarded:
        # 取首个 IP（链路中第一个为原始客户端）
        return forwarded.split(",")[0].strip()
    if request.client:
        return request.client.host
    return "unknown"


@router.post("/channels/{channel_id}/webhook")
async def channel_webhook(channel_id: str, request: Request) -> Any:
    """渠道 webhook 端点 — 接收平台事件（飞书/钉钉/企微/Slack）。

    安全流程（按顺序）：
        1. Per-IP 限流（100 req/min）— 超限 429
        2. 读取原始 body（未解析）
        3. 签名验证 — 失败 401
        4. Nonce dedup — 重复返回 200（飞书要求 3s 内响应）
        5. URL verification — 飞书/Slack 返回 challenge；企微返回 XML
        6. 解析消息 → 后台异步处理 → 立即返回 200

    U4：nonce dedup、限流、backpressure 优先使用 Redis（多 worker 共享），
    redis=None 时降级到进程内内存实现。

    企微通过 query 参数传递 ``msg_signature``/``timestamp``/``nonce``，
    合并到 headers dict 供适配器读取。
    """
    redis = _get_redis_from_request(request)
    client_ip = _get_client_ip(request)

    # 1. 限流 — Redis ZSET 滑动窗口（多 worker 共享），降级到内存
    if redis is not None:
        if not await _check_rate_limit_redis(redis, client_ip):
            raise HTTPException(status_code=429, detail="请求过于频繁，请稍后重试")
    else:
        if not _check_rate_limit(client_ip):
            raise HTTPException(status_code=429, detail="请求过于频繁，请稍后重试")

    body = await request.body()

    adapter = await _build_adapter(channel_id)

    headers_dict = dict(request.headers)
    # 企微等平台通过 query 参数传递签名信息 — 合并到 headers dict 供适配器读取
    for key, value in request.query_params.items():
        if key not in headers_dict:
            headers_dict[key] = value

    if not await adapter.verify_signature(headers_dict, body):
        raise HTTPException(status_code=401, detail="签名校验失败")

    # 4. Nonce dedup — Redis SET NX EX（多 worker 共享），降级到内存
    nonce = request.headers.get("x-lark-request-nonce")
    if nonce:
        if redis is not None:
            if not await _check_nonce_dedup_redis(redis, nonce):
                return {"code": 0, "msg": "duplicate"}
        else:
            if not _check_nonce_dedup(nonce):
                return {"code": 0, "msg": "duplicate"}

    try:
        message = await adapter.receive_message(headers_dict, body)
    except URLVerificationChallenge as e:
        # URL 验证流程 — 飞书 / Slack 配置 webhook 时发送
        return {"challenge": e.challenge}
    except WeComURLVerification as e:
        # 企微 URL 验证 — 返回 XML 响应
        return Response(content=e.response_xml, media_type="application/xml")
    except Exception as exc:  # noqa: BLE001 — 防止 receive_message 异常导致 500 触发平台重试风暴
        logger.warning("receive_message 解析失败 channel=%s: %s", channel_id, exc)
        return {"code": 0, "msg": "invalid_payload"}

    # 6. Backpressure + 异步分发 — Redis 共享计数器（多 worker），降级到内存 set
    if redis is not None:
        if not await _acquire_backpressure_slot(redis):
            logger.warning("webhook Redis backpressure 超限，拒绝新任务")
            raise HTTPException(status_code=429, detail="服务器繁忙，请稍后重试")

        async def _run_with_release() -> None:
            """执行消息处理并在完成后释放 Redis 并发槽位。"""
            try:
                await _process_inbound_message(request.app.state, adapter, message)
            finally:
                await _release_backpressure_slot(redis)

        task = asyncio.create_task(_run_with_release())
    else:
        # 降级模式：进程内 set 长度检查 + GC 引用持有
        if len(_pending_webhook_tasks) >= _WEBHOOK_MAX_CONCURRENT * 2:
            logger.warning("webhook 后台任务积压 %d，拒绝新任务", len(_pending_webhook_tasks))
            raise HTTPException(status_code=429, detail="服务器繁忙，请稍后重试")
        task = asyncio.create_task(_process_inbound_message(request.app.state, adapter, message))

    _pending_webhook_tasks.add(task)
    task.add_done_callback(_pending_webhook_tasks.discard)

    return {"code": 0}
