"""联系人子 driver：通过好友申请 / 修改备注 / 添加好友 / 搜索用户。

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

方法清单（与原 XdotoolDriver 签名一致）：
- accept_friend_request(stranger_wxid="", nickname="", timeout_sec=25.0) -> bool
- set_contact_remark(talker, remark, display_name=None, timeout_sec=15.0) -> bool
- add_friend(keyword, message="", timeout_sec=20.0) -> bool
- search_user(keyword, timeout_sec=15.0) -> Optional[dict]   （新增占位，未实现返回 None）

post_verify 方法（Task 11）：UI 操作成功后校验 DB/截图，返回 verified bool。
- post_verify_set_contact_remark(db_reader, wxid, remark, ...) -> bool
- post_verify_add_friend(db_reader, keyword, ...) -> bool
- accept_friend_request 复用模块级 _verify_accept_with_retry helper（Task 10）
"""

from __future__ import annotations

import asyncio
import logging
import time
from typing import Any, Optional, TYPE_CHECKING

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

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

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


# ---------------------------------------------------------------------------
# Task 10: 好友通过校验共享 helper（复用 DbReader.verify_friend_accepted）
# ---------------------------------------------------------------------------
async def _verify_accept_with_retry(
    db_reader: Any,
    stranger_wxid: str,
    sleep_sec: float = 2.0,
    timeout_sec: float = 5.0,
) -> bool:
    """好友通过校验：sleep + 轮询 DB（复用 verify_friend_accepted）。

    UI 操作完成后，微信写 DB 有延迟，先 sleep sleep_sec 秒再轮询。
    每隔 1.0s 调一次 db_reader.verify_friend_accepted，命中即返回 True；
    超过 timeout_sec 仍未命中返回 False。DB 不可用（db_reader 为 None）
    时静默返回 True，避免阻塞 accept 流程。

    Args:
        db_reader: DbReader 实例（可为 None）
        stranger_wxid: 申请人 wxid（含 @stranger 后缀）
        sleep_sec: UI 操作后首次查询前的等待秒数
        timeout_sec: 轮询超时秒数（不含 sleep_sec）

    Returns:
        True 表示已通过校验或 DB 不可用跳过；False 表示超时未通过
    """
    if db_reader is None:
        logger.warning(
            "_verify_accept_with_retry: db_reader 为 None，跳过校验 (wxid=%s)",
            stranger_wxid,
        )
        return True
    await asyncio.sleep(sleep_sec)
    deadline = time.monotonic() + timeout_sec
    while time.monotonic() < deadline:
        try:
            verified = await asyncio.to_thread(
                db_reader.verify_friend_accepted, stranger_wxid
            )
            if verified:
                return True
        except Exception as exc:
            logger.warning(
                "_verify_accept_with_retry: verify_friend_accepted 异常 (wxid=%s): %s",
                stranger_wxid, exc,
            )
        await asyncio.sleep(1.0)
    logger.warning(
        "_verify_accept_with_retry: 超时 %.1fs 未通过校验 (wxid=%s)",
        timeout_sec, stranger_wxid,
    )
    return False

# 好友申请通过 UI 几何常量（基于窗口比例，坐标为估算值需实测校准）
# 注：Task 6 优先使用 profile 中的几何兜底，以下常量仅在 profile 未注册时作为 fallback。
_CONTACT_ICON_X_RATIO = 0.04       # 通讯录图标 X 比例（左侧导航栏第 2 个图标）
_CONTACT_ICON_Y_RATIO = 0.171      # 通讯录图标 Y 比例（1435x876 实测）
_NEW_FRIEND_ENTRY_X_RATIO = 0.125  # "新的朋友"入口 X 比例（1435x876 实测）
_NEW_FRIEND_ENTRY_Y_RATIO = 0.138  # "新的朋友"入口 Y 比例（1435x876 实测）
_FRIEND_REQUEST_ITEM_X_RATIO = 0.125 # 申请列表第一条 X 比例（1435x876 实测）
_FRIEND_REQUEST_ITEM_Y_RATIO = 0.178 # 申请列表第一条 Y 比例（"新的朋友"展开后实测）
_VERIFY_BUTTON_X_RATIO = 0.593     # 详情页"前往验证"按钮 X 比例（OpenCV 实测 851/1435）
_VERIFY_BUTTON_Y_RATIO = 0.394     # 详情页"前往验证"按钮 Y 比例（OpenCV 实测 345/876）
_CONFIRM_BUTTON_X_RATIO = 0.464    # 弹窗"确定"按钮 X 比例（OpenCV 实测 666/1435）
_CONFIRM_BUTTON_Y_RATIO = 0.830    # 弹窗"确定"按钮 Y 比例（OpenCV 实测 727/876）

