"""消息子 driver：发送文本 / 发送文件 / 撤回 / 转发。

从 xdotool_driver.py 单类中拆出的消息域方法，继承 BaseDriver 复用公共能力。
find_wechat_window 归属 WindowDriver，本 driver 通过构造函数注入
window_driver 引用调用。

方法清单（与原 XdotoolDriver 签名一致）：
- send_text(to_wxid, content, display_name=None) -> str
- send_file(to_wxid, file_path, is_image=False, display_name=None) -> str  （保持原 placeholder 逻辑）
- revoke_message(talker, create_time, display_name=None, timeout_sec=15.0) -> bool
- forward_message(talker, target_display_name, source_display_name=None, timeout_sec=20.0) -> bool
"""

from __future__ import annotations

import asyncio
import logging
import os
import random
import time
from typing import Any, Awaitable, Optional, TYPE_CHECKING

from woc_bridge.models import BridgeError
from woc_bridge.ui.backends.base import WindowGeometry
from woc_bridge.ui.drivers.base import BaseDriver

if TYPE_CHECKING:
    from woc_bridge.ui.drivers.window import WindowDriver

logger = logging.getLogger("woc-bridge")

# 微信窗口标题常量（与 flow 路径保持一致）
_WECHAT_WINDOW_TITLE = "微信"

# ---------------------------------------------------------------------------
# 分层超时与布局常量（与 xdotool_driver.py 保持一致）
# ---------------------------------------------------------------------------
# send_text 中"粘贴内容 + 短暂等待 + 回车发送"三步整体的超时（秒）。
_SEND_BODY_TIMEOUT_SEC = 15.0

# 微信 4.x 自绘 UI 布局常量（基于窗口几何的相对坐标）
_LEFT_PANEL_WIDTH = 300          # 左侧会话列表宽度（像素）
_SEARCH_BOX_Y_OFFSET = 55        # 搜索框中心相对窗口顶部的 Y 偏移
_SEARCH_RESULT_Y_OFFSET = 120    # 搜索结果/会话列表第一项中心相对窗口顶部的 Y 偏移
_INPUT_BOX_Y_OFFSET = 50         # 输入框中心相对窗口底部的 Y 偏移（向上）
_SEND_BUTTON_X_OFFSET = 60       # 发送按钮中心相对窗口右边缘的 X 偏移（向左）
_SEND_BUTTON_Y_OFFSET = 30       # 发送按钮中心相对窗口底部的 Y 偏移（向上）


