"""xdotool / xclip 异步驱动。

封装对微信窗口的所有 X11 自动化操作：查找窗口、检测登录态、激活窗口、
通过 Ctrl+F 搜索会话并粘贴发送文本。所有外部命令通过 asyncio 子进程执行，
不阻塞 event loop。
"""

from __future__ import annotations

import asyncio
import base64
import logging
import os
import random
import time
from typing import Awaitable, Optional

from woc_bridge.models import BridgeError, LoginState


# 模块级 logger：与 server.py 同名，便于 s6 统一收日志
logger = logging.getLogger("woc-bridge")


class XdotoolDriver:
    """xdotool/xclip 异步驱动。

    所有方法均为 async，内部使用 asyncio.create_subprocess_exec 调用
    xdotool / xclip / pgrep 等命令，并通过 DISPLAY 环境变量指定 X server。
    """

    def __init__(self, display: str = ":1") -> None:
        """保存 DISPLAY 环境变量值。

        Args:
            display: X server display 地址，如 ":1"
        """
        self.display = display

    # ------------------------------------------------------------------
    # 环境与底层工具
    # ------------------------------------------------------------------
    def _env(self) -> dict:
        """返回带 DISPLAY 的环境副本。"""
        env = dict(os.environ)
        env["DISPLAY"] = self.display
        return env

    async def _run(
        self,
        args: list[str],
        *,
        input_bytes: Optional[bytes] = None,
    ) -> tuple[int, bytes, bytes]:
        """执行一条命令并返回 (returncode, stdout, stderr)。

        Args:
            args: 命令及其参数列表，如 ["xdotool", "search", "--name", "微信"]
            input_bytes: 需要写入 stdin 的字节
        """
        proc = await asyncio.create_subprocess_exec(
            *args,
            stdin=asyncio.subprocess.PIPE if input_bytes is not None else None,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
            env=self._env(),
        )
        stdout, stderr = await proc.communicate(input=input_bytes)
        return proc.returncode, stdout, stderr

    async def _key(self, key: str) -> None:
        """执行 xdotool key <key>。"""
        logger.info("[ui] key: %s", key)
        rc, stdout, stderr = await self._run(["xdotool", "key", key])
        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 find_wechat_window(self) -> Optional[int]:
        """用 xdotool search --name "微信" 查找微信窗口 ID。

        Returns:
            窗口 ID（int），找不到返回 None
        """
        returncode, stdout, stderr = await self._run(
            ["xdotool", "search", "--name", "微信"]
        )
        if returncode != 0:
            return None
        text = stdout.decode(errors="ignore").strip()
        if not text:
            return None
        # 取第一个匹配的窗口 ID
        first_line = text.splitlines()[0].strip()
        try:
            return int(first_line)
        except ValueError:
            return None

    async def is_wechat_running(self) -> bool:
        """判断微信进程是否运行。

        用 pgrep -x 精确匹配进程名 wechat（避免 -f 子串匹配误中
        bridge 自身命令行里的 /config/.config/xwechat 路径）。
        若 pgrep 不可用，回落到 xdotool search（窗口存在即视为进程运行）。
        """
        # 用 -x 精确进程名匹配，避免 -f 子串误中 bridge 自身
        # 微信 4.0 Linux 进程名通常为 "wechat"
        for name in ("wechat", "WeChat"):
            returncode, stdout, _ = await self._run(["pgrep", "-x", name])
            if returncode == 0 and stdout.decode(errors="ignore").strip():
                return True
        # 回落：窗口存在即视为运行
        window_id = await self.find_wechat_window()
        return window_id is not None

    async def activate_window(self, window_id: Optional[int] = None) -> None:
        """激活微信窗口。

        Args:
            window_id: 指定窗口 ID；为 None 时自动查找
        """
        if window_id is None:
            window_id = await self.find_wechat_window()
        if window_id is None:
            raise BridgeError(
                code="WINDOW_NOT_FOUND",
                message="未找到微信窗口，无法激活",
            )
        await self._run(["xdotool", "windowactivate", "--sync", str(window_id)])

    async def _activate_window_fast(self, window_id: Optional[int] = None) -> None:
        """非阻塞激活微信窗口，避免 --sync 在 VNC 无人操作时死等。"""
        if window_id is None:
            window_id = await self.find_wechat_window()
        if window_id is None:
            raise BridgeError(
                code="WINDOW_NOT_FOUND",
                message="未找到微信窗口，无法激活",
            )
        await self._run(["xdotool", "windowactivate", str(window_id)])

    async def restart_wechat(self, timeout_sec: int = 30) -> int:
        """重启微信进程：发 SIGTERM 让微信优雅退出，等待 autostart 拉起新进程。

        微信由容器内 autostart 脚本常驻拉起（while true 循环 + sleep 2），
        故 bridge 只需 kill，autostart 会在 2 秒后自动重启。不强制 SIGKILL，
        给微信优雅退出机会（避免 DB 写入未刷盘）。

        Args:
            timeout_sec: 等待新进程出现的最大秒数，默认 30

        Returns:
            新进程的 PID

        Raises:
            BridgeError(RESTART_TIMEOUT): 超时未检测到新进程
        """
        # 1. 获取当前 PID（若有）
        returncode, stdout, _ = await self._run(["pgrep", "-x", "wechat"])
        old_pids: set[int] = set()
        if returncode == 0:
            for line in stdout.decode(errors="ignore").splitlines():
                line = line.strip()
                if line:
                    try:
                        old_pids.add(int(line))
                    except ValueError:
                        pass

        # 2. 若有旧进程，发 SIGTERM
        for pid in old_pids:
            # 用 kill（shell 内建）发 SIGTERM，不强制 SIGKILL
            await self._run(["kill", "-TERM", str(pid)])

        # 3. 等待新进程出现（autostart 会在 2 秒后拉起）
        deadline = time.monotonic() + timeout_sec
        while time.monotonic() < deadline:
            await self._sleep(1.0)
            rc, stdout, _ = await self._run(["pgrep", "-x", "wechat"])
            if rc == 0:
                for line in stdout.decode(errors="ignore").splitlines():
                    line = line.strip()
                    if not line:
                        continue
                    try:
                        new_pid = int(line)
                    except ValueError:
                        continue
                    # 确认是新 PID（不在旧 PID 集合中）
                    if new_pid not in old_pids:
                        return new_pid

        # 4. 超时
        raise BridgeError(
            code="RESTART_TIMEOUT",
            message=f"等待微信重启超时（{timeout_sec}s）",
        )

    async def detect_login_state(self) -> str:
        """检测登录态（启发式）。

        MVP 阶段判定逻辑：
        - 微信进程未运行 → not_running
        - 进程运行但窗口未找到 → not_logged_in
        - 窗口存在 → logged_in

        Returns:
            LoginState 枚举值（字符串）
        """
        running = await self.is_wechat_running()
        if not running:
            return LoginState.NOT_RUNNING.value
        window_id = await self.find_wechat_window()
        if window_id is None:
            return LoginState.NOT_LOGGED_IN.value
        # MVP：窗口存在即视为已登录
        return LoginState.LOGGED_IN.value

    # ------------------------------------------------------------------
    # 显式启动微信（供 diagnostic autofix 使用）
    # ------------------------------------------------------------------
    # 微信可执行文件固定路径（与 docker/app-defs.sh 中 wechat 类型一致）。
    # 仅对 wechat 类型实例有意义；Telegram/Chromium/自定义应用的生命周期
    # 不应通过 bridge autofix 干预。
    _WECHAT_BIN = "/config/wechat/opt/wechat/wechat"

    async def start_wechat(self, timeout_sec: int = 10) -> Optional[int]:
        """显式启动微信进程，返回新 PID 或 None。

        用于 diagnostic autofix：pkill 后 autostart watchdog 未拉起时，
        bridge 显式启动作为兜底。避免引入 shell 依赖，直接执行二进制。

        竞态规避：
        1. 启动前再次 pgrep，已有进程则直接返回（不重复启动）
        2. 启动后轮询 pgrep，等待新 PID 出现

        Args:
            timeout_sec: 等待新进程出现的最大秒数，默认 10

        Returns:
            新进程 PID；启动失败或超时返回 None
        """
        # 1. 启动前再 pgrep，已有则跳过（避免与 autostart 竞态）
        existing_pid = await self.check_wechat_pid()
        if existing_pid is not None:
            return existing_pid

        # 2. 显式启动（disown 语义：不等待进程结束）
        #    DISPLAY/XAUTHORITY 由 _env() 透传，确保 wechat 能连接 X
        try:
            await asyncio.create_subprocess_exec(
                self._WECHAT_BIN,
                stdout=asyncio.subprocess.DEVNULL,
                stderr=asyncio.subprocess.DEVNULL,
                env=self._env(),
                # 启动后立即 detach，不让 wechat 成为 bridge 的子进程
                # （避免 bridge 退出时连带杀掉 wechat）
                start_new_session=True,
            )
        except Exception as e:
            logger.warning("start_wechat: 启动 %s 失败: %r", self._WECHAT_BIN, e)
            return None

        # 3. 等待新 PID 出现（不依赖 subprocess 返回的 pid，因为 wechat
        #    可能 fork 出独立进程，pgrep 比依赖 proc.pid 更可靠）
        deadline = time.monotonic() + timeout_sec
        while time.monotonic() < deadline:
            await self._sleep(1.0)
            new_pid = await self.check_wechat_pid()
            if new_pid is not None:
                return new_pid

        logger.warning("start_wechat: 等待 %ss 内未检测到新进程", timeout_sec)
        return None

    async def check_wechat_pid(self) -> Optional[int]:
        """返回当前运行的微信主进程 PID，无则返回 None。"""
        returncode, stdout, _ = await self._run(["pgrep", "-x", "wechat"])
        if returncode != 0:
            return None
        for line in stdout.decode(errors="ignore").splitlines():
            line = line.strip()
            if not line:
                continue
            try:
                return int(line)
            except ValueError:
                continue
        return None

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

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

        方法名保留 _paste_via_xclip 以维持调用点稳定，内部实现已切换。

        Args:
            text: 待输入文本（支持中英文、路径）

        Raises:
            BridgeError(SEND_FAILED): xdotool type 执行失败
        """
        logger.info("[ui] type text: len=%d", len(text))
        rc, _, stderr = await self._run(["xdotool", "type", 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)

    # ------------------------------------------------------------------
    # 会话定位（Ctrl+F 搜索）
    # ------------------------------------------------------------------
    async def _open_session_by_name(
        self,
        name: str,
        timeout_sec: float = 10.0,
    ) -> None:
        """通过微信搜索框定位并进入指定联系人的会话。

        流程：
        1. 非阻塞激活微信窗口
        2. 按 Esc 关闭可能存在的搜索框/弹窗
        3. Ctrl+F 打开搜索
        4. Ctrl+A 全选旧内容，粘贴 display_name
        5. 等待搜索结果渲染
        6. 按 ↓ 选中第一个结果，回车进入会话
        7. 再按 Esc 确保退出搜索模式，焦点落在聊天输入框

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

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

        deadline = time.monotonic() + timeout_sec

        async def _step(coro: Awaitable[None], desc: str) -> None:
            """执行单个 UI 步骤并加超时保护。"""
            remaining = deadline - time.monotonic()
            logger.info("[ui] step start: %s", desc)
            if remaining <= 0:
                raise BridgeError(
                    code="SEND_FAILED",
                    message=f"定位会话超时: {desc}",
                )
            try:
                await asyncio.wait_for(coro, timeout=max(1.0, remaining))
            except asyncio.TimeoutError as exc:
                logger.error("[ui] step timeout: %s", desc)
                raise BridgeError(
                    code="SEND_FAILED",
                    message=f"定位会话步骤超时: {desc}",
                ) from exc
            logger.info("[ui] step done: %s", desc)

        # 1. 激活窗口（非阻塞，避免 --sync 死等）
        window_id = await self.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 _step(self._activate_window_fast(window_id), "激活窗口")
        await _step(self._sleep(0.3), "等待窗口激活")

        # 2. 关闭可能存在的搜索框/弹窗
        await _step(self._key("Escape"), "关闭搜索框")
        await _step(self._key("Escape"), "再按一次 Esc")
        await _step(self._key("Escape"), "第三次 Esc 确保退出")
        await _step(self._sleep(0.2), "等待 Esc 生效")

        # 3. 打开搜索
        await _step(self._key("ctrl+f"), "打开搜索")
        await _step(self._sleep(0.4), "等待搜索框打开")

        # 4. 清空并输入搜索关键词
        await _step(self._key("ctrl+a"), "全选搜索框内容")
        await _step(self._sleep(0.1), "等待全选")
        await _step(self._paste_via_xclip(name), "粘贴搜索关键词")
        await _step(self._sleep(0.8), "等待搜索结果")

        # 5. 选中第一个结果并进入会话
        await _step(self._key("Down"), "选中搜索结果")
        await _step(self._sleep(0.3), "等待选中")
        await _step(self._key("Return"), "进入会话")
        await _step(self._sleep(0.6), "等待会话打开")

        # 6. 退出搜索模式，确保焦点在输入框
        await _step(self._key("Escape"), "退出搜索模式")
        await _step(self._sleep(0.2), "等待焦点稳定")

    # ------------------------------------------------------------------
    # 发送文本
    # ------------------------------------------------------------------
    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
        logger.info("[send_text] to=%s name=%s content_len=%s", to_wxid, name, len(content))
        # 1. 定位会话
        await self._open_session_by_name(name)
        logger.info("[send_text] session opened")
        # 2. 粘贴内容并发送
        await self._paste_via_xclip(content)
        logger.info("[send_text] content pasted")
        await self._sleep(0.2)
        await self._key("Return")
        logger.info("[send_text] return pressed")
        # 3. 生成 local_send_id（本地 ID，非微信原生 msg_id）
        local_send_id = f"local_{int(time.time())}_{random.randint(0, 0xFFFFFF):06x}"
        logger.info("[send_text] done local_send_id=%s", local_send_id)
        return local_send_id

    # ------------------------------------------------------------------
    # 发送文件 / 图片
    # ------------------------------------------------------------------
    async def send_file(
        self,
        to_wxid: str,
        file_path: str,
        is_image: bool = False,
        display_name: Optional[str] = None,
    ) -> str:
        """发送文件/图片（MVP 简化版）。

        MVP 策略：检查文件可读 → 进入会话（复用 _open_session_by_name）
        → 发送"[图片/文件] 文件名"提示文本。真实文件传输机制留给后续完善。

        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)

        # 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

    # ------------------------------------------------------------------
    # 退出登录
    # ------------------------------------------------------------------
    async def logout(self) -> None:
        """通过 UI 操作退出微信登录。

        流程：
        1. 检测登录态，若非 logged_in 直接返回（幂等）
        2. 查找并激活微信窗口（复用 window_id）
        3. 获取窗口几何，点击左下角主菜单图标
        4. 等待菜单弹出，用方向键导航到「退出登录」并回车
        5. 若出现确认对话框，按回车确认
        6. 等待 2 秒让 UI 完成切换

        注意：下方点击与方向键坐标/次数均为估算值，需在目标分辨率
        实测后调优（微信 4.0 Linux UI 路径见模块 docstring）。

        Raises:
            BridgeError(LOGOUT_FAILED): 窗口未找到或 UI 操作失败
        """
        # 1. 检测登录态（幂等）
        state = await self.detect_login_state()
        if state != LoginState.LOGGED_IN.value:
            # 未登录，无需退出
            return

        # 2. 查找窗口 ID 并激活（复用 window_id，避免二次 search）
        window_id = await self.find_wechat_window()
        if window_id is None:
            raise BridgeError(
                code="LOGOUT_FAILED",
                message="未找到微信窗口，无法退出登录",
            )
        await self.activate_window(window_id)

        # 3. 获取窗口几何（用已知的 window_id，避免命令链歧义）
        returncode, stdout, _ = await self._run(
            ["xdotool", "getwindowgeometry", "--shell", str(window_id)]
        )
        if returncode != 0:
            raise BridgeError(
                code="LOGOUT_FAILED",
                message="无法获取微信窗口几何信息",
            )
        # 解析 getwindowgeometry --shell 输出（KEY=VALUE 格式）
        geom = {}
        for line in stdout.decode(errors="ignore").splitlines():
            if "=" in line:
                k, v = line.split("=", 1)
                geom[k.strip()] = v.strip()
        try:
            win_x = int(geom.get("X", 0))
            win_y = int(geom.get("Y", 0))
            win_w = int(geom.get("WIDTH", 800))
            win_h = int(geom.get("HEIGHT", 600))
        except ValueError:
            raise BridgeError(
                code="LOGOUT_FAILED",
                message="解析窗口几何信息失败",
            )

        # 4. 点击左下角主菜单图标
        #    估算：窗口左侧栏底部，约 x=win_x+30, y=win_y+win_h-30
        #    该坐标随分辨率/缩放变化，需实测调优。
        menu_x = win_x + 30
        menu_y = win_y + win_h - 30
        rc, _, _ = await self._run(
            ["xdotool", "mousemove", "--sync", str(menu_x), str(menu_y)]
        )
        if rc != 0:
            raise BridgeError(code="LOGOUT_FAILED", message="移动鼠标到主菜单失败")
        # 用 xdotool click 1（左键单击）
        rc, _, _ = await self._run(["xdotool", "click", "1"])
        if rc != 0:
            raise BridgeError(code="LOGOUT_FAILED", message="点击主菜单失败")

        # 5. 等待菜单弹出
        await self._sleep(0.8)

        # 6. 用方向键导航到「退出登录」并回车
        #    微信菜单项顺序通常为：设置 / 切换账号 / 退出登录 / 关闭
        #    退出登录一般在第 3 项，从顶部按 ↓ 2 次到达
        #    该顺序为估算，需实测调优。
        await self._key("Down")
        await self._sleep(0.2)
        await self._key("Down")
        await self._sleep(0.2)
        await self._key("Return")

        # 7. 等待确认对话框
        await self._sleep(0.8)
        # 按回车确认（默认焦点通常在确认按钮）
        await self._key("Return")

        # 8. 等待 UI 完成切换
        await self._sleep(2.0)

    # ------------------------------------------------------------------
    # 发表朋友圈（纯文字）
    # ------------------------------------------------------------------
    async def publish_moment(
        self,
        content: str,
        timeout_sec: float = 20.0,
    ) -> str:
        """发表纯文字朋友圈。

        流程：
        1. 激活微信窗口
        2. 获取窗口几何，点击左侧栏「朋友圈」图标进入朋友圈页
        3. 点击右上角相机图标 → 选择「发表文字」
        4. 粘贴文字内容
        5. 点击「发表」按钮
        6. 按 Esc 关闭朋友圈页回到主界面

        注意：下方点击坐标均为估算值（基于微信 4.0 Linux 默认布局），
        需在目标分辨率实测后调优：
        - 朋友圈入口：左侧栏底部图标，约 x=win_x+30, y=win_y+win_h-90
        - 相机图标：朋友圈页右上角，约 x=win_x+win_w-30, y=win_y+30
        - 「发表文字」菜单项：相机下方第 1 项
        - 「发表」按钮：发表文字弹窗右上角，约 x=win_x+win_w-60, y=win_y+60

        Args:
            content: 朋友圈文字内容
            timeout_sec: 整体超时（秒），默认 20

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

        Raises:
            BridgeError(WINDOW_NOT_FOUND): 找不到微信窗口
            BridgeError(SEND_FAILED): 超时或 UI 操作失败
        """
        if not content:
            raise BridgeError(
                code="SEND_FAILED",
                message="content 不能为空，无法发表朋友圈",
            )

        deadline = time.monotonic() + timeout_sec

        async def _step(coro: Awaitable[None], desc: str) -> None:
            """执行单个 UI 步骤并加超时保护。"""
            remaining = deadline - time.monotonic()
            if remaining <= 0:
                raise BridgeError(
                    code="SEND_FAILED",
                    message=f"发表朋友圈超时: {desc}",
                )
            try:
                await asyncio.wait_for(coro, timeout=max(1.0, remaining))
            except asyncio.TimeoutError as exc:
                raise BridgeError(
                    code="SEND_FAILED",
                    message=f"发表朋友圈步骤超时: {desc}",
                ) from exc

        # 1. 激活窗口（非阻塞，避免 --sync 死等）
        window_id = await self.find_wechat_window()
        if window_id is None:
            raise BridgeError(
                code="WINDOW_NOT_FOUND",
                message="未找到微信窗口，无法发表朋友圈",
            )
        await _step(self._activate_window_fast(window_id), "激活窗口")
        await _step(self._sleep(0.3), "等待窗口激活")

        # 2. 获取窗口几何
        returncode, stdout, _ = await self._run(
            ["xdotool", "getwindowgeometry", "--shell", str(window_id)]
        )
        if returncode != 0:
            raise BridgeError(
                code="SEND_FAILED",
                message="无法获取微信窗口几何信息",
            )
        geom = {}
        for line in stdout.decode(errors="ignore").splitlines():
            if "=" in line:
                k, v = line.split("=", 1)
                geom[k.strip()] = v.strip()
        try:
            win_x = int(geom.get("X", 0))
            win_y = int(geom.get("Y", 0))
            win_w = int(geom.get("WIDTH", 800))
            win_h = int(geom.get("HEIGHT", 600))
        except ValueError:
            raise BridgeError(
                code="SEND_FAILED",
                message="解析窗口几何信息失败",
            )

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

        # 4. 点击左侧栏「朋友圈」图标（估算：左栏底部偏上）
        moment_x = win_x + 30
        moment_y = win_y + win_h - 90
        await _step(self._click(moment_x, moment_y), "点击朋友圈入口")
        await _step(self._sleep(1.0), "等待朋友圈页打开")

        # 5. 点击右上角相机图标（估算：窗口右上角）
        camera_x = win_x + win_w - 30
        camera_y = win_y + 30
        await _step(self._click(camera_x, camera_y), "点击相机图标")
        await _step(self._sleep(0.6), "等待发表菜单弹出")

        # 6. 选择「发表文字」（菜单第 1 项，按 Down 选中 + 回车）
        await _step(self._key("Down"), "选中发表文字")
        await _step(self._sleep(0.2), "等待选中")
        await _step(self._key("Return"), "进入发表文字")
        await _step(self._sleep(0.8), "等待发表文字弹窗")

        # 7. 粘贴文字内容
        await _step(self._paste_via_xclip(content), "粘贴朋友圈内容")
        await _step(self._sleep(0.3), "等待粘贴完成")

        # 8. 点击「发表」按钮（估算：弹窗右上角）
        publish_x = win_x + win_w - 60
        publish_y = win_y + 60
        await _step(self._click(publish_x, publish_y), "点击发表按钮")
        await _step(self._sleep(1.0), "等待发表完成")

        # 9. 点击左上角返回按钮回到聊天主界面（估算：窗口左上角）
        back_x = win_x + 30
        back_y = win_y + 30
        await _step(self._click(back_x, back_y), "点击返回按钮")
        await _step(self._sleep(0.5), "等待返回主界面")
        # 兜底 Esc，关闭可能残留的弹窗（如发表成功提示）
        await _step(self._key("Escape"), "兜底关闭弹窗")
        await _step(self._sleep(0.2), "等待焦点稳定")

        # 10. 生成 local_moment_id
        local_moment_id = f"moment_{int(time.time())}_{random.randint(0, 0xFFFFFF):06x}"
        return local_moment_id

    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 秒后释放。

        用于触发微信消息长按菜单（撤回/转发/复制等）。
        微信长按阈值约 1~2 秒，默认 1.5 秒。

        用 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 _right_click(self, x: int, y: int) -> None:
        """移动鼠标到 (x, y) 并右键单击（button 3）。"""
        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", "3"])
        if rc != 0:
            raise BridgeError(
                code="SEND_FAILED",
                message=f"右键点击 ({x},{y}) 失败",
            )

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

        统一封装 getwindowgeometry --shell 解析逻辑，消除 8+ 处重复代码。
        """
        window_id = await self.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 = {}
        for line in stdout.decode(errors="ignore").splitlines():
            if "=" in line:
                k, v = line.split("=", 1)
                geom[k.strip()] = v.strip()
        try:
            return (
                int(geom.get("X", 0)),
                int(geom.get("Y", 0)),
                int(geom.get("WIDTH", 800)),
                int(geom.get("HEIGHT", 600)),
            )
        except ValueError:
            raise BridgeError(
                code="SEND_FAILED",
                message="解析窗口几何信息失败",
            )

    async def _enter_moments_page(self) -> tuple[int, int, int, int]:
        """进入朋友圈页面并返回窗口几何 (win_x, win_y, win_w, win_h)。

        统一朋友圈互动接口（like/comment/delete）的前置步骤：
        激活窗口 → 获取几何 → 关闭弹窗 → 点击朋友圈入口 → 等待打开。

        避免在聊天主界面执行朋友圈操作导致误删聊天消息（C3 风险）。
        """
        window_id = await self.find_wechat_window()
        if window_id is None:
            raise BridgeError(
                code="WINDOW_NOT_FOUND",
                message="未找到微信窗口，无法进入朋友圈",
            )
        await self._activate_window_fast(window_id)
        await self._sleep(0.3)

        win_x, win_y, win_w, win_h = await self._get_window_geometry()

        # 关闭可能存在的弹窗/搜索框
        await self._key("Escape")
        await self._sleep(0.2)

        # 点击左侧栏「朋友圈」图标（估算：左栏底部偏上）
        moment_x = win_x + 30
        moment_y = win_y + win_h - 90
        await self._click(moment_x, moment_y)
        await self._sleep(1.0)

        return (win_x, win_y, win_w, win_h)

    # ------------------------------------------------------------------
    # 撤回消息（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. 获取窗口几何，估算最近一条自己发送的消息气泡位置
           （聊天区域底部偏上，约 y=win_y+win_h-150）
        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 分钟撤回时限

        Notes:
            - experimental：消息气泡坐标为估算值，需在目标分辨率实测调优
            - 撤回菜单项位置同样为估算，不同微信版本菜单顺序可能不同
            - 无法校验撤回是否真正生效（无 UI 元素检测）
        """
        # create_time 必须有效，避免绕过时限校验
        if create_time <= 0:
            raise BridgeError(
                code="INVALID_PARAMS",
                message=f"create_time 必须 > 0，收到 {create_time}",
            )
        # 撤回时间窗校验（2 分钟 = 120 秒）
        now = int(time.time())
        if now - create_time > 120:
            raise BridgeError(
                code="REVOKE_WINDOW_EXPIRED",
                message=f"消息发送于 {now - create_time}s 前，超过 120s 撤回时限",
            )

        deadline = time.monotonic() + timeout_sec

        async def _step(coro: Awaitable[None], desc: str) -> None:
            remaining = deadline - time.monotonic()
            if remaining <= 0:
                if asyncio.iscoroutine(coro):
                    coro.close()
                raise BridgeError(
                    code="SEND_FAILED",
                    message=f"撤回消息超时: {desc}",
                )
            try:
                await asyncio.wait_for(coro, timeout=max(1.0, remaining))
            except asyncio.TimeoutError as exc:
                raise BridgeError(
                    code="SEND_FAILED",
                    message=f"撤回消息步骤超时: {desc}",
                ) from exc

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

        # 2. 获取窗口几何
        window_id = await self.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 = {}
        for line in stdout.decode(errors="ignore").splitlines():
            if "=" in line:
                k, v = line.split("=", 1)
                geom[k.strip()] = v.strip()
        try:
            win_x = int(geom.get("X", 0))
            win_y = int(geom.get("Y", 0))
            win_w = int(geom.get("WIDTH", 800))
            win_h = int(geom.get("HEIGHT", 600))
        except ValueError:
            raise BridgeError(
                code="SEND_FAILED",
                message="解析窗口几何信息失败",
            )

        # 3. 估算最近一条消息气泡位置并长按
        #    聊天区域底部偏上（最新消息通常在输入框上方约 150px）
        #    右半区域（自己发送的消息在右侧）
        msg_x = win_x + win_w * 3 // 4
        msg_y = win_y + win_h - 150
        await _step(self._long_press(msg_x, msg_y, 1.5), "长按消息")
        await _step(self._sleep(0.6), "等待菜单弹出")

        # 4. 在菜单中查找"撤回"项
        #    估算：菜单出现在长按位置附近，撤回通常在菜单中部
        #    按方向键导航 + 回车选择
        await _step(self._key("Down"), "导航到撤回项")
        await _step(self._sleep(0.2), "等待选中")
        await _step(self._key("Down"), "导航到撤回项")
        await _step(self._sleep(0.2), "等待选中")
        await _step(self._key("Return"), "选择撤回")

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

        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)

        Notes:
            - experimental：消息气泡与菜单项坐标均为估算
            - 转发目标选择界面是独立窗口，坐标可能与主窗口不同
        """
        deadline = time.monotonic() + timeout_sec

        async def _step(coro: Awaitable[None], desc: str) -> None:
            remaining = deadline - time.monotonic()
            if remaining <= 0:
                if asyncio.iscoroutine(coro):
                    coro.close()
                raise BridgeError(
                    code="SEND_FAILED",
                    message=f"转发消息超时: {desc}",
                )
            try:
                await asyncio.wait_for(coro, timeout=max(1.0, remaining))
            except asyncio.TimeoutError as exc:
                raise BridgeError(
                    code="SEND_FAILED",
                    message=f"转发消息步骤超时: {desc}",
                ) from exc

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

        # 2. 获取窗口几何
        window_id = await self.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 = {}
        for line in stdout.decode(errors="ignore").splitlines():
            if "=" in line:
                k, v = line.split("=", 1)
                geom[k.strip()] = v.strip()
        try:
            win_x = int(geom.get("X", 0))
            win_y = int(geom.get("Y", 0))
            win_w = int(geom.get("WIDTH", 800))
            win_h = int(geom.get("HEIGHT", 600))
        except ValueError:
            raise BridgeError(
                code="SEND_FAILED",
                message="解析窗口几何信息失败",
            )

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

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

        # 5. 在转发界面搜索目标
        #    转发界面通常有搜索框，复用 Ctrl+F + 粘贴逻辑
        await _step(self._key("ctrl+f"), "打开转发搜索")
        await _step(self._sleep(0.4), "等待搜索框")
        await _step(self._paste_via_xclip(target_display_name), "粘贴目标名称")
        await _step(self._sleep(0.8), "等待搜索结果")

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

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

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

    # ------------------------------------------------------------------
    # 修改好友备注（experimental）
    # ------------------------------------------------------------------
    async def set_contact_remark(
        self,
        talker: str,
        remark: str,
        display_name: Optional[str] = None,
        timeout_sec: float = 15.0,
    ) -> bool:
        """修改好友备注名（experimental）。

        UI 路径：
        1. 定位到指定会话
        2. 点击右上角"..."菜单
        3. 点击"备注"项
        4. 清空输入框，粘贴新备注
        5. 点击"完成"按钮

        Args:
            talker: 好友 wxid（用于 DB 解析 display_name）
            remark: 新备注名
            display_name: 可选，会话显示名用于搜索框定位；
                不传则用 talker (wxid) 作为搜索关键词
            timeout_sec: 总超时秒数

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

        Raises:
            BridgeError(WINDOW_NOT_FOUND / SEND_FAILED)

        Notes:
            - experimental：菜单项与按钮坐标均为估算
        """
        if not remark:
            raise BridgeError(
                code="INVALID_PARAMS",
                message="remark 不能为空",
            )

        deadline = time.monotonic() + timeout_sec

        async def _step(coro: Awaitable[None], desc: str) -> None:
            remaining = deadline - time.monotonic()
            if remaining <= 0:
                if asyncio.iscoroutine(coro):
                    coro.close()
                raise BridgeError(
                    code="SEND_FAILED",
                    message=f"修改备注超时: {desc}",
                )
            try:
                await asyncio.wait_for(coro, timeout=max(1.0, remaining))
            except asyncio.TimeoutError as exc:
                raise BridgeError(
                    code="SEND_FAILED",
                    message=f"修改备注步骤超时: {desc}",
                ) from exc

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

        # 2. 获取窗口几何
        window_id = await self.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 = {}
        for line in stdout.decode(errors="ignore").splitlines():
            if "=" in line:
                k, v = line.split("=", 1)
                geom[k.strip()] = v.strip()
        try:
            win_x = int(geom.get("X", 0))
            win_y = int(geom.get("Y", 0))
            win_w = int(geom.get("WIDTH", 800))
            win_h = int(geom.get("HEIGHT", 600))
        except ValueError:
            raise BridgeError(
                code="SEND_FAILED",
                message="解析窗口几何信息失败",
            )

        # 3. 点击右上角"..."菜单
        menu_x = win_x + win_w - 30
        menu_y = win_y + 30
        await _step(self._click(menu_x, menu_y), "点击更多菜单")
        await _step(self._sleep(0.6), "等待菜单弹出")

        # 4. 选择"备注"项（通常在菜单第一项）
        await _step(self._key("Down"), "导航到备注项")
        await _step(self._sleep(0.2), "等待选中")
        await _step(self._key("Return"), "进入备注设置")
        await _step(self._sleep(0.8), "等待备注输入框")

        # 5. 清空并粘贴新备注
        await _step(self._key("ctrl+a"), "全选旧备注")
        await _step(self._sleep(0.1), "等待全选")
        await _step(self._paste_via_xclip(remark), "粘贴新备注")
        await _step(self._sleep(0.3), "等待粘贴完成")

        # 6. 点击"完成"按钮（估算：输入框右侧或弹窗右上角）
        done_x = win_x + win_w - 60
        done_y = win_y + 60
        await _step(self._click(done_x, done_y), "点击完成按钮")
        await _step(self._sleep(0.5), "等待保存完成")

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

    # ------------------------------------------------------------------
    # 添加好友（experimental）
    # ------------------------------------------------------------------
    async def add_friend(
        self,
        keyword: str,
        message: str = "",
        timeout_sec: float = 20.0,
    ) -> bool:
        """搜索并添加好友（experimental）。

        UI 路径：
        1. 激活微信窗口
        2. Ctrl+F 搜索关键词（wxid/手机号/微信号）
        3. 在搜索结果中点击"添加联系人"
        4. 可选：填写验证消息
        5. 点击"发送"提交好友请求

        Args:
            keyword: 搜索关键词（wxid/手机号/微信号）
            message: 可选好友验证消息，空则用默认

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

        Raises:
            BridgeError(WINDOW_NOT_FOUND / SEND_FAILED)

        Notes:
            - experimental：搜索结果与按钮坐标均为估算
            - 无法校验对方是否已收到请求
        """
        if not keyword:
            raise BridgeError(
                code="INVALID_PARAMS",
                message="keyword 不能为空",
            )

        deadline = time.monotonic() + timeout_sec

        async def _step(coro: Awaitable[None], desc: str) -> None:
            remaining = deadline - time.monotonic()
            if remaining <= 0:
                if asyncio.iscoroutine(coro):
                    coro.close()
                raise BridgeError(
                    code="SEND_FAILED",
                    message=f"添加好友超时: {desc}",
                )
            try:
                await asyncio.wait_for(coro, timeout=max(1.0, remaining))
            except asyncio.TimeoutError as exc:
                raise BridgeError(
                    code="SEND_FAILED",
                    message=f"添加好友步骤超时: {desc}",
                ) from exc

        # 1. 激活窗口
        window_id = await self.find_wechat_window()
        if window_id is None:
            raise BridgeError(
                code="WINDOW_NOT_FOUND",
                message="未找到微信窗口，无法添加好友",
            )
        await _step(self._activate_window_fast(window_id), "激活窗口")
        await _step(self._sleep(0.3), "等待窗口激活")

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

        # 3. 打开搜索并输入关键词
        await _step(self._key("ctrl+f"), "打开搜索")
        await _step(self._sleep(0.4), "等待搜索框打开")
        await _step(self._key("ctrl+a"), "全选搜索框内容")
        await _step(self._sleep(0.1), "等待全选")
        await _step(self._paste_via_xclip(keyword), "粘贴搜索关键词")
        await _step(self._sleep(1.0), "等待搜索结果")

        # 4. 获取窗口几何，定位"添加联系人"按钮
        returncode, stdout, _ = await self._run(
            ["xdotool", "getwindowgeometry", "--shell", str(window_id)]
        )
        if returncode != 0:
            raise BridgeError(
                code="SEND_FAILED",
                message="无法获取微信窗口几何信息",
            )
        geom = {}
        for line in stdout.decode(errors="ignore").splitlines():
            if "=" in line:
                k, v = line.split("=", 1)
                geom[k.strip()] = v.strip()
        try:
            win_x = int(geom.get("X", 0))
            win_y = int(geom.get("Y", 0))
            win_w = int(geom.get("WIDTH", 800))
            win_h = int(geom.get("HEIGHT", 600))
        except ValueError:
            raise BridgeError(
                code="SEND_FAILED",
                message="解析窗口几何信息失败",
            )

        # 5. 点击搜索结果中的"添加联系人"按钮
        #    估算：搜索结果区域右侧
        add_x = win_x + win_w - 80
        add_y = win_y + 120
        await _step(self._click(add_x, add_y), "点击添加联系人")
        await _step(self._sleep(0.8), "等待添加界面打开")

        # 6. 可选：填写验证消息
        if message and message.strip():
            await _step(self._key("ctrl+a"), "全选验证消息框")
            await _step(self._sleep(0.1), "等待全选")
            await _step(self._paste_via_xclip(message), "粘贴验证消息")
            await _step(self._sleep(0.3), "等待粘贴完成")

        # 7. 点击"发送"按钮提交好友请求
        send_x = win_x + win_w - 60
        send_y = win_y + win_h - 60
        await _step(self._click(send_x, send_y), "点击发送按钮")
        await _step(self._sleep(0.8), "等待请求发送")

        # 8. 返回主界面
        await _step(self._key("Escape"), "返回主界面")
        await _step(self._sleep(0.3), "等待返回")

        logger.info(
            "add_friend: keyword=%s message_len=%d → UI 操作完成",
            keyword, len(message) if message else 0,
        )
        return True

    # ------------------------------------------------------------------
    # 朋友圈互动（experimental）
    # ------------------------------------------------------------------
    async def moment_like(
        self,
        moment_index: int = 1,
        timeout_sec: float = 20.0,
    ) -> bool:
        """给朋友圈点赞（experimental）。

        自动进入朋友圈页面后再执行点赞操作，避免在聊天主界面误触。

        Args:
            moment_index: 朋友圈在时间线中的序号，1=最新，2=次新...

        Returns:
            True 表示 UI 操作流程执行完成
        """
        if moment_index < 1:
            raise BridgeError(
                code="INVALID_PARAMS",
                message=f"moment_index 必须 >= 1，收到 {moment_index}",
            )

        deadline = time.monotonic() + timeout_sec

        async def _step(coro: Awaitable, desc: str):
            """执行协程，带 deadline 检查。返回协程结果。"""
            remaining = deadline - time.monotonic()
            if remaining <= 0:
                if asyncio.iscoroutine(coro):
                    coro.close()
                raise BridgeError(
                    code="SEND_FAILED",
                    message=f"朋友圈点赞超时: {desc}",
                )
            try:
                return await asyncio.wait_for(coro, timeout=max(1.0, remaining))
            except asyncio.TimeoutError as exc:
                raise BridgeError(
                    code="SEND_FAILED",
                    message=f"朋友圈点赞步骤超时: {desc}",
                ) from exc

        # 进入朋友圈页面（自动激活窗口 + 导航）
        win_x, win_y, win_w, win_h = await _step(
            self._enter_moments_page(), "进入朋友圈页面"
        )

        # 估算第 N 条朋友圈的"赞/评论"按钮位置
        # 朋友圈每条约 200px 高，按钮在右下角
        like_x = win_x + win_w - 30
        like_y = win_y + 90 + (moment_index - 1) * 200
        await _step(self._click(like_x, like_y), "点击赞/评论按钮")
        await _step(self._sleep(0.5), "等待弹窗")

        # 弹窗中"赞"通常是第一项
        await _step(self._key("Down"), "选中赞")
        await _step(self._sleep(0.2), "等待选中")
        await _step(self._key("Return"), "点击赞")
        await _step(self._sleep(0.5), "等待点赞完成")

        logger.info("moment_like: index=%d → UI 操作完成", moment_index)
        return True

    async def moment_comment(
        self,
        comment: str,
        moment_index: int = 1,
        timeout_sec: float = 20.0,
    ) -> bool:
        """评论朋友圈（experimental）。

        自动进入朋友圈页面后再执行评论操作。

        Args:
            comment: 评论文字内容
            moment_index: 朋友圈序号，1=最新
        """
        if not comment:
            raise BridgeError(
                code="INVALID_PARAMS",
                message="comment 不能为空",
            )
        if moment_index < 1:
            raise BridgeError(
                code="INVALID_PARAMS",
                message=f"moment_index 必须 >= 1，收到 {moment_index}",
            )

        deadline = time.monotonic() + timeout_sec

        async def _step(coro: Awaitable, desc: str):
            """执行协程，带 deadline 检查。返回协程结果。"""
            remaining = deadline - time.monotonic()
            if remaining <= 0:
                if asyncio.iscoroutine(coro):
                    coro.close()
                raise BridgeError(
                    code="SEND_FAILED",
                    message=f"朋友圈评论超时: {desc}",
                )
            try:
                return await asyncio.wait_for(coro, timeout=max(1.0, remaining))
            except asyncio.TimeoutError as exc:
                raise BridgeError(
                    code="SEND_FAILED",
                    message=f"朋友圈评论步骤超时: {desc}",
                ) from exc

        # 进入朋友圈页面
        win_x, win_y, win_w, win_h = await _step(
            self._enter_moments_page(), "进入朋友圈页面"
        )

        # 点击"赞/评论"按钮
        like_x = win_x + win_w - 30
        like_y = win_y + 90 + (moment_index - 1) * 200
        await _step(self._click(like_x, like_y), "点击赞/评论按钮")
        await _step(self._sleep(0.5), "等待弹窗")

        # "评论"通常是第二项
        await _step(self._key("Down"), "选中评论")
        await _step(self._sleep(0.2), "等待选中")
        await _step(self._key("Down"), "选中评论")
        await _step(self._sleep(0.2), "等待选中")
        await _step(self._key("Return"), "点击评论")
        await _step(self._sleep(0.5), "等待输入框")

        # 粘贴评论内容
        await _step(self._paste_via_xclip(comment), "粘贴评论内容")
        await _step(self._sleep(0.3), "等待粘贴完成")

        # 发送
        await _step(self._key("Return"), "发送评论")
        await _step(self._sleep(0.5), "等待发送完成")

        logger.info(
            "moment_comment: index=%d comment_len=%d → UI 操作完成",
            moment_index, len(comment),
        )
        return True

    async def moment_delete(
        self,
        moment_index: int = 1,
        timeout_sec: float = 20.0,
    ) -> bool:
        """删除自己的朋友圈（experimental）。

        自动进入朋友圈页面后再执行删除操作，避免在聊天主界面
        长按消息导致误删聊天消息（不可逆数据丢失）。

        Args:
            moment_index: 朋友圈序号，1=最新
        """
        if moment_index < 1:
            raise BridgeError(
                code="INVALID_PARAMS",
                message=f"moment_index 必须 >= 1，收到 {moment_index}",
            )

        deadline = time.monotonic() + timeout_sec

        async def _step(coro: Awaitable, desc: str):
            """执行协程，带 deadline 检查。返回协程结果。"""
            remaining = deadline - time.monotonic()
            if remaining <= 0:
                if asyncio.iscoroutine(coro):
                    coro.close()
                raise BridgeError(
                    code="SEND_FAILED",
                    message=f"删除朋友圈超时: {desc}",
                )
            try:
                return await asyncio.wait_for(coro, timeout=max(1.0, remaining))
            except asyncio.TimeoutError as exc:
                raise BridgeError(
                    code="SEND_FAILED",
                    message=f"删除朋友圈步骤超时: {desc}",
                ) from exc

        # 进入朋友圈页面（关键安全步骤：避免在聊天界面误删消息）
        win_x, win_y, win_w, win_h = await _step(
            self._enter_moments_page(), "进入朋友圈页面"
        )

        # 长按自己的朋友圈触发删除菜单
        moment_x = win_x + win_w // 2
        moment_y = win_y + 90 + (moment_index - 1) * 200
        await _step(self._long_press(moment_x, moment_y, 1.5), "长按朋友圈")
        await _step(self._sleep(0.6), "等待菜单弹出")

        # 选择"删除"
        await _step(self._key("Down"), "导航到删除")
        await _step(self._sleep(0.2), "等待选中")
        await _step(self._key("Return"), "选择删除")
        await _step(self._sleep(0.5), "等待确认对话框")

        # 确认删除
        await _step(self._key("Return"), "确认删除")
        await _step(self._sleep(0.5), "等待删除完成")

        logger.info("moment_delete: index=%d → UI 操作完成", moment_index)
        return True

    async def publish_moment_with_image(
        self,
        image_path: str,
        content: str = "",
        timeout_sec: float = 25.0,
    ) -> str:
        """发表带图片的朋友圈（experimental）。

        Args:
            image_path: 图片文件容器内绝对路径
            content: 可选文字内容
        """
        if not image_path or not os.path.isfile(image_path):
            raise BridgeError(
                code="INVALID_PARAMS",
                message=f"图片不存在: {image_path}",
            )
        if not os.access(image_path, os.R_OK):
            raise BridgeError(
                code="INVALID_PARAMS",
                message=f"图片不可读: {image_path}",
            )

        deadline = time.monotonic() + timeout_sec

        async def _step(coro: Awaitable[None], desc: str) -> None:
            remaining = deadline - time.monotonic()
            if remaining <= 0:
                if asyncio.iscoroutine(coro):
                    coro.close()
                raise BridgeError(
                    code="SEND_FAILED",
                    message=f"发表图片朋友圈超时: {desc}",
                )
            try:
                await asyncio.wait_for(coro, timeout=max(1.0, remaining))
            except asyncio.TimeoutError as exc:
                raise BridgeError(
                    code="SEND_FAILED",
                    message=f"发表图片朋友圈步骤超时: {desc}",
                ) from exc

        # 复用纯文字朋友圈的前置步骤：激活窗口 → 进入朋友圈 → 点相机
        window_id = await self.find_wechat_window()
        if window_id is None:
            raise BridgeError(
                code="WINDOW_NOT_FOUND",
                message="未找到微信窗口，无法发表朋友圈",
            )
        await _step(self._activate_window_fast(window_id), "激活窗口")
        await _step(self._sleep(0.3), "等待窗口激活")

        returncode, stdout, _ = await self._run(
            ["xdotool", "getwindowgeometry", "--shell", str(window_id)]
        )
        if returncode != 0:
            raise BridgeError(
                code="SEND_FAILED",
                message="无法获取微信窗口几何信息",
            )
        geom = {}
        for line in stdout.decode(errors="ignore").splitlines():
            if "=" in line:
                k, v = line.split("=", 1)
                geom[k.strip()] = v.strip()
        try:
            win_x = int(geom.get("X", 0))
            win_y = int(geom.get("Y", 0))
            win_w = int(geom.get("WIDTH", 800))
            win_h = int(geom.get("HEIGHT", 600))
        except ValueError:
            raise BridgeError(
                code="SEND_FAILED",
                message="解析窗口几何信息失败",
            )

        # 进入朋友圈
        await _step(self._key("Escape"), "关闭弹窗")
        await _step(self._sleep(0.2), "等待 Esc 生效")
        moment_x = win_x + 30
        moment_y = win_y + win_h - 90
        await _step(self._click(moment_x, moment_y), "点击朋友圈入口")
        await _step(self._sleep(1.0), "等待朋友圈页打开")

        # 点相机 → 选"发表图片"（第 2 项，Down 2 次）
        camera_x = win_x + win_w - 30
        camera_y = win_y + 30
        await _step(self._click(camera_x, camera_y), "点击相机图标")
        await _step(self._sleep(0.6), "等待发表菜单弹出")
        await _step(self._key("Down"), "选中发表图片")
        await _step(self._sleep(0.2), "等待选中")
        await _step(self._key("Down"), "选中发表图片")
        await _step(self._sleep(0.2), "等待选中")
        await _step(self._key("Return"), "进入发表图片")
        await _step(self._sleep(0.8), "等待文件选择器")

        # 文件选择器：粘贴图片路径并回车
        await _step(self._paste_via_xclip(image_path), "粘贴图片路径")
        await _step(self._sleep(0.3), "等待粘贴完成")
        await _step(self._key("Return"), "确认选择图片")
        await _step(self._sleep(1.0), "等待图片加载")

        # 可选：输入文字
        if content and content.strip():
            await _step(self._paste_via_xclip(content), "粘贴文字内容")
            await _step(self._sleep(0.3), "等待粘贴完成")

        # 点击"发表"按钮
        publish_x = win_x + win_w - 60
        publish_y = win_y + 60
        await _step(self._click(publish_x, publish_y), "点击发表按钮")
        await _step(self._sleep(1.0), "等待发表完成")

        # 返回主界面
        back_x = win_x + 30
        back_y = win_y + 30
        await _step(self._click(back_x, back_y), "点击返回按钮")
        await _step(self._sleep(0.5), "等待返回主界面")
        await _step(self._key("Escape"), "兜底关闭弹窗")
        await _step(self._sleep(0.2), "等待焦点稳定")

        local_moment_id = f"moment_img_{int(time.time())}_{random.randint(0, 0xFFFFFF):06x}"
        logger.info(
            "publish_moment_with_image: image=%s content_len=%d → 成功 local_moment_id=%s",
            image_path, len(content) if content else 0, local_moment_id,
        )
        return local_moment_id

    # ------------------------------------------------------------------
    # 分享公众号文章到朋友圈
    # ------------------------------------------------------------------
    async def share_article_moment(
        self,
        public_account: str,
        article_index: int = 1,
        comment: Optional[str] = None,
        timeout_sec: float = 40.0,
    ) -> str:
        """分享公众号文章到朋友圈。

        流程：
        1. 通过搜索定位公众号会话（复用 _open_session_by_name）
        2. 在会话中点击第 article_index 篇推送文章卡片
        3. 轮询等待文章页加载完成（最长 10s）
        4. 点击文章页右上角「⋯」菜单
        5. 点击「分享到朋友圈」
        6. 可选：在「说点什么」输入框粘贴 comment
        7. 点击「发表」
        8. 等待分享完成 → 关闭文章页 → 回到聊天主界面

        注意：下方点击坐标均为估算值（基于微信 4.0 Linux 默认布局），
        需在目标分辨率实测后调优：
        - 文章卡片：会话顶部第 N 条消息位置，估算 y=win_y+90+(N-1)*80
        - 「⋯」菜单按钮：文章页右上角，约 x=win_x+win_w-30, y=win_y+30
        - 「分享到朋友圈」菜单项：菜单第 1 项（Down 选中 + 回车）
        - 「说点什么」输入框：分享弹窗顶部，约 y=win_y+90
        - 「发表」按钮：分享弹窗右上角，约 x=win_x+win_w-30, y=win_y+30
        - 返回按钮：文章页左上角，约 x=win_x+30, y=win_y+30

        Args:
            public_account: 公众号名称或 wxid，用于搜索定位
            article_index: 从会话顶部往下的第几篇推送文章卡片，默认 1
            comment: 可选「说点什么」评论文字，None/空串跳过
            timeout_sec: 整体超时（秒），默认 40（含文章页加载等待）

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

        Raises:
            BridgeError(WINDOW_NOT_FOUND): 找不到微信窗口
            BridgeError(SEND_FAILED): 超时或 UI 操作失败
        """
        if not public_account:
            raise BridgeError(
                code="SEND_FAILED",
                message="public_account 不能为空，无法定位公众号会话",
            )
        if article_index < 1:
            raise BridgeError(
                code="SEND_FAILED",
                message=f"article_index 必须 >= 1，收到 {article_index}",
            )

        deadline = time.monotonic() + timeout_sec

        async def _step(coro: Awaitable[None], desc: str) -> None:
            """执行单个 UI 步骤并加超时保护。"""
            remaining = deadline - time.monotonic()
            if remaining <= 0:
                raise BridgeError(
                    code="SEND_FAILED",
                    message=f"分享文章超时: {desc}",
                )
            try:
                await asyncio.wait_for(coro, timeout=max(1.0, remaining))
            except asyncio.TimeoutError as exc:
                raise BridgeError(
                    code="SEND_FAILED",
                    message=f"分享文章步骤超时: {desc}",
                ) from exc

        # 1. 定位公众号会话（复用 _open_session_by_name）
        await _step(
            self._open_session_by_name(public_account),
            "定位公众号会话",
        )

        # 2. 获取窗口几何（用于估算文章卡片坐标）
        window_id = await self.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 = {}
        for line in stdout.decode(errors="ignore").splitlines():
            if "=" in line:
                k, v = line.split("=", 1)
                geom[k.strip()] = v.strip()
        try:
            win_x = int(geom.get("X", 0))
            win_y = int(geom.get("Y", 0))
            win_w = int(geom.get("WIDTH", 800))
        except ValueError:
            raise BridgeError(
                code="SEND_FAILED",
                message="解析窗口几何信息失败",
            )

        # 3. 点击第 article_index 篇推送文章卡片
        #    估算：会话顶部偏下，每条消息约 80px 高，卡片居中点击
        card_x = win_x + win_w // 2
        card_y = win_y + 90 + (article_index - 1) * 80
        await _step(self._click(card_x, card_y), f"点击第 {article_index} 篇文章卡片")
        # 4. 等待文章页加载（最长 10s，分步等待避免单次长睡）
        for _ in range(20):
            remaining = deadline - time.monotonic()
            if remaining <= 0:
                raise BridgeError(
                    code="SEND_FAILED",
                    message="分享文章超时: 等待文章页加载",
                )
            await _step(self._sleep(0.5), "等待文章页加载")
        # 加载完成后窗口可能已切换到文章页，重新获取几何
        returncode, stdout, _ = await self._run(
            ["xdotool", "getwindowgeometry", "--shell", str(window_id)]
        )
        if returncode == 0:
            geom2 = {}
            for line in stdout.decode(errors="ignore").splitlines():
                if "=" in line:
                    k, v = line.split("=", 1)
                    geom2[k.strip()] = v.strip()
            try:
                win_x = int(geom2.get("X", win_x))
                win_y = int(geom2.get("Y", win_y))
                win_w = int(geom2.get("WIDTH", win_w))
            except ValueError:
                pass  # 解析失败用旧值

        # 5. 点击文章页右上角「⋯」菜单按钮
        menu_x = win_x + win_w - 30
        menu_y = win_y + 30
        await _step(self._click(menu_x, menu_y), "点击更多菜单")
        await _step(self._sleep(0.6), "等待菜单弹出")

        # 6. 选择「分享到朋友圈」（菜单第 1 项，Down 选中 + 回车）
        await _step(self._key("Down"), "选中分享到朋友圈")
        await _step(self._sleep(0.2), "等待选中")
        await _step(self._key("Return"), "进入分享到朋友圈")
        await _step(self._sleep(1.0), "等待分享弹窗打开")

        # 7. 可选：粘贴「说点什么」评论文字
        if comment and comment.strip():
            # 点击「说点什么」输入框定位焦点（估算：分享弹窗顶部偏下）
            comment_x = win_x + win_w // 2
            comment_y = win_y + 90
            await _step(self._click(comment_x, comment_y), "点击说点什么输入框")
            await _step(self._sleep(0.3), "等待输入框聚焦")
            await _step(self._paste_via_xclip(comment), "粘贴评论文字")
            await _step(self._sleep(0.3), "等待粘贴完成")

        # 8. 点击「发表」按钮（估算：分享弹窗右上角）
        publish_x = win_x + win_w - 30
        publish_y = win_y + 30
        await _step(self._click(publish_x, publish_y), "点击发表按钮")
        await _step(self._sleep(1.5), "等待分享完成")

        # 9. 关闭文章页回到聊天主界面
        #    点击左上角返回按钮 + 兜底 Esc
        back_x = win_x + 30
        back_y = win_y + 30
        await _step(self._click(back_x, back_y), "点击返回按钮")
        await _step(self._sleep(0.5), "等待返回")
        await _step(self._key("Escape"), "兜底关闭弹窗")
        await _step(self._sleep(0.2), "等待焦点稳定")

        # 10. 生成 local_moment_id
        local_moment_id = f"share_{int(time.time())}_{random.randint(0, 0xFFFFFF):06x}"
        return local_moment_id
