"""XdotoolDriver 门面：转发到 ui/drivers/ 子包的 5 个子 driver。

原 2375 行单类已按业务边界拆分为 5 个子 driver：
- WindowDriver  : 窗口查找 / 激活 / 几何 / 截图 / 启动清场
- LoginDriver   : 登录态检测 / 等待登录 / 二维码定位
- MessageDriver : 发送文本 / 文件 / 撤回 / 转发
- MomentDriver  : 朋友圈图文 / 点赞 / 评论 / 删除
- ContactDriver : 通过好友申请 / 修改备注 / 添加好友 / 搜索用户

本文件仅保留门面壳，所有已拆分方法透传到对应子 driver，保持原公开
方法签名不变（路由层零改动）。

未拆分到子 driver 的 5 个方法（restart_wechat / start_wechat /
check_wechat_pid / logout / share_article_moment）暂时内联保留，
通过委托辅助方法（_run / _sleep / _key 等）调用子 driver 的底层能力。
"""

from __future__ import annotations

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

from woc_bridge.models import BridgeError, LoginState
from woc_bridge.ui.drivers import (
    BaseDriver,  # noqa: F401  保留导出供外部类型检查
    WindowDriver,
    LoginDriver,
    MessageDriver,
    MomentDriver,
    ContactDriver,
)

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

# 单步骤超时下限 = L1(5s) + 2s，避免 L2 先于 L1 触发导致子进程泄漏。
# 仅供内联方法 share_article_moment 的本地 _step 闭包使用（与 base.py 同名常量对齐）。
_STEP_MIN_TIMEOUT_SEC = 7.0

# 微信可执行文件固定路径（与 docker/app-defs.sh 中 wechat 类型一致）。
# 仅 start_wechat 显式启动时使用。
_WECHAT_BIN = "/config/wechat/opt/wechat/wechat"