class MessageDriver(BaseDriver):
    """消息子 driver。"""

    def __init__(
        self,
        display: str,
        backend=None,
        window_driver: Optional["WindowDriver"] = None,
        actions: Optional[Any] = None,
    ) -> None:
        super().__init__(display, backend)
        # 注入兄弟 driver：_open_session_by_name / revoke / forward 依赖
        # WindowDriver.find_wechat_window
        self._window_driver = window_driver
        # 注入 L3 Actions（可选）：非 None 时 _open_session_by_name / 输入框 /
        # 发送按钮走 LocatorRegistry + profile 比例坐标；None 时回退原硬编码坐标。
        self._actions = actions
        # 调试截图：环境变量控制开关（与原 XdotoolDriver.__init__ 一致）
        self._debug_shots_enabled = os.environ.get(
            "WOC_UI_DEBUG_SHOTS", "false"
        ).lower() == "true"
        self._debug_queue: asyncio.Queue = asyncio.Queue()
        self._debug_task: Optional[asyncio.Task] = None

    # ------------------------------------------------------------------
    # 命令执行包装（保持与原 XdotoolDriver._run 语义一致）
    # ------------------------------------------------------------------
    async def _run(
        self,
        args: list[str],
        *,
        input_bytes: Optional[bytes] = None,
    ) -> tuple[int, bytes, bytes]:
        """执行一条命令并返回 (returncode, stdout, stderr)。

        委托 BaseDriver._run_managed，TimeoutError 转 BridgeError(code=SEND_FAILED)。
        """
        try:
            return await self._run_managed(args)
        except asyncio.TimeoutError as exc:
            logger.error("[ui] command timeout: %s", args[0] if args else "?")
            raise BridgeError(
                code="SEND_FAILED",
                message=f"命令超时: {args[0] if args else '?'}",
            ) from exc

    async def _key(self, key: str, repeat: int = 1) -> None:
        """执行 xdotool key [--repeat N] <key>。"""
        logger.info("[ui] key: %s repeat=%d", key, repeat)
        args = ["xdotool", "key"]
        if repeat > 1:
            args.extend(["--repeat", str(repeat)])
        args.append(key)
        rc, _, _ = await self._run(args)
        logger.info("[ui] key %s -> rc=%s", key, rc)

    async def _sleep(self, seconds: float) -> None:
        """asyncio.sleep 封装，便于测试与统一调速。"""
        logger.info("[ui] sleep %.2fs", seconds)
        await asyncio.sleep(seconds)

    async def _paste_via_xclip(self, text: str) -> None:
        """通过 xdotool type 直接向聚焦控件输入文本。

        历史背景：原实现用 xclip 写剪贴板 + Ctrl+V 粘贴，但微信 4.x Linux
        自绘 UI 不响应 X11 core keyboard 的修饰键组合（Ctrl+V），只响应单
        字符键盘事件。xev 验证 KeyPress 事件确实到达窗口但微信不处理，
        而 xdotool type（逐字符 XSendEvent）能正常输入中英文。因此改用
        xdotool type 直接打字。方法名保留 _paste_via_xclip 以维持调用点稳定。

        注意：
        - 把 \n / \r 替换为空格，避免 xdotool 把换行解析为 Return 键触发发送。
        - 使用 --delay 30ms 降低输入速度，减少中文 IME 字符丢失。

        Raises:
            BridgeError(SEND_FAILED): xdotool type 执行失败
        """
        # 规范化：去除换行，避免 Return 误触发
        normalized = text.replace("\r\n", " ").replace("\n", " ").replace("\r", " ")
        normalized = " ".join(normalized.split())
        if normalized != text:
            logger.info("[ui] type text normalized: original_len=%d len=%d", len(text), len(normalized))
        text = normalized
        logger.info("[ui] type text: len=%d", len(text))
        rc, _, stderr = await self._run(["xdotool", "type", "--delay", "30", "--", text])
        if rc != 0:
            err = stderr.decode(errors="ignore").strip() if stderr else "unknown"
            raise BridgeError(
                code="SEND_FAILED",
                message=f"xdotool type 失败 (code={rc}): {err}",
            )
        logger.info("[ui] type done rc=%s", rc)

    async def _click(self, x: int, y: int) -> None:
        """移动鼠标到 (x, y) 并左键单击（分两条命令版本）。"""
        rc, _, _ = await self._run(
            ["xdotool", "mousemove", "--sync", str(x), str(y)]
        )
        if rc != 0:
            raise BridgeError(
                code="SEND_FAILED",
                message=f"移动鼠标到 ({x},{y}) 失败",
            )
        rc, _, _ = await self._run(["xdotool", "click", "1"])
        if rc != 0:
            raise BridgeError(
                code="SEND_FAILED",
                message=f"点击 ({x},{y}) 失败",
            )

    async def _long_press(self, x: int, y: int, hold_sec: float = 1.5) -> None:
        """移动鼠标到 (x, y) 并长按左键 hold_sec 秒后释放。

        用于触发微信消息长按菜单（撤回/转发/复制等）。
        用 try/finally 保证 mouseup 一定执行，避免超时/取消时
        mousedown 状态泄漏导致整个 UI 自动化瘫痪（拖拽锁死）。
        """
        rc, _, _ = await self._run(
            ["xdotool", "mousemove", "--sync", str(x), str(y)]
        )
        if rc != 0:
            raise BridgeError(
                code="SEND_FAILED",
                message=f"长按：移动鼠标到 ({x},{y}) 失败",
            )
        rc, _, _ = await self._run(["xdotool", "mousedown", "1"])
        if rc != 0:
            raise BridgeError(
                code="SEND_FAILED",
                message=f"长按 ({x},{y}) mousedown 失败",
            )
        try:
            await self._sleep(hold_sec)
        finally:
            # 用 shield 防止 finally 中的 mouseup 被再次取消
            rc, _, _ = await asyncio.shield(
                self._run(["xdotool", "mouseup", "1"])
            )
            if rc != 0:
                logger.warning(
                    "_long_press mouseup 失败 (rc=%d)，鼠标可能仍处于按下状态", rc
                )

    # ------------------------------------------------------------------
    # 调试截图（异步入队写盘，不阻塞主流程）
    # ------------------------------------------------------------------
    async def _debug_screenshot(self, step: str, trace_id: str = "") -> None:
        """入队一张调试截图，由后台 _debug_writer 异步写盘。"""
        if not self._debug_shots_enabled:
            return
        await self._debug_queue.put((step, trace_id, time.time()))
        if self._debug_task is None or self._debug_task.done():
            self._debug_task = asyncio.create_task(self._debug_writer())

    async def _debug_writer(self) -> None:
        """后台消费 _debug_queue，将截图请求异步写盘。"""
        while True:
            step, trace_id, ts = await self._debug_queue.get()
            try:
                os.makedirs("/tmp/woc_debug", exist_ok=True)
                path = f"/tmp/woc_debug/{trace_id}_{step}_{int(ts * 1000)}.png"
                rc, _, stderr = await self._run(["scrot", path])
                if rc == 0:
                    logger.info("[ui] debug screenshot saved: %s", path)
                else:
                    err = stderr.decode(errors="ignore").strip() if stderr else "unknown"
                    logger.warning("[ui] debug screenshot failed (%s): %s", step, err)
            except Exception as e:
                logger.warning("[ui] debug writer 异常 (step=%s): %r", step, e)
            finally:
                self._debug_queue.task_done()

    # ------------------------------------------------------------------
    # 输入框 / 发送按钮定位（优先走 Actions + profile 比例坐标）
    # ------------------------------------------------------------------
    async def _get_window_geometry_backend(self) -> WindowGeometry:
        """获取窗口几何，返回 backend 统一的 WindowGeometry。

        优先委托 XdotoolBackend；无 backend 时自建 WindowGeometry。
        """
        if self._backend is not None and hasattr(
            self._backend, "get_window_geometry"
        ):
            return await self._backend.get_window_geometry(_WECHAT_WINDOW_TITLE)
        win_x, win_y, win_w, win_h = await self._get_window_geometry()
        return WindowGeometry(x=win_x, y=win_y, width=win_w, height=win_h)

    async def _click_input_box(self) -> None:
        """鼠标点击聊天输入框，强制焦点落到输入框。"""
        if self._actions is not None:
            geom = await self._get_window_geometry_backend()
            await self._actions.click_element("input_box", geom)
            await asyncio.sleep(0.2)
            return
        # legacy fallback：固定像素坐标
        win_x, win_y, win_w, win_h = await self._get_window_geometry()
        input_x = win_x + _LEFT_PANEL_WIDTH + (win_w - _LEFT_PANEL_WIDTH) // 2
        input_y = win_y + win_h - _INPUT_BOX_Y_OFFSET
        await self._click_at(input_x, input_y)
        await self._sleep(0.3)

    async def _click_send_button(self) -> None:
        """鼠标点击右下角发送按钮，替代可能失效的 Return 键。"""
        if self._actions is not None:
            geom = await self._get_window_geometry_backend()
            await self._actions.click_element("send_button", geom)
            return
        # legacy fallback：固定像素坐标
        win_x, win_y, win_w, win_h = await self._get_window_geometry()
        send_x = win_x + win_w - _SEND_BUTTON_X_OFFSET
        send_y = win_y + win_h - _SEND_BUTTON_Y_OFFSET
        await self._click_at(send_x, send_y)

    # ------------------------------------------------------------------
    # 会话定位（鼠标点击搜索框 + 点击结果）
    # ------------------------------------------------------------------
    async def _open_session_by_name(
        self,
        name: str,
        timeout_sec: float = 20.0,
    ) -> None:
        """通过微信左侧搜索框定位并进入指定联系人的会话。

        优先走 Actions + LocatorRegistry + YAML profile 比例坐标；
        未注入 Actions 时回退原硬编码坐标逻辑。

        新流程：
        1. 激活窗口并获取几何
        2. 点击左侧"会话管理"图标重置状态（回到会话列表首页）
        3. 点击左侧顶部搜索框
        4. Ctrl+A + BackSpace 清空旧内容
        5. 输入 display_name
        6. 等待并点击第一个搜索结果
        7. 验证 input_box 出现（确保真正进入会话）
        8. 按 Esc 退出搜索覆盖层

        Args:
            name: 用于搜索的显示名（备注/昵称/微信号）
            timeout_sec: 整体超时（秒）

        Raises:
            BridgeError(WINDOW_NOT_FOUND): 找不到微信窗口
            BridgeError(SEND_FAILED): 超时或 xdotool 操作失败
        """
        if not name:
            raise BridgeError(
                code="SEND_FAILED",
                message="display_name 不能为空，无法定位会话",
            )

        if self._actions is None:
            return await self._open_session_by_name_legacy(name, timeout_sec)

        deadline = time.monotonic() + timeout_sec
        backend = self._actions.backend

        # 1. 激活窗口并获取几何
        geom = await self._get_window_geometry_backend()
        await self._step(backend.activate_window(_WECHAT_WINDOW_TITLE), "激活窗口", deadline)

        # 2. 重置状态：点击左侧"会话管理"图标，强制回到会话列表首页
        await self._step(
            self._actions.click_element("session_manager_icon", geom),
            "点击会话管理图标重置状态",
            deadline,
        )
        await self._step(backend.key_press("Escape"), "关闭可能打开的菜单", deadline)
        await self._step(asyncio.sleep(0.3), "等待重置生效", deadline)

        # 3. 点击搜索框
        await self._step(self._actions.click_element("search_box", geom), "点击搜索框", deadline)
        await self._step(asyncio.sleep(0.2), "等待搜索框聚焦", deadline)

        # 4. 清空旧内容：Ctrl+A 全选 + BackSpace 删除
        await self._step(backend.key_press("ctrl+a"), "全选搜索框旧内容", deadline)
        await self._step(backend.key_press("BackSpace"), "删除搜索框旧内容", deadline)
        await self._step(asyncio.sleep(0.2), "等待清空", deadline)

        # 5. 输入搜索关键词
        await self._step(backend.type_text(name), "输入搜索关键词", deadline)
        await self._step(asyncio.sleep(0.5), "等待搜索结果渲染", deadline)

        # 6. 点击第一个搜索结果进入会话
        await self._step(
            self._actions.click_element("search_result_first", geom),
            "点击第一个搜索结果",
            deadline,
        )
        await self._step(asyncio.sleep(0.5), "等待会话打开", deadline)
        await self._debug_screenshot("after_open_session")

        # 7. 验证 input_box 出现（确保真正进入目标会话）
        input_appeared = False
        verify_deadline = time.monotonic() + 2.0
        while time.monotonic() < verify_deadline:
            try:
                elem = await self._actions.wait_for(
                    "input_box", geom, timeout=0.5, interval=0.1
                )
                if elem is not None:
                    input_appeared = True
                    break
            except Exception:
                pass
            await asyncio.sleep(0.2)
        if not input_appeared:
            raise BridgeError(
                code="SEND_FAILED",
                message="点击搜索结果后未进入目标会话（未找到输入框）",
            )

        # 8. 退出搜索模式，确保焦点在会话区域
        await self._step(backend.key_press("Escape"), "退出搜索模式", deadline)
        await self._step(asyncio.sleep(0.2), "等待焦点稳定", deadline)

    async def _open_session_by_name_legacy(
        self,
        name: str,
        timeout_sec: float = 20.0,
    ) -> None:
        """原硬编码坐标实现（保留兼容）。"""
        deadline = time.monotonic() + timeout_sec

        # 1. 查找并激活窗口（委托 WindowDriver 查窗口）
        window_id = await self._window_driver.find_wechat_window()
        logger.info("[ui] found window_id=%s", window_id)
        if window_id is None:
            raise BridgeError(
                code="WINDOW_NOT_FOUND",
                message="未找到微信窗口，无法定位会话",
            )
        await self._step(self._activate_window_fast(window_id), "激活窗口", deadline)
        await self._step(self._sleep(1.0), "等待窗口激活", deadline)

        # 2. 关闭可能存在的搜索框/弹窗
        await self._step(self._key("Escape"), "关闭搜索框/弹窗", deadline)
        await self._step(self._sleep(1.0), "等待 Esc 生效", deadline)

        # 3. 获取窗口几何，计算搜索框/结果坐标
        win_x, win_y, win_w, win_h = await self._get_window_geometry()
        search_x = win_x + _LEFT_PANEL_WIDTH // 2
        search_y = win_y + _SEARCH_BOX_Y_OFFSET
        result_x = win_x + _LEFT_PANEL_WIDTH // 2
        result_y = win_y + _SEARCH_RESULT_Y_OFFSET

        # 4. 点击搜索框并输入关键词
        await self._step(self._click_at(search_x, search_y), "点击搜索框", deadline)
        await self._step(self._sleep(1.0), "等待搜索框聚焦", deadline)
        # 清空可能存在的旧内容：用多次 Backspace 规避 Ctrl+A 可能失效的问题
        await self._step(self._key("BackSpace", repeat=30), "清空搜索框旧内容", deadline)
        await self._step(self._sleep(1.0), "等待清空", deadline)
        await self._step(self._paste_via_xclip(name), "输入搜索关键词", deadline)
        await self._step(self._sleep(1.0), "等待搜索结果", deadline)

        # 5. 点击第一个搜索结果进入会话
        await self._step(self._click_at(result_x, result_y), "点击第一个搜索结果", deadline)
        await self._step(self._sleep(1.0), "等待会话打开", deadline)
        await self._debug_screenshot("after_open_session")

        # 6. 退出搜索模式，确保焦点在会话区域
        await self._step(self._key("Escape"), "退出搜索模式", deadline)
        await self._step(self._sleep(1.0), "等待焦点稳定", deadline)

    async def _activate_window_fast(self, window_id: Optional[int] = None) -> None:
        """激活微信窗口并校验焦点是否真正落到该窗口上。

        先用非阻塞 windowactivate 避免 --sync 在 VNC 无人操作时死等，
        然后轮询 getactivewindow 最多 2 秒确认目标窗口已获得焦点。
        """
        if window_id is None:
            window_id = await self._window_driver.find_wechat_window()
        if window_id is None:
            raise BridgeError(
                code="WINDOW_NOT_FOUND",
                message="未找到微信窗口，无法激活",
            )
        await self._run(["xdotool", "windowactivate", str(window_id)])

        # 轮询校验焦点（最多 2 秒，步进 100ms）
        deadline = time.monotonic() + 2.0
        while time.monotonic() < deadline:
            rc, stdout, _ = await self._run(["xdotool", "getactivewindow"])
            if rc == 0:
                active_text = stdout.decode(errors="ignore").strip()
                try:
                    if int(active_text) == window_id:
                        return
                except ValueError:
                    pass
            await self._sleep(0.1)
        logger.warning(
            "[ui] 窗口激活校验失败: window_id=%s 未在 2s 内成为活动窗口", window_id
        )

    async def _get_window_geometry(self) -> tuple[int, int, int, int]:
        """获取微信窗口几何信息，返回 (win_x, win_y, win_w, win_h)。

        复用 BaseDriver._parse_window_geometry 消除内联解析重复。
        """
        window_id = await self._window_driver.find_wechat_window()
        if window_id is None:
            raise BridgeError(
                code="WINDOW_NOT_FOUND",
                message="未找到微信窗口",
            )
        returncode, stdout, _ = await self._run(
            ["xdotool", "getwindowgeometry", "--shell", str(window_id)]
        )
        if returncode != 0:
            raise BridgeError(
                code="SEND_FAILED",
                message="无法获取微信窗口几何信息",
            )
        geom = self._parse_window_geometry(stdout.decode(errors="ignore"))
        if geom is None:
            raise BridgeError(
                code="SEND_FAILED",
                message="解析窗口几何信息失败",
            )
        return (geom.x, geom.y, geom.width, geom.height)

    # ------------------------------------------------------------------
    # 发送文本
    # ------------------------------------------------------------------
    async def send_text(
        self,
        to_wxid: str,
        content: str,
        display_name: Optional[str] = None,
    ) -> str:
        """发送文本消息。

        流程：
        1. 通过微信左侧搜索框定位会话（使用 display_name，未提供时回退到 to_wxid）
        2. 输入文本内容并回车发送
        3. 返回本地生成的 channel_msg_id

        Args:
            to_wxid: 目标 wxid
            content: 文本内容
            display_name: 用于搜索定位会话的显示名（备注/昵称/微信号）

        Returns:
            本地生成的 channel_msg_id，格式 local_<unix秒>_<随机>
        """
        name = display_name if display_name else to_wxid
        t_total = time.perf_counter()
        logger.info("[send_text] 开始 to=%s name=%s content_len=%d", to_wxid, name, len(content))
        # 1. 定位会话（_open_session_by_name 内部已含整体超时）
        t0 = time.perf_counter()
        await self._open_session_by_name(name)
        logger.info("[send_text] 会话定位完成 (%.0fms)", (time.perf_counter() - t0) * 1000)
        await self._debug_screenshot("after_session")
        # 1.5 鼠标点击聊天输入框强制焦点
        await self._click_input_box()
        await self._debug_screenshot("after_click_input")
        # 2. 输入内容并发送（三步整体超时保护）
        async def _send_body() -> None:
            await self._paste_via_xclip(content)
            logger.info("[send_text] 文本已输入")
            await self._debug_screenshot("after_type")
            await self._sleep(0.3)
            # 优先用鼠标点击右下角发送按钮，规避 Return 键可能不触发发送的问题
            await self._click_send_button()
        try:
            t0 = time.perf_counter()
            await asyncio.wait_for(_send_body(), timeout=_SEND_BODY_TIMEOUT_SEC)
            logger.info("[send_text] 发送按钮已点击 (%.0fms)", (time.perf_counter() - t0) * 1000)
        except asyncio.TimeoutError as exc:
            logger.error("[send_text] 输入/发送步骤超时")
            raise BridgeError(
                code="SEND_FAILED",
                message="文本输入/发送步骤超时",
            ) from exc
        await self._sleep(0.3)
        await self._debug_screenshot("after_send")
        # 3. 按 Esc 退出当前聊天界面，回到会话列表，避免影响下一次发送
        await self._key("Escape")
        await self._sleep(0.3)
        await self._debug_screenshot("after_escape")
        # 4. 生成 local_send_id（本地 ID，非微信原生 msg_id）
        local_send_id = f"local_{int(time.time())}_{random.randint(0, 0xFFFFFF):06x}"
        logger.info("[send_text] 完成 local_send_id=%s (总耗时 %.0fms)",
                    local_send_id, (time.perf_counter() - t_total) * 1000)
        return local_send_id

    # ------------------------------------------------------------------
    # 发送文件 / 图片（MVP placeholder，保持原逻辑，真实文件传输留给 Task 7）
    # ------------------------------------------------------------------
    async def send_file(
        self,
        to_wxid: str,
        file_path: str,
        is_image: bool = False,
        display_name: Optional[str] = None,
    ) -> str:
        """发送文件/图片（MVP 简化版，保持原 placeholder 逻辑）。

        MVP 策略：检查文件可读 → 进入会话（复用 _open_session_by_name）
        → 发送"[图片/文件] 文件名"提示文本。真实文件传输机制留给后续完善
        （Task 7 的 SendFileFlow 处理），此处方法签名与原 XdotoolDriver 一致。

        Args:
            to_wxid: 目标 wxid
            file_path: 容器内文件绝对路径
            is_image: 是否为图片
            display_name: 用于搜索定位会话的显示名

        Returns:
            本地生成的 channel_msg_id，格式 local_<unix秒>_<随机>

        Raises:
            BridgeError(INVALID_PARAMS): 文件不存在或不可读
        """
        # 文件存在性校验
        if not file_path or not os.path.isfile(file_path):
            raise BridgeError(
                code="INVALID_PARAMS",
                message=f"文件不存在: {file_path}",
            )
        if not os.access(file_path, os.R_OK):
            raise BridgeError(
                code="INVALID_PARAMS",
                message=f"文件不可读: {file_path}",
            )

        # 1. 定位会话
        await self._open_session_by_name(display_name if display_name else to_wxid)
        # 1.5 鼠标点击聊天输入框强制焦点（与 send_text 同理）
        await self._click_input_box()

        # 2. MVP：发送文件路径提示文本（真实文件传输留给后续完善）
        filename = os.path.basename(file_path)
        prefix = "[图片]" if is_image else "[文件]"
        hint = f"{prefix} {filename}"
        await self._paste_via_xclip(hint)
        await self._sleep(0.2)
        await self._key("Return")

        # 3. 生成 local_send_id（本地 ID，非微信原生 msg_id）
        local_send_id = f"local_{int(time.time())}_{random.randint(0, 0xFFFFFF):06x}"
        return local_send_id

    # ------------------------------------------------------------------
    # 撤回消息（experimental）
    # ------------------------------------------------------------------
    async def revoke_message(
        self,
        talker: str,
        create_time: int,
        display_name: Optional[str] = None,
        timeout_sec: float = 15.0,
    ) -> bool:
        """撤回指定会话中最近发送的消息（experimental）。

        微信撤回限制：发送后 2 分钟内可撤回。UI 路径：
        1. 定位到指定会话（复用 _open_session_by_name）
        2. 获取窗口几何，估算最近一条自己发送的消息气泡位置
        3. 长按该位置触发消息菜单
        4. 按 Esc 关闭可能的多级菜单，再重新长按
        5. 在弹出的菜单中查找"撤回"项

        Args:
            talker: 会话对方 wxid（用于 DB 解析 display_name）
            create_time: 消息发送时间戳，必须 > 0，用于校验是否在 2 分钟撤回窗口内
            display_name: 可选，会话显示名（备注/昵称）用于搜索框定位；
                不传则用 talker (wxid) 作为搜索关键词
            timeout_sec: 总超时秒数

        Returns:
            True 表示 UI 操作流程执行完成（不代表微信一定撤回成功）

        Raises:
            BridgeError(INVALID_PARAMS): create_time <= 0
            BridgeError(WINDOW_NOT_FOUND): 找不到微信窗口
            BridgeError(SEND_FAILED): 超时或 UI 操作失败
            BridgeError(REVOKE_WINDOW_EXPIRED): 超过 2 分钟撤回时限
        """
        # create_time 必须有效，避免绕过时限校验
        if create_time <= 0:
            raise BridgeError(
                code="INVALID_PARAMS",
                message=f"create_time 必须 > 0，收到 {create_time}",
            )
        # 撤回时间窗校验（2 分钟 = 120 秒，Task 11 收紧到 110s 留 10s UI 操作余量）
        now = int(time.time())
        if now - create_time > 110:
            raise BridgeError(
                code="REVOKE_WINDOW_EXPIRED",
                message=f"消息发送于 {now - create_time}s 前，超过 110s 撤回时限（已为 UI 操作预留 10s 余量）",
            )

        deadline = time.monotonic() + timeout_sec

        # 1. 定位会话（display_name 优先，回退到 wxid）
        search_name = display_name or talker
        await self._step(self._open_session_by_name(search_name), "定位会话", deadline)

        # 2. 获取窗口几何
        window_id = await self._window_driver.find_wechat_window()
        if window_id is None:
            raise BridgeError(
                code="WINDOW_NOT_FOUND",
                message="未找到微信窗口，无法撤回消息",
            )
        returncode, stdout, _ = await self._run(
            ["xdotool", "getwindowgeometry", "--shell", str(window_id)]
        )
        if returncode != 0:
            raise BridgeError(
                code="SEND_FAILED",
                message="无法获取微信窗口几何信息",
            )
        geom = self._parse_window_geometry(stdout.decode(errors="ignore"))
        if geom is None:
            raise BridgeError(
                code="SEND_FAILED",
                message="解析窗口几何信息失败",
            )
        win_x, win_y, win_w, win_h = geom.x, geom.y, geom.width, geom.height

        # 3. 估算最近一条消息气泡位置并长按
        msg_x = win_x + win_w * 3 // 4
        msg_y = win_y + win_h - 150
        await self._step(self._long_press(msg_x, msg_y, 1.5), "长按消息", deadline)
        await self._step(self._sleep(0.6), "等待菜单弹出", deadline)

        # 4. 在菜单中查找"撤回"项
        await self._step(self._key("Down"), "导航到撤回项", deadline)
        await self._step(self._sleep(0.2), "等待选中", deadline)
        await self._step(self._key("Down"), "导航到撤回项", deadline)
        await self._step(self._sleep(0.2), "等待选中", deadline)
        await self._step(self._key("Return"), "选择撤回", deadline)

        # 5. 可能有确认对话框
        await self._step(self._sleep(0.5), "等待确认对话框", deadline)
        await self._step(self._key("Return"), "确认撤回", deadline)
        await self._step(self._sleep(0.5), "等待撤回完成", deadline)

        logger.info(
            "revoke_message: talker=%s create_time=%d → UI 操作完成",
            talker, create_time,
        )
        return True

    # ------------------------------------------------------------------
    # 转发消息（experimental）
    # ------------------------------------------------------------------
    async def forward_message(
        self,
        talker: str,
        target_display_name: str,
        source_display_name: Optional[str] = None,
        timeout_sec: float = 20.0,
    ) -> bool:
        """转发指定会话中的消息到另一会话（experimental）。

        UI 路径：
        1. 定位到源会话
        2. 长按最近一条消息触发菜单
        3. 选择"转发"
        4. 在转发目标选择界面搜索目标联系人
        5. 选中目标并确认转发

        Args:
            talker: 源会话对方 wxid（用于 DB 解析 source_display_name）
            target_display_name: 转发目标联系人的显示名
            source_display_name: 可选，源会话显示名用于搜索框定位；
                不传则用 talker (wxid) 作为搜索关键词
            timeout_sec: 总超时秒数

        Returns:
            True 表示 UI 操作流程执行完成

        Raises:
            BridgeError(WINDOW_NOT_FOUND / SEND_FAILED)
        """
        deadline = time.monotonic() + timeout_sec

        # 1. 定位源会话（source_display_name 优先，回退到 wxid）
        search_name = source_display_name or talker
        await self._step(self._open_session_by_name(search_name), "定位源会话", deadline)

        # 2. 获取窗口几何
        window_id = await self._window_driver.find_wechat_window()
        if window_id is None:
            raise BridgeError(
                code="WINDOW_NOT_FOUND",
                message="未找到微信窗口，无法转发消息",
            )
        returncode, stdout, _ = await self._run(
            ["xdotool", "getwindowgeometry", "--shell", str(window_id)]
        )
        if returncode != 0:
            raise BridgeError(
                code="SEND_FAILED",
                message="无法获取微信窗口几何信息",
            )
        geom = self._parse_window_geometry(stdout.decode(errors="ignore"))
        if geom is None:
            raise BridgeError(
                code="SEND_FAILED",
                message="解析窗口几何信息失败",
            )
        win_x, win_y, win_w, win_h = geom.x, geom.y, geom.width, geom.height

        # 3. 长按最近消息
        msg_x = win_x + win_w // 2
        msg_y = win_y + win_h - 150
        await self._step(self._long_press(msg_x, msg_y, 1.5), "长按消息", deadline)
        await self._step(self._sleep(0.6), "等待菜单弹出", deadline)

        # 4. 选择"转发"（菜单项通常在"复制"附近）
        await self._step(self._key("Down"), "导航到转发项", deadline)
        await self._step(self._sleep(0.2), "等待选中", deadline)
        await self._step(self._key("Return"), "选择转发", deadline)
        await self._step(self._sleep(1.0), "等待转发界面打开", deadline)

        # 5. 在转发界面搜索目标
        await self._step(self._key("ctrl+f"), "打开转发搜索", deadline)
        await self._step(self._sleep(0.4), "等待搜索框", deadline)
        await self._step(self._paste_via_xclip(target_display_name), "粘贴目标名称", deadline)
        await self._step(self._sleep(0.8), "等待搜索结果", deadline)

        # 6. 选中第一个结果
        await self._step(self._key("Down"), "选中目标", deadline)
        await self._step(self._sleep(0.3), "等待选中", deadline)
        await self._step(self._key("Return"), "进入选择", deadline)

        # 7. 确认转发
        await self._step(self._sleep(0.5), "等待确认按钮", deadline)
        await self._step(self._key("Return"), "确认转发", deadline)
        await self._step(self._sleep(1.0), "等待转发完成", deadline)

        logger.info(
            "forward_message: talker=%s target=%s → UI 操作完成",
            talker, target_display_name,
        )
        return True

    # ------------------------------------------------------------------
    # Task 11: post_verify 校验方法（UI 操作成功后校验 DB，返回 verified bool）
    # ------------------------------------------------------------------
    async def post_verify_revoke_message(
        self,
        db_reader: Any,
        talker: str,
        create_time: int,
        sleep_sec: float = 1.5,
        timeout_sec: float = 3.0,
    ) -> bool:
        """校验撤回是否生效（Task 11）。

        sleep 1.5s 后轮询 DB 3s（间隔 1s），查询 talker 会话中 create_time
        之后的消息，成功判定 local_type in (10000, 10002) 或 content 含
        "撤回了一条消息"。

        Args:
            db_reader: DbReader 实例（可为 None，此时跳过返回 True）
            talker: 会话对方 wxid
            create_time: 原消息发送时间戳（用于建立 DB 查询游标）
            sleep_sec: 首次查询前等待秒数
            timeout_sec: 轮询超时秒数

        Returns:
            True 表示检测到撤回系统消息；False 表示超时未检测到
        """
        if db_reader is None:
            logger.warning(
                "post_verify_revoke_message: db_reader 为 None，跳过校验 (talker=%s)",
                talker,
            )
            return True
        await asyncio.sleep(sleep_sec)
        deadline = time.monotonic() + timeout_sec
        while time.monotonic() < deadline:
            try:
                # 查 create_time 之后的新消息（撤回系统消息 local_type=10000/10002）
                result = await asyncio.to_thread(
                    db_reader.get_messages_by_session,
                    talker,
                    cursor=create_time,
                    limit=5,
                    direction="after",
                )
                for msg in result.get("messages", []):
                    local_type = int(msg.get("type", 0))
                    content = msg.get("content", "") or ""
                    if local_type in (10000, 10002) or "撤回了一条消息" in content:
                        logger.info(
                            "post_verify_revoke_message: 检测到撤回标记 (type=%d)",
                            local_type,
                        )
                        return True
            except Exception as exc:
                logger.warning(
                    "post_verify_revoke_message: DB 查询异常 (talker=%s): %s",
                    talker, exc,
                )
            await asyncio.sleep(1.0)
        logger.warning(
            "post_verify_revoke_message: 超时 %.1fs 未检测到撤回标记 (talker=%s)",
            timeout_sec, talker,
        )
        return False

    async def _resolve_wxid_by_display_name(
        self,
        db_reader: Any,
        display_name: str,
    ) -> str:
        """通过显示名解析联系人 wxid（post_verify 共用 helper）。

        用 get_contacts(keyword=display_name) 模糊查 contact 表，优先精确匹配
        nickname/remark，否则取第一条。解析失败返回空字符串。

        Args:
            db_reader: DbReader 实例（可为 None，返回 ""）
            display_name: 联系人显示名

        Returns:
            解析到的 wxid；失败返回 ""
        """
        if db_reader is None or not display_name:
            return ""
        try:
            result = await asyncio.to_thread(
                db_reader.get_contacts, display_name, 5
            )
            contacts = result.get("contacts", []) if result else []
            if contacts:
                # 优先精确匹配 nickname/remark，否则取第一条
                for c in contacts:
                    if c.get("nickname") == display_name or c.get("remark") == display_name:
                        return c.get("wxid", "")
                return contacts[0].get("wxid", "")
        except Exception as exc:
            logger.warning(
                "_resolve_wxid_by_display_name: 解析 wxid 异常 (display_name=%s): %s",
                display_name, exc,
            )
        return ""

    async def capture_forward_baseline(
        self,
        db_reader: Any,
        target_display_name: str,
    ) -> tuple[int, int]:
        """捕获转发目标会话的消息基线（UI 操作前调用）。

        P0 修复：post_verify_forward_message 原实现无基线游标，查目标会话最近
        5 条 type=49 消息时会把历史转发消息误报为新转发。本方法在 UI 操作前
        查询目标会话当前最新消息的 (create_time, local_id) 作为基线，供
        post_verify 用 direction="after" 只查 UI 操作后的新消息。

        Args:
            db_reader: DbReader 实例（可为 None）
            target_display_name: 转发目标联系人的显示名

        Returns:
            (max_create_time, max_local_id)；db_reader 为 None / wxid 解析失败 /
            会话无消息时返回 (0, 0)
        """
        if db_reader is None:
            return (0, 0)
        target_wxid = await self._resolve_wxid_by_display_name(
            db_reader, target_display_name
        )
        if not target_wxid:
            logger.warning(
                "capture_forward_baseline: 无法解析目标 wxid (target=%s)，基线=(0,0)",
                target_display_name,
            )
            return (0, 0)
        try:
            result = await asyncio.to_thread(
                db_reader.get_messages_by_session,
                target_wxid,
                cursor=0,
                limit=1,
                direction="before",
            )
            messages = result.get("messages", []) if result else []
            if not messages:
                return (0, 0)
            msg = messages[0]
            return (
                int(msg.get("create_time", 0)),
                int(msg.get("local_id", 0)),
            )
        except Exception as exc:
            logger.warning(
                "capture_forward_baseline: 查询目标会话基线异常 (target=%s): %s",
                target_display_name, exc,
            )
            return (0, 0)

    async def post_verify_forward_message(
        self,
        db_reader: Any,
        target_display_name: str,
        sleep_sec: float = 2.0,
        timeout_sec: float = 3.0,
        baseline_create_time: int = 0,
        baseline_local_id: int = 0,
    ) -> bool:
        """校验转发是否生效（Task 11）。

        sleep 2s 后查目标会话消息，成功判定 type=49（转发/链接消息）。目标 wxid
        通过 get_contacts(keyword=target_display_name) 解析，解析失败返回 False。

        P0 修复：新增 baseline_create_time / baseline_local_id 参数。传入基线时
        用 direction="after" 只查 UI 操作后的新消息，避免历史 type=49 消息误报；
        未传基线（默认 0,0）时回退原行为（查最近 5 条），并打 warning 说明可能
        误报，保持向后兼容。

        Args:
            db_reader: DbReader 实例（可为 None，此时跳过返回 True）
            target_display_name: 转发目标联系人的显示名
            sleep_sec: 首次查询前等待秒数
            timeout_sec: 轮询超时秒数
            baseline_create_time: UI 操作前目标会话最新消息的 create_time；
                >0 时启用基线模式（只查基线之后的新消息）
            baseline_local_id: 配合 baseline_create_time 的 tie-breaker

        Returns:
            True 表示目标会话出现 type=49 转发消息；False 表示未检测到
        """
        if db_reader is None:
            logger.warning(
                "post_verify_forward_message: db_reader 为 None，跳过校验 (target=%s)",
                target_display_name,
            )
            return True
        # 解析目标 wxid（复用 helper，避免重复逻辑）
        target_wxid = await self._resolve_wxid_by_display_name(
            db_reader, target_display_name
        )
        if not target_wxid:
            logger.warning(
                "post_verify_forward_message: 无法解析目标 wxid (target=%s)，返回 False",
                target_display_name,
            )
            return False
        use_baseline = baseline_create_time > 0
        if not use_baseline:
            logger.warning(
                "post_verify_forward_message: 未传基线，回退查最近 5 条消息，"
                "历史 type=49 消息可能误报 (target=%s)",
                target_display_name,
            )
        await asyncio.sleep(sleep_sec)
        deadline = time.monotonic() + timeout_sec
        while time.monotonic() < deadline:
            try:
                if use_baseline:
                    # 基线模式：只查基线之后的新消息，避免历史消息误报
                    result = await asyncio.to_thread(
                        db_reader.get_messages_by_session,
                        target_wxid,
                        cursor=baseline_create_time,
                        limit=5,
                        direction="after",
                        cursor_local_id=baseline_local_id,
                    )
                else:
                    # 向后兼容：查最近 5 条消息（有误报风险）
                    result = await asyncio.to_thread(
                        db_reader.get_messages_by_session,
                        target_wxid,
                        cursor=0,
                        limit=5,
                        direction="before",
                    )
                for msg in result.get("messages", []):
                    if int(msg.get("type", 0)) == 49:
                        logger.info(
                            "post_verify_forward_message: 检测到 type=49 转发消息 (target=%s, baseline=%s)",
                            target_display_name, use_baseline,
                        )
                        return True
            except Exception as exc:
                logger.warning(
                    "post_verify_forward_message: DB 查询异常 (target=%s): %s",
                    target_display_name, exc,
                )
            await asyncio.sleep(1.0)
        logger.warning(
            "post_verify_forward_message: 超时 %.1fs 未检测到转发消息 (target=%s)",
            timeout_sec, target_display_name,
        )
        return False
