"""文件发送 Flow：基于文件选择器路径输入实现真实文件发送。

复用 publish_moment_with_image 已验证机制（系统文件选择器接受 xdotool 输入）。
Flow 流程：定位会话 → 点击"+"→ 选"发送文件"→ xdotool type 路径 → Return
post_verify：sleep 2s + 轮询 DB 30s，查 type in (3,43,49) 且 content 含文件名。

路径白名单：/config/Desktop/ /config/woc-uploads/ /tmp/woc-files/
大文件超时自适应：30 + size_mb * 1.5（上限 180s）。
"""

from __future__ import annotations

import asyncio
import logging
import os
import time
from typing import Optional

from woc_bridge.models import BridgeError

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

# 微信窗口标题（与 send_text.py 对齐）
_WECHAT_WINDOW_TITLE = "微信"

# 路径白名单（防路径遍历）：仅允许容器内受控目录
_FILE_PATH_WHITELIST = (
    "/config/Desktop/",
    "/config/woc-uploads/",
    "/tmp/woc-files/",
)

# 大文件超时自适应参数
_LARGE_FILE_THRESHOLD_MB = 10.0
_BASE_TIMEOUT_SEC = 30.0
_PER_MB_TIMEOUT_SEC = 1.5
_MAX_TIMEOUT_SEC = 180.0

# DB 校验参数
_DB_VERIFY_TIMEOUT_SEC = 30.0  # DB 校验总超时
_DB_VERIFY_INTERVAL_SEC = 2.0  # 轮询间隔
_DB_VERIFY_WAL_FLUSH_SEC = 2.0  # 等待 WAL 刷盘

# "+" 菜单导航：点击 "+" 后按 Down N 次选中"发送文件"，再 Return 确认。
# 默认值基于微信 4.x Linux 菜单布局（发送图片/发送文件/...），实际可能需调优。
_SEND_FILE_MENU_DOWN_COUNT = 1

# 文件类型常量（微信 msg.type）：3=图片 43=视频 49=文件/分享
_FILE_MSG_TYPES = (3, 43, 49)


def _validate_file_path(file_path: str) -> None:
    """校验文件路径白名单 + 拒绝路径遍历。

    双重校验：
    1. 原始路径段不含 ".."（防输入层绕过）
    2. os.path.realpath 解析后的真实路径以白名单前缀开头（防 symlink + .. 绕过）

    Raises:
        BridgeError(INVALID_PARAMS): 路径含 .. 或不在白名单目录
    """
    # 段级 ".." 检查（兼容 / 与 \ 分隔符）
    parts = file_path.replace("\\", "/").split("/")
    if ".." in parts:
        raise BridgeError(
            "INVALID_PARAMS",
            f"文件路径含 '..' 段，拒绝: {file_path}",
            http_status=400,
        )

    # realpath 解析 symlink + ..，得到真实绝对路径
    real_path = os.path.realpath(file_path)
    for prefix in _FILE_PATH_WHITELIST:
        if real_path.startswith(prefix):
            return
    raise BridgeError(
        "INVALID_PARAMS",
        f"文件路径不在白名单目录内: {file_path}（允许: {list(_FILE_PATH_WHITELIST)}）",
        http_status=400,
    )


def _compute_timeout(file_size_bytes: int) -> float:
    """大文件超时自适应：30 + size_mb * 1.5（上限 180s）。

    小文件（<=10MB）固定 30s；大文件按大小线性增加，上限 180s。
    """
    size_mb = file_size_bytes / (1024 * 1024)
    if size_mb <= _LARGE_FILE_THRESHOLD_MB:
        return _BASE_TIMEOUT_SEC
    timeout = _BASE_TIMEOUT_SEC + size_mb * _PER_MB_TIMEOUT_SEC
    return min(timeout, _MAX_TIMEOUT_SEC)