class XdotoolDriver:
    """XdotoolDriver 门面（原 2375 行单类已拆分到 ui/drivers/）。

    构造签名保持 ``XdotoolDriver(display: str = ":1")``，路由层零改动。
    内部实例化 5 个子 driver，其中 4 个注入 window_driver 引用。

    所有已拆分方法透传到子 driver；未拆分的 5 个方法（进程管理 / logout /
    分享文章）暂时内联保留，通过委托辅助方法调用子 driver 底层能力。
    """

    def __init__(self, display: str = ":1") -> None:
        """保存 DISPLAY 并实例化 5 个子 driver。

        Args:
            display: X server display 地址，如 ":1"
        """
        self.display = display
        # 先构造 WindowDriver（无兄弟依赖）
        self._window = WindowDriver(display=display)
        # 其他子 driver 注入 window_driver 引用（find_wechat_window 归属 Window）
        self._login = LoginDriver(display=display, window_driver=self._window)
        self._message = MessageDriver(display=display, window_driver=self._window)
        self._moment = MomentDriver(display=display, window_driver=self._window)
        self._contact = ContactDriver(display=display, window_driver=self._window)

        # P0 修复：5 个子 driver 必须共享同一把 _ui_lock。
        # BaseDriver 在 backend=None 时各自创建独立 asyncio.Lock()，导致跨 driver
        # 并发保护失效（如 logout 不走 send_queue 与 send_text 走 send_queue 可能
        # 并发执行 xdotool 命令，造成点击焦点错乱）。统一复用 WindowDriver 的锁。
        shared_ui_lock = self._window._ui_lock
        self._login._ui_lock = shared_ui_lock
        self._message._ui_lock = shared_ui_lock
        self._moment._ui_lock = shared_ui_lock
        self._contact._ui_lock = shared_ui_lock

        # Actions 由 app.py 在初始化阶段回填，供 MessageDriver 复用 profile 比例坐标。
        self._actions: Optional[Any] = None

    def set_actions(self, actions: Any) -> None:
        """注入 L3 Actions，使 MessageDriver 可走 LocatorRegistry + profile。"""
        self._actions = actions
        self._message._actions = actions

    # ==================================================================
    # 委托辅助方法（供内联的 5 个未拆分方法调用子 driver 底层能力）
    # ==================================================================
    def _env(self) -> dict:
        """返回带 DISPLAY 的环境副本（委托 WindowDriver._env，来自 BaseDriver）。"""
        return self._window._env()

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

        委托 WindowDriver._run，保持原 XdotoolDriver._run 的 BridgeError 语义。
        """
        return await self._window._run(args, input_bytes=input_bytes)

    async def _key(self, key: str, repeat: int = 1) -> None:
        """执行 xdotool key [--repeat N] <key>（委托 WindowDriver._key）。"""
        await self._window._key(key, repeat=repeat)

    async def _sleep(self, seconds: float) -> None:
        """asyncio.sleep 封装（委托 MessageDriver._sleep）。"""
        await self._message._sleep(seconds)

    async def _click(self, x: int, y: int) -> None:
        """移动鼠标到 (x, y) 并左键单击（委托 MomentDriver._click）。"""
        await self._moment._click(x, y)

    async def _paste_via_xclip(self, text: str) -> None:
        """xdotool type 直接输入文本（委托 MessageDriver._paste_via_xclip）。"""
        await self._message._paste_via_xclip(text)

    async def _open_session_by_name(
        self,
        name: str,
        timeout_sec: float = 20.0,
    ) -> None:
        """通过搜索框定位会话（委托 MessageDriver._open_session_by_name）。"""
        await self._message._open_session_by_name(name, timeout_sec=timeout_sec)

    async def _activate_window_fast(self, window_id: Optional[int] = None) -> None:
        """激活微信窗口并校验焦点（委托 MessageDriver._activate_window_fast）。

        P0 修复：routes/login.py 调用 xdotool._activate_window_fast()，
        但门面未转发该方法，导致扫码登录入口 AttributeError 崩溃。
        """
        await self._message._activate_window_fast(window_id)

    # ==================================================================
    # WindowDriver 转发
    # ==================================================================
    async def find_wechat_window(self) -> Optional[int]:
        """用 xdotool search --name "微信" 查找微信窗口 ID。"""
        return await self._window.find_wechat_window()

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

    async def get_window_geometry(self, window_id: Optional[int] = None):
        """获取微信窗口几何信息，返回 WindowGeom（新增公开方法）。"""
        return await self._window.get_window_geometry(window_id)

    async def screenshot(self) -> bytes:
        """截取整个 X 屏幕为 PNG bytes（新增方法，对齐 backends 模式）。"""
        return await self._window.screenshot()

    async def _full_cleanup_on_startup(self) -> bool:
        """启动时全量清场（3 次重试），转发到 WindowDriver。

        app.py 启动流程调用，保持私有方法签名不变。
        """
        return await self._window._full_cleanup_on_startup()

    # ==================================================================
    # LoginDriver 转发
    # ==================================================================
    async def is_wechat_running(self) -> bool:
        """判断微信进程是否运行。"""
        return await self._login.is_wechat_running()

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

    async def wait_for_login(self, timeout_sec: float = 120.0) -> bool:
        """轮询等待微信登录完成（新增方法）。"""
        return await self._login.wait_for_login(timeout_sec=timeout_sec)

    async def get_qr_rect(self) -> Optional[tuple[int, int, int, int]]:
        """定位登录二维码矩形区域（新增占位，未实现返回 None）。"""
        return await self._login.get_qr_rect()

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

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

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

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

    # ==================================================================
    # MomentDriver 转发
    # ==================================================================
    async def publish_moment(
        self,
        content: str,
        timeout_sec: float = 20.0,
    ) -> str:
        """发表纯文字朋友圈。"""
        return await self._moment.publish_moment(content, timeout_sec=timeout_sec)

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

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

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

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

    # ==================================================================
    # ContactDriver 转发
    # ==================================================================
    async def accept_friend_request(
        self,
        stranger_wxid: str = "",
        nickname: str = "",
        timeout_sec: float = 25.0,
    ) -> bool:
        """通过好友申请（experimental）。"""
        return await self._contact.accept_friend_request(
            stranger_wxid=stranger_wxid,
            nickname=nickname,
            timeout_sec=timeout_sec,
        )

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

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

    async def search_user(
        self,
        keyword: str,
        timeout_sec: float = 15.0,
    ) -> Optional[dict]:
        """搜索用户并返回用户信息（新增占位，未实现返回 None）。"""
        return await self._contact.search_user(keyword, timeout_sec=timeout_sec)

    # ==================================================================
    # 未拆分到子 driver 的方法（暂时内联保留，路由层有调用）
    # ==================================================================
    # 以下 5 个方法在 Task 2 拆分时未归入任何子 driver，但路由层
    # (routes/login.py / routes/diagnostic.py / routes/moments.py) 仍在调用。
    # 为保证"路由层零改动"，此处保留原实现，通过委托辅助方法调用子 driver
    # 底层能力。后续可由其他 Task 补充到对应子 driver。

    # ------------------------------------------------------------------
    # 进程管理（restart_wechat / start_wechat / check_wechat_pid）
    # ------------------------------------------------------------------
    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 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(
                _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", _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

    # ------------------------------------------------------------------
    # 退出登录（routes/login.py 调用）
    # ------------------------------------------------------------------
    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)

    # ------------------------------------------------------------------
    # 分享公众号文章到朋友圈（routes/moments.py 调用）
    # ------------------------------------------------------------------
    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(_STEP_MIN_TIMEOUT_SEC, 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