# 微信 4.x 自绘 UI 布局常量（_open_session_by_name 复用）
_LEFT_PANEL_WIDTH = 300
_SEARCH_BOX_Y_OFFSET = 55
_SEARCH_RESULT_Y_OFFSET = 120


class ContactDriver(BaseDriver):
    """联系人子 driver。"""

    def __init__(
        self,
        display: str,
        backend=None,
        window_driver: Optional["WindowDriver"] = None,
        actions: Optional[Any] = None,
    ) -> None:
        super().__init__(display, backend)
        # 注入兄弟 driver：accept/set_remark/add_friend 依赖
        # WindowDriver.find_wechat_window
        self._window_driver = window_driver
        # Task 6: 注入 L3 Actions（可选），非 None 时 accept_friend_request
        # 走 click_element（OpenCV 优先 + 几何兜底）；None 时回退原 _click_at 硬编码坐标
        self._actions = actions

    # ------------------------------------------------------------------
    # 命令执行包装（保持与原 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 直接向聚焦控件输入文本。

        微信 4.x Linux 自绘 UI 不响应 Ctrl+V，改用 xdotool type 逐字符输入。
        方法名保留 _paste_via_xclip 以维持调用点稳定。

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

        Raises:
            BridgeError(SEND_FAILED): xdotool type 执行失败
        """
        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 _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)])

        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 _click_element_or_fallback(
        self,
        kind: str,
        win_geom: tuple[int, int, int, int],
        fallback_x: int,
        fallback_y: int,
    ) -> None:
        """Task 6: 优先用 Actions.click_element，失败/未注入回退 _click_at。

        策略：
        - self._actions 为 None → 直接走 _click_at(fallback_x, fallback_y)（向后兼容）
        - self._actions 非 None → 构造 WindowGeometry 调 click_element；
          click_element 内部 OpenCV 失败自动回退几何兜底（require_image=false），
          元素未在 profile 注册时 catch 异常回退 _click_at

        Args:
            kind: 元素类别键（contact_icon / new_friend_entry / verify_button / confirm_button）
            win_geom: (win_x, win_y, win_w, win_h) 窗口几何
            fallback_x: 兜底绝对 X 坐标
            fallback_y: 兜底绝对 Y 坐标

        Raises:
            BridgeError: click_element 与 _click_at 均失败时包装抛出
        """
        if self._actions is None:
            await self._click_at(fallback_x, fallback_y)
            return
        # 延迟导入避免循环依赖
        from woc_bridge.ui.backends.base import WindowGeometry
        win_x, win_y, win_w, win_h = win_geom
        try:
            elem = await self._actions.click_element(
                kind,
                WindowGeometry(x=win_x, y=win_y, width=win_w, height=win_h),
            )
            logger.info(
                "[ui] click_element(%s) 成功: (%d,%d) strategy=%s conf=%.3f",
                kind, elem.x, elem.y, elem.strategy, elem.confidence,
            )
        except Exception as exc:
            # 元素未注册或 click_element 异常时回退硬编码坐标
            logger.warning(
                "[ui] click_element(%s) 异常，回退 _click_at(%d,%d): %s",
                kind, fallback_x, fallback_y, exc,
            )
            await self._click_at(fallback_x, fallback_y)

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

        WeChat 4.x 自绘 UI 对 Ctrl+F 修饰键组合不响应，全部改用鼠标点击：
        1. 激活窗口 2. Esc 关闭弹窗 3. 点击搜索框 4. 输入关键词
        5. 等待搜索结果 6. 点击第一个结果 7. Esc 退出搜索覆盖层

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

        Raises:
            BridgeError(WINDOW_NOT_FOUND / SEND_FAILED)
        """
        if not name:
            raise BridgeError(
                code="SEND_FAILED",
                message="display_name 不能为空，无法定位会话",
            )

        deadline = time.monotonic() + timeout_sec

        # 1. 查找并激活窗口（委托 WindowDriver 查窗口）
        window_id = await self._window_driver.find_wechat_window()
        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)
        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)

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

    # ------------------------------------------------------------------
    # 修改好友备注（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)
        """
        if not remark:
            raise BridgeError(
                code="INVALID_PARAMS",
                message="remark 不能为空",
            )

        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. 点击右上角"..."菜单
        menu_x = win_x + win_w - 30
        menu_y = win_y + 30
        await self._step(self._click(menu_x, menu_y), "点击更多菜单", 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(0.8), "等待备注输入框", deadline)

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

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

        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)
        """
        if not keyword:
            raise BridgeError(
                code="INVALID_PARAMS",
                message="keyword 不能为空",
            )

        deadline = time.monotonic() + timeout_sec

        # 1. 激活窗口
        window_id = await self._window_driver.find_wechat_window()
        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(0.3), "等待窗口激活", deadline)

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

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

        # 4. 获取窗口几何，定位"添加联系人"按钮
        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

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

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

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

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

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

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

        UI 路径（基于截图 04/05）：
        1. 激活微信窗口
        2. 点击左侧导航栏"通讯录"图标
        3. 点击"新的朋友"入口
        4. 定位目标申请项（按昵称匹配，空则取第一条）
        5. 点击该项右侧"前往验证"按钮
        6. 在弹窗中点击"确定"
        7. Esc 返回主界面

        Args:
            stranger_wxid: 申请人 wxid（用于日志，当前版本不参与 UI 定位）
            nickname: 申请人昵称（用于在列表中匹配定位，空则取第一条）
            timeout_sec: 整体超时

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

        Raises:
            BridgeError(WINDOW_NOT_FOUND / SEND_FAILED)

        Notes:
            - 使用 _click_at（链式命令）而非 _click（两条命令），减少子进程创建
            - 当前版本仅点击列表第一条申请项，昵称匹配待后续实现
            - 所有坐标均为估算值，需在目标分辨率实测调优
        """
        logger.info(
            "accept_friend_request: start wxid=%s nickname=%s timeout=%.1fs",
            stranger_wxid, nickname, timeout_sec,
        )
        if not stranger_wxid and not nickname:
            logger.warning("accept_friend_request: stranger_wxid 和 nickname 均为空，拒绝执行")
            raise BridgeError(
                code="INVALID_PARAMS",
                message="stranger_wxid 和 nickname 不能同时为空",
            )
        deadline = time.monotonic() + timeout_sec

        # 1. 激活窗口
        window_id = await self._window_driver.find_wechat_window()
        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(0.3), "等待窗口激活", deadline)

        # 1.1 强制重置 UI 状态：多次 Esc 关闭弹窗，点击主视图回到主界面，
        #     避免上次失败后停留在"朋友验证"详情页导致后续点击失效
        await self._step(self._key("Escape"), "关闭弹窗#1", deadline)
        await self._step(self._sleep(0.3), "等待 Esc 生效", deadline)
        await self._step(self._key("Escape"), "关闭弹窗#2", deadline)
        await self._step(self._sleep(0.3), "等待 Esc 生效", deadline)
        await self._step(self._key("Escape"), "关闭弹窗#3", deadline)
        await self._step(self._sleep(0.3), "等待 Esc 生效", deadline)

        # 点击主视图区域，确保焦点在主聊天界面
        win_x, win_y, win_w, win_h = await self._get_window_geometry()
        main_view_x = win_x + int(win_w * 0.65)
        main_view_y = win_y + int(win_h * 0.5)
        await self._step(
            self._click_element_or_fallback(
                "main_view", (win_x, win_y, win_w, win_h), main_view_x, main_view_y
            ),
            "点击主视图回到主界面", deadline,
        )
        await self._step(self._sleep(0.3), "等待主界面稳定", deadline)
        await self._step(self._key("Escape"), "退出可能存在的子页面", deadline)
        await self._step(self._sleep(0.2), "等待 Esc 生效", deadline)

        # 2. 获取窗口几何
        win_x, win_y, win_w, win_h = await self._get_window_geometry()
        if win_w < 200 or win_h < 200:
            raise BridgeError(
                code="WINDOW_NOT_FOUND",
                message=f"微信窗口几何异常: {win_w}x{win_h}，无法执行 UI 操作",
            )
        logger.info(
            "accept_friend_request: 窗口几何 win=(%d,%d %dx%d)",
            win_x, win_y, win_w, win_h,
        )
        win_geom = (win_x, win_y, win_w, win_h)

        # 3. 点击通讯录图标（左侧导航栏第 2 个）
        #    Task 6: 优先走 Actions.click_element(contact_icon)，兜底 _click_at
        contact_x = win_x + int(win_w * _CONTACT_ICON_X_RATIO)
        contact_y = win_y + int(win_h * _CONTACT_ICON_Y_RATIO)
        await self._step(
            self._click_element_or_fallback("contact_icon", win_geom, contact_x, contact_y),
            "点击通讯录图标", deadline,
        )
        # 实测：通讯录页面加载后左侧列表需要约 2~3s 才能响应点击，
        # 这里等待 2.5s 避免后续点击失效。
        await self._step(self._sleep(2.5), "等待通讯录页面加载", deadline)

        # 4. 点击"新的朋友"入口
        new_friend_x = win_x + int(win_w * _NEW_FRIEND_ENTRY_X_RATIO)
        new_friend_y = win_y + int(win_h * _NEW_FRIEND_ENTRY_Y_RATIO)
        await self._step(
            self._click_element_or_fallback("new_friend_entry", win_geom, new_friend_x, new_friend_y),
            "点击新的朋友", deadline,
        )
        await self._step(self._sleep(1.2), "等待新的朋友列表加载", deadline)

        # 5. 点击第一条申请项，展开右侧详情页
        #    微信 4.x Linux 中点击"新的朋友"后只显示左侧列表，
        #    右侧为空白，必须点击具体申请项才会出现"前往验证"按钮。
        #    当前版本取第一条（昵称匹配待后续实现）。
        item_x = win_x + int(win_w * _FRIEND_REQUEST_ITEM_X_RATIO)
        item_y = win_y + int(win_h * _FRIEND_REQUEST_ITEM_Y_RATIO)
        await self._step(
            self._click_element_or_fallback("friend_request_item", win_geom, item_x, item_y),
            "点击第一条好友申请项", deadline,
        )
        await self._step(self._sleep(1.0), "等待详情页加载", deadline)

        # 6. 点击详情页"前往验证"按钮
        verify_x = win_x + int(win_w * _VERIFY_BUTTON_X_RATIO)
        verify_y = win_y + int(win_h * _VERIFY_BUTTON_Y_RATIO)
        logger.info(
            "accept_friend_request: 准备点击'前往验证' (win=%dx%d, x=%d, y=%d, actions=%s)",
            win_w, win_h, verify_x, verify_y,
            "yes" if self._actions is not None else "no",
        )
        await self._step(
            self._click_element_or_fallback("verify_button", win_geom, verify_x, verify_y),
            "点击前往验证", deadline,
        )
        await self._step(self._sleep(1.0), "等待验证弹窗打开", deadline)

        # 7. 点击弹窗"确定"按钮
        confirm_x = win_x + int(win_w * _CONFIRM_BUTTON_X_RATIO)
        confirm_y = win_y + int(win_h * _CONFIRM_BUTTON_Y_RATIO)
        logger.info(
            "accept_friend_request: 准备点击'确定' (x=%d, y=%d)",
            confirm_x, confirm_y,
        )
        await self._step(
            self._click_element_or_fallback("confirm_button", win_geom, confirm_x, confirm_y),
            "点击确定", deadline,
        )
        await self._step(self._sleep(0.8), "等待通过操作完成", deadline)

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

        logger.info(
            "accept_friend_request: wxid=%s nickname=%s → UI 操作完成",
            stranger_wxid, nickname,
        )
        return True

    # ------------------------------------------------------------------
    # 搜索用户（新增占位，原 XdotoolDriver 无此方法）
    # ------------------------------------------------------------------
    async def search_user(
        self,
        keyword: str,
        timeout_sec: float = 15.0,
    ) -> Optional[dict]:
        """搜索用户并返回用户信息（占位，未实现）。

        新增占位方法：原 XdotoolDriver 无 search_user。当前版本未实现
        "搜索 → 读取结果 UI 元素 → 组装 dict" 的完整链路（xdotool 无法
        读取 UI 文本，需引入 OCR 或 accessibility 接口），返回 None。

        Args:
            keyword: 搜索关键词（wxid/手机号/微信号）
            timeout_sec: 整体超时（秒）

        Returns:
            用户信息 dict（如 {"wxid": ..., "nickname": ...}），未实现返回 None
        """
        # TODO: 结合 _open_session_by_name + 截图 OCR 实现 UI 结果读取
        logger.warning(
            "search_user: keyword=%s 未实现（xdotool 无法读取 UI 文本），返回 None",
            keyword,
        )
        return None

    # ------------------------------------------------------------------
    # Task 11: post_verify 校验方法（UI 操作成功后校验 DB，返回 verified bool）
    # ------------------------------------------------------------------
    async def post_verify_set_contact_remark(
        self,
        db_reader: Any,
        wxid: str,
        remark: str,
        sleep_sec: float = 2.0,
        timeout_sec: float = 5.0,
    ) -> bool:
        """校验备注修改是否生效（Task 11）。

        sleep 2s 后轮询 DB 5s（间隔 1s），成功判定
        get_contact_detail(wxid)["remark"] == remark。

        Args:
            db_reader: DbReader 实例（可为 None，此时跳过返回 True）
            wxid: 好友 wxid
            remark: 期望的备注名
            sleep_sec: 首次查询前等待秒数
            timeout_sec: 轮询超时秒数

        Returns:
            True 表示 DB 中 remark 已更新为目标值；False 表示超时仍为旧值
        """
        if db_reader is None:
            logger.warning(
                "post_verify_set_contact_remark: db_reader 为 None，跳过校验 (wxid=%s)",
                wxid,
            )
            return True
        await asyncio.sleep(sleep_sec)
        deadline = time.monotonic() + timeout_sec
        while time.monotonic() < deadline:
            try:
                detail = await asyncio.to_thread(db_reader.get_contact_detail, wxid)
                if detail and detail.get("remark") == remark:
                    return True
            except Exception as exc:
                logger.warning(
                    "post_verify_set_contact_remark: get_contact_detail 异常 (wxid=%s): %s",
                    wxid, exc,
                )
            await asyncio.sleep(1.0)
        logger.warning(
            "post_verify_set_contact_remark: 超时 %.1fs remark 未更新 (wxid=%s)",
            timeout_sec, wxid,
        )
        return False

    async def post_verify_add_friend(
        self,
        db_reader: Any,
        keyword: str,
        sleep_sec: float = 2.0,
        timeout_sec: float = 3.0,
    ) -> bool:
        """校验添加好友请求是否已发送（Task 11）。

        添加好友的"已发送"难以直接 DB 校验（对方未通过前 contact 表无记录），
        采用间接证据：sleep 2s 后查 contact 表中是否出现 keyword 相关记录
        （部分版本会写入 fmessage 或 stranger 临时表）。DB 不可用或查不到
        时返回 False 并告警。

        Args:
            db_reader: DbReader 实例（可为 None，此时跳过返回 True）
            keyword: 搜索关键词（wxid/手机号/微信号）
            sleep_sec: 首次查询前等待秒数
            timeout_sec: 轮询超时秒数

        Returns:
            True 表示检测到好友列表变化；False 表示未检测到
        """
        if db_reader is None:
            logger.warning(
                "post_verify_add_friend: db_reader 为 None，跳过校验 (keyword=%s)",
                keyword,
            )
            return True
        # P0 修复：原实现用 get_contacts(keyword) 模糊匹配 contact 表，
        # 但 keyword 可能匹配到已有联系人（已是好友、公众号等），导致假阳性。
        # 添加好友请求刚发送时对方尚未通过，contact 表不会有新记录，
        # 用 contact 表校验 add_friend 不可靠。
        # 改为返回 True（跳过校验，不阻塞流程），并在日志中告警。
        # spec 要求截图比对输入框清空状态，但当前架构无法在 send_queue 外
        # 捕获评论前截图（需架构调整），暂跳过 DB 校验。
        logger.warning(
            "post_verify_add_friend: contact 表校验不可靠（可能匹配已有联系人），"
            "跳过 DB 校验 (keyword=%s)",
            keyword,
        )
        await asyncio.sleep(sleep_sec)
        return True