class SendFileFlow:
    """文件发送 Flow。

    依赖注入：
    - actions: L3 Actions 实例（用于点击元素、窗口激活、坐标点击）
    - xdotool: XdotoolDriver 实例（用于 xdotool type 路径，因系统文件选择器
      需逐字符输入且 actions.backend.type_text 不支持 --delay 参数）
    - db_reader: DbReader 实例（用于 post_verify）
    """

    def __init__(self, actions, xdotool, db_reader) -> None:
        self._actions = actions
        self._xdotool = xdotool
        self._db_reader = db_reader

    async def execute(
        self,
        to_wxid: str,
        file_path: str,
        file_type: str = "file",
        timeout_sec: Optional[float] = None,
        display_name: Optional[str] = None,
    ) -> dict:
        """执行文件发送流程。

        Args:
            to_wxid: 目标会话 wxid
            file_path: 文件绝对路径（必须在白名单目录内）
            file_type: "image" / "file" / "video"（仅用于日志，不影响流程）
            timeout_sec: 自定义超时（None 时按文件大小自适应）
            display_name: 用于微信搜索框定位会话的显示名（备注/昵称），
                          None 时回退到 to_wxid（部分联系人会话可能定位失败）

        Returns:
            {"success": bool, "verified": bool, "error": Optional[str]}

        Raises:
            BridgeError: 路径校验失败 / 文件不存在 / 窗口未找到 / 发送超时
        """
        # 1. 路径白名单 + 遍历校验
        _validate_file_path(file_path)

        # 2. 文件存在性 + 大小检查
        if not os.path.isfile(file_path):
            raise BridgeError(
                "INVALID_PARAMS",
                f"文件不存在: {file_path}",
                http_status=400,
            )
        file_size = os.path.getsize(file_path)
        if timeout_sec is None:
            timeout_sec = _compute_timeout(file_size)
        file_name = os.path.basename(file_path)
        # 搜索查询词：优先使用 display_name，回退到 to_wxid
        search_query = display_name.strip() if display_name and display_name.strip() else to_wxid

        logger.info(
            "[flow:send_file] START to_wxid=%s file=%s size=%d type=%s timeout=%.1fs display_name=%s",
            to_wxid, file_name, file_size, file_type, timeout_sec,
            display_name or "(none)",
        )

        # 3. 获取基线时间戳（post_verify 游标，仅查发送之后的新消息）
        # 复合游标 (create_time, local_id) 避免基线消息本身被重复匹配
        baseline_time, baseline_local_id = await self._get_baseline_time(to_wxid)
        logger.info(
            "[flow:send_file] baseline ct=%d local_id=%d",
            baseline_time, baseline_local_id,
        )

        try:
            async with asyncio.timeout(timeout_sec):
                # 4. 激活窗口 + 获取几何 + Esc 清场
                t0 = time.perf_counter()
                await self._actions.backend.activate_window(_WECHAT_WINDOW_TITLE)
                win_geom = await self._actions.backend.get_window_geometry(
                    _WECHAT_WINDOW_TITLE
                )
                await self._actions.backend.key_press("Escape")
                await asyncio.sleep(0.3)
                logger.info(
                    "[flow:send_file] step=activate done geom=%dx%d@(%d,%d) (%.0fms)",
                    win_geom.width, win_geom.height, win_geom.x, win_geom.y,
                    (time.perf_counter() - t0) * 1000,
                )

                # 5. 定位会话（搜索框 → 输入 display_name → 点击搜索结果第一项）
                t0 = time.perf_counter()
                logger.info(
                    "[flow:send_file] step=open_session start query=%r",
                    search_query,
                )
                await self._open_session(search_query, win_geom)
                logger.info(
                    "[flow:send_file] step=open_session done (%.0fms)",
                    (time.perf_counter() - t0) * 1000,
                )

                # 6. 点击 "+" → 选 "发送文件" 菜单项
                t0 = time.perf_counter()
                logger.info("[flow:send_file] step=click_plus_and_send_file start")
                await self._click_plus_and_send_file(win_geom)
                logger.info(
                    "[flow:send_file] step=click_plus_and_send_file done (%.0fms)",
                    (time.perf_counter() - t0) * 1000,
                )

                # 7. 系统文件选择器：xdotool type 路径 → Return 提交
                t0 = time.perf_counter()
                logger.info(
                    "[flow:send_file] step=input_path_in_chooser start path=%s",
                    file_path,
                )
                await self._input_path_in_chooser(file_path)
                logger.info(
                    "[flow:send_file] step=input_path_in_chooser done (%.0fms)",
                    (time.perf_counter() - t0) * 1000,
                )

                # 8. 等待文件加载到输入区
                logger.info("[flow:send_file] step=wait_file_load 1.0s")
                await asyncio.sleep(1.0)
        except asyncio.TimeoutError as exc:
            logger.warning(
                "[flow:send_file] TIMEOUT file=%s timeout=%.1fs",
                file_name, timeout_sec,
            )
            # 兜底清场：Esc 关闭可能残留的弹窗/选择器
            try:
                await self._actions.backend.key_press("Escape", repeat=2)
            except Exception:
                pass
            raise BridgeError(
                "SEND_TIMEOUT",
                f"文件发送超时: {file_name}",
                http_status=503,
            ) from exc

        # 9. post_verify：DB 校验文件消息是否落库
        t0 = time.perf_counter()
        verified = await self._post_verify(
            to_wxid, file_name, baseline_time, baseline_local_id
        )
        logger.info(
            "[flow:send_file] DONE file=%s verified=%s (%.0fms)",
            file_name, verified, (time.perf_counter() - t0) * 1000,
        )
        return {"success": True, "verified": verified, "error": None}

    # ------------------------------------------------------------------
    # 会话定位（参考 send_text.py 的 _open_session 范式重新实现，
    # 避免破坏 SendTextFlow 的封装）
    # ------------------------------------------------------------------
    async def _open_session(self, search_query: str, win_geom) -> None:
        """定位会话：点击搜索框 → 输入查询词 → 点击搜索结果第一项。

        Args:
            search_query: 搜索查询词（display_name 优先，回退到 to_wxid）
            win_geom: 窗口几何信息
        """
        logger.info("[flow:send_file] _open_session: click search_box")
        await self._actions.click_element("search_box", win_geom)
        await asyncio.sleep(0.3)
        # 清空可能残留的查询
        logger.info("[flow:send_file] _open_session: clear + type query")
        await self._actions.backend.key_press("BackSpace", repeat=30)
        await self._actions.backend.type_text(search_query)
        # 等待搜索结果出现
        await asyncio.sleep(0.5)
        # 点击搜索结果第一项打开会话
        logger.info("[flow:send_file] _open_session: click search_result_first")
        await self._actions.click_element("search_result_first", win_geom)
        await asyncio.sleep(0.5)

    # ------------------------------------------------------------------
    # 点击 "+" + 选 "发送文件"
    # ------------------------------------------------------------------
    async def _click_plus_and_send_file(self, win_geom) -> None:
        """点击 "+" 按钮并选择 "发送文件" 菜单项。

        plus_button / send_file_menu 未在 locator 注册表注册，用坐标点击 "+"
        （位于输入框右侧、靠近窗口右下角），菜单弹出后用键盘 Down + Return 选择。
        """
        # "+" 按钮：靠近窗口右下角（输入框右侧）
        plus_x = win_geom.x + win_geom.width - 30
        plus_y = win_geom.y + win_geom.height - 30
        logger.info(
            "[flow:send_file] _click_plus: click (+) at (%d,%d) geom=%dx%d@(%d,%d)",
            plus_x, plus_y, win_geom.width, win_geom.height, win_geom.x, win_geom.y,
        )
        await self._actions.backend.click(plus_x, plus_y)
        await asyncio.sleep(0.6)  # 等待菜单弹出

        # 键盘导航选 "发送文件"（参考 publish_moment_with_image 的 Down + Return 范式）
        logger.info(
            "[flow:send_file] _click_plus: navigate menu Down×%d + Return",
            _SEND_FILE_MENU_DOWN_COUNT,
        )
        for _ in range(_SEND_FILE_MENU_DOWN_COUNT):
            await self._actions.backend.key_press("Down")
            await asyncio.sleep(0.15)
        await self._actions.backend.key_press("Return")
        await asyncio.sleep(0.8)  # 等待系统文件选择器打开

    # ------------------------------------------------------------------
    # 文件选择器路径输入
    # ------------------------------------------------------------------
    async def _input_path_in_chooser(self, file_path: str) -> None:
        """在系统文件选择器对话框输入路径并回车提交。

        关键点：微信自绘 UI 不接受 Ctrl+V，但系统 GTK/Qt 文件选择器对话框
        接受 xdotool type 逐字符输入。用 --delay 50 保证每字符被对话框处理。
        """
        logger.info(
            "[flow:send_file] _input_path: xdotool type path len=%d delay=50ms",
            len(file_path),
        )
        rc, _, stderr = await self._xdotool._run(
            ["xdotool", "type", "--delay", "50", file_path]
        )
        if rc != 0:
            err = stderr.decode(errors="ignore").strip() if stderr else "unknown"
            logger.warning(
                "[flow:send_file] _input_path: xdotool type FAILED rc=%d err=%s",
                rc, err,
            )
            raise BridgeError(
                "SEND_FAILED",
                f"xdotool type 路径失败 (code={rc}): {err}",
            )
        await asyncio.sleep(0.3)  # 等待输入完成
        # Return 提交选择
        logger.info("[flow:send_file] _input_path: Return to submit")
        await self._xdotool._key("Return")

    # ------------------------------------------------------------------
    # DB 校验
    # ------------------------------------------------------------------
    async def _get_baseline_time(self, to_wxid: str) -> tuple[int, int]:
        """获取目标会话当前最新消息的 (create_time, local_id) 作为发送前基线。

        post_verify 仅查基线之后的新消息，避免历史消息假阳性。
        复合游标 (create_time, local_id) 作为 tie-breaker，避免基线消息本身
        在 after 模式下被重复匹配（create_time = baseline AND local_id > 0）。

        Returns:
            (create_time, local_id) 元组。DB 不可用或会话无历史时返回 (0, 0)，
            _post_verify 检测到 (0, 0) 会跳过 DB 校验，避免查询全部历史消息
            造成假阳性（历史同名文件消息被误匹配）。
        """
        if self._db_reader is None:
            return (0, 0)
        try:
            result = await asyncio.to_thread(
                self._db_reader.get_messages_by_session,
                to_wxid,
                cursor=0,
                limit=1,
                direction="before",
                is_sender=None,
            )
            messages = result.get("messages", [])
            if messages:
                return (
                    int(messages[0].get("create_time", 0)),
                    int(messages[0].get("local_id", 0)),
                )
        except Exception as exc:
            logger.debug(
                "[flow:send_file] get_baseline_time exception: %s", exc
            )
        return (0, 0)

    async def _post_verify(
        self,
        to_wxid: str,
        file_name: str,
        baseline_time: int,
        baseline_local_id: int = 0,
    ) -> Optional[bool]:
        """DB 校验：查 type in (3,43,49) 且 content 含文件名。

        Args:
            to_wxid: 目标会话 wxid
            file_name: 文件名（用于 content 匹配）
            baseline_time: 基线时间戳（仅查此时间之后的消息）
            baseline_local_id: 基线消息 local_id（tie-breaker，避免基线本身被重匹配）

        Returns:
            True 表示校验通过；False 表示校验超时未匹配（真正的校验失败）；
            None 表示无法校验（DB 不可用或无基线），orchestrator 不应计入
            熔断失败计数，避免 DB 不可用时熔断器雪崩阻塞全部发送
        """
        if self._db_reader is None:
            logger.warning("[flow:send_file] no db_reader, skip post_verify")
            return None

        # P0 修复：baseline_time=0 表示 DB 不可用或会话无历史。
        # 此时若仍查 DB（cursor=0 + direction=after）会扫描全部历史消息，
        # 同名文件的历史消息会被误匹配（假阳性）。返回 None 表示"无法校验"，
        # orchestrator 不计熔断失败，避免 DB 不可用时熔断器雪崩。
        if baseline_time == 0:
            logger.warning(
                "[flow:send_file] baseline_time=0（DB 不可用或无历史），"
                "跳过 DB 校验避免假阳性（返回 None，不计熔断）"
            )
            return None

        # 等待 WAL 刷盘（微信写入 + SQLite checkpoint）
        await asyncio.sleep(_DB_VERIFY_WAL_FLUSH_SEC)

        deadline = time.monotonic() + _DB_VERIFY_TIMEOUT_SEC
        attempts = 0
        while time.monotonic() < deadline:
            attempts += 1
            try:
                result = await asyncio.to_thread(
                    self._db_reader.get_messages_by_session,
                    to_wxid,
                    cursor=baseline_time,
                    cursor_local_id=baseline_local_id,
                    limit=10,
                    direction="after",
                    is_sender=None,
                )
                messages = result.get("messages", [])
                for msg in messages:
                    msg_type = int(msg.get("type", 0))
                    msg_content = msg.get("content", "")
                    # 文件消息 type in (3,43,49) 且 content 含文件名
                    if msg_type in _FILE_MSG_TYPES and file_name in msg_content:
                        logger.info(
                            "[flow:send_file] post_verify OK "
                            "(attempt=%d, type=%d, is_sender=%s)",
                            attempts, msg_type, msg.get("is_sender"),
                        )
                        return True
                if messages:
                    logger.debug(
                        "[flow:send_file] post_verify attempt=%d: %d new msgs but no match",
                        attempts, len(messages),
                    )
            except Exception as exc:
                logger.debug(
                    "[flow:send_file] post_verify attempt=%d exception: %s",
                    attempts, exc,
                )
            await asyncio.sleep(_DB_VERIFY_INTERVAL_SEC)

        logger.warning(
            "[flow:send_file] post_verify timeout (attempts=%d, file=%s)",
            attempts, file_name,
        )
        return False
