"""
updater.py —— 联网更新执行器
此文件会被更新流程从服务器下载到 temp 目录后动态加载，在主线程调用。

UpdateExecutor().update(exe_dir)
  -> 弹出带进度条+日志的 PyQt5 对话框（必须在主线程调用）
  -> 返回 {"success": bool, "message": str, "need_restart": bool, "bat_path": str|None}

更新内容全部在 UpdateExecutor.FILE_DICT / EXE_DICT 中配置，主程序无需关心。
"""

import os
import shutil
import traceback
import requests
from urllib.parse import urljoin, urlparse, unquote
from html.parser import HTMLParser

from PyQt5.QtWidgets import (
    QDialog, QVBoxLayout, QHBoxLayout,
    QLabel, QProgressBar, QTextEdit, QPushButton,
    QSizePolicy, QMessageBox,
)
from PyQt5.QtCore import Qt, QThread, pyqtSignal
from PyQt5.QtGui import QFont, QTextCursor


def _fmt_error(exc: Exception) -> str:
    """格式化异常为含文件名、行号、调用栈的详细文本，供客户截图发给售后。"""
    tb = traceback.format_exc()
    return (
        f"错误类型: {type(exc).__name__}\n"
        f"错误信息: {exc}\n\n"
        f"详细堆栈（请截图发给售后）:\n"
        f"{tb}"
    )


# ══════════════════════════════════════════════════════════════════════════
# 后台工作线程
# ══════════════════════════════════════════════════════════════════════════
class _WorkThread(QThread):
    log             = pyqtSignal(str)   # 日志行
    progress        = pyqtSignal(int)   # 总进度 0-100
    finished_result = pyqtSignal(dict)  # 最终结果

    def __init__(self, exe_dir: str, file_dict: dict, exe_dict: dict):
        super().__init__()
        self.exe_dir   = exe_dir
        self.file_dict = file_dict
        self.exe_dict  = exe_dict
        self.temp_dir  = os.path.join(exe_dir, "_update_temp")

    def run(self):
        import datetime
        need_restart = False
        bat_path     = None

        # 备份根目录：_update_backup/<时间戳>/  目录结构与 exe_dir 完全一致
        timestamp  = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
        backup_root = os.path.join(self.exe_dir, "_update_backup", timestamp)

        try:
            # ── 创建工作目录 ──────────────────────────────────────────────
            try:
                os.makedirs(self.temp_dir,  exist_ok=True)
                os.makedirs(backup_root,    exist_ok=True)
            except Exception as e:
                raise RuntimeError(f"无法创建工作目录: {e}") from e

            all_items = list(self.file_dict.items()) + list(self.exe_dict.items())
            total = len(all_items)
            if total == 0:
                self.log.emit("[warn] 没有任何更新项，退出。")
                self.progress.emit(100)
                self.finished_result.emit({
                    "success": True, "message": "无更新项",
                    "need_restart": False, "bat_path": None,
                })
                return

            done = 0

            # ── 普通文件 ──────────────────────────────────────────────────
            for key, item in self.file_dict.items():
                try:
                    addr = item.get("addr", "").strip()
                    path = item.get("path", "").strip()
                    if not addr or not path:
                        self.log.emit(f"[warn] [{key}] addr 或 path 为空，跳过")
                        done += 1; self.progress.emit(int(done / total * 100)); continue

                    target    = path if os.path.isabs(path) else os.path.join(self.exe_dir, path)
                    temp_file = os.path.join(self.temp_dir, key)

                    self.log.emit(f"[info] 下载 [{key}] ...")
                    try:
                        self._download(addr, temp_file, key)
                    except Exception as dl_err:
                        self.log.emit(
                            f"[error] [{key}] 下载失败\n"
                            + _fmt_error(dl_err)
                        )
                        done += 1; self.progress.emit(int(done / total * 100)); continue

                    # 备份原文件：保持与 exe_dir 相同的相对目录结构
                    try:
                        if os.path.isfile(target):
                            self._backup_file(target, backup_root)
                            self.log.emit(f"  [bak] 已备份: {os.path.relpath(target, self.exe_dir)}")
                    except Exception as bak_err:
                        self.log.emit(
                            f"  [warn] [{key}] 备份失败（继续更新）\n"
                            + _fmt_error(bak_err)
                        )

                    # 复制到目标
                    try:
                        os.makedirs(os.path.dirname(os.path.abspath(target)), exist_ok=True)
                        shutil.copy2(temp_file, target)
                        self.log.emit(f"  [ok] [{key}] 已更新 -> {target}")
                    except Exception as cp_err:
                        self.log.emit(
                            f"  [error] [{key}] 复制到目标路径失败\n"
                            + _fmt_error(cp_err)
                        )

                except Exception as item_err:
                    self.log.emit(
                        f"[error] 处理文件 [{key}] 时发生意外错误\n"
                        + _fmt_error(item_err)
                    )
                finally:
                    done += 1
                    self.progress.emit(int(done / total * 100))

            # ── exe 文件 ──────────────────────────────────────────────────
            for key, item in self.exe_dict.items():
                try:
                    addr = item.get("addr", "").strip()
                    path = item.get("path", "").strip()
                    if not addr or not path:
                        self.log.emit(f"[warn] exe [{key}] addr 或 path 为空，跳过")
                        done += 1; self.progress.emit(int(done / total * 100)); continue

                    target    = path if os.path.isabs(path) else os.path.join(self.exe_dir, path)
                    new_exe   = target + ".new"
                    temp_file = os.path.join(self.temp_dir, key)

                    self.log.emit(f"[info] 下载 exe [{key}] ...")
                    try:
                        self._download(addr, temp_file, key)
                    except Exception as dl_err:
                        self.log.emit(
                            f"[error] exe [{key}] 下载失败\n"
                            + _fmt_error(dl_err)
                        )
                        done += 1; self.progress.emit(int(done / total * 100)); continue

                    # 备份原 exe：保持与 exe_dir 相同的相对目录结构
                    try:
                        if os.path.isfile(target):
                            self._backup_file(target, backup_root)
                            self.log.emit(f"  [bak] 已备份 exe: {os.path.relpath(target, self.exe_dir)}")
                    except Exception as bak_err:
                        self.log.emit(
                            f"  [warn] exe [{key}] 备份失败（继续更新）\n"
                            + _fmt_error(bak_err)
                        )

                    # 准备 .new 文件和 bat
                    try:
                        shutil.copy2(temp_file, new_exe)
                        bat_path     = self._gen_bat(new_exe, target)
                        need_restart = True
                        self.log.emit(f"  [ok] exe [{key}] 已准备，重启后生效")
                    except Exception as cp_err:
                        self.log.emit(
                            f"  [error] exe [{key}] 准备替换文件失败\n"
                            + _fmt_error(cp_err)
                        )

                except Exception as item_err:
                    self.log.emit(
                        f"[error] 处理 exe [{key}] 时发生意外错误\n"
                        + _fmt_error(item_err)
                    )
                finally:
                    done += 1
                    self.progress.emit(int(done / total * 100))

            # ── 清理 temp ────────────────────────────────────────────────
            try:
                shutil.rmtree(self.temp_dir, ignore_errors=True)
                self.log.emit("[info] 临时目录已清理")
            except Exception as rm_err:
                self.log.emit(
                    "[warn] 清理临时目录失败（不影响更新结果）\n"
                    + _fmt_error(rm_err)
                )

            self.log.emit(f"[info] 备份保存于: _update_backup/{timestamp}/")
            self.progress.emit(100)
            self.finished_result.emit({
                "success":      True,
                "message":      "更新完成",
                "need_restart": need_restart,
                "bat_path":     bat_path,
            })

        except Exception as fatal_err:
            err_text = _fmt_error(fatal_err)
            self.log.emit("[FATAL] 更新线程发生致命错误:\n" + err_text)
            self.progress.emit(100)
            try:
                shutil.rmtree(self.temp_dir, ignore_errors=True)
            except Exception:
                pass
            self.finished_result.emit({
                "success":      False,
                "message":      "[FATAL] " + err_text,
                "need_restart": False,
                "bat_path":     None,
            })

    def _backup_file(self, src: str, backup_root: str):
        """
        将 src 文件备份到 backup_root 下，保持与 exe_dir 完全一致的相对路径结构。

        例如 exe_dir = D:/app，src = D:/app/models/yolo.onnx，
        backup_root = D:/app/_update_backup/20260410_153000
        则备份到   D:/app/_update_backup/20260410_153000/models/yolo.onnx
        """
        try:
            # 计算相对路径；若 src 不在 exe_dir 内（绝对路径配置），
            # 则取去掉盘符后的路径，避免目录穿越
            try:
                rel = os.path.relpath(src, self.exe_dir)
                # relpath 可能以 ".." 开头（绝对路径配置到了 exe_dir 之外）
                if rel.startswith(".."):
                    rel = os.path.splitdrive(src)[1].lstrip("\\/")
            except ValueError:
                # Windows 跨盘符时 relpath 会抛 ValueError
                rel = os.path.splitdrive(src)[1].lstrip("\\/")

            dest = os.path.join(backup_root, rel)
            os.makedirs(os.path.dirname(os.path.abspath(dest)), exist_ok=True)
            shutil.copy2(src, dest)
        except Exception as e:
            raise OSError(f"备份文件失败: {src} -> {backup_root}\n{e}") from e

    def _download(self, url: str, save_path: str, desc: str):
        """下载文件，智能处理各种服务器响应和编码"""
        try:
            resp = requests.get(url, stream=True, timeout=60)
            resp.raise_for_status()
        except requests.exceptions.ConnectionError as e:
            raise ConnectionError(f"无法连接到服务器: {url}\n{e}") from e
        except requests.exceptions.Timeout as e:
            raise TimeoutError(f"请求超时: {url}\n{e}") from e
        except requests.exceptions.HTTPError as e:
            raise RuntimeError(f"服务器返回错误状态码: {e}") from e
        except Exception as e:
            raise RuntimeError(f"下载请求异常: {e}") from e

        content_type = resp.headers.get("Content-Type", "").lower()
        
        # 判断是否为文本类型（Python脚本、配置文件等）
        is_text_file = (
            save_path.endswith((".py", ".json", ".txt", ".ui", ".xml", ".yml", ".yaml"))
            or "text/" in content_type
            or "application/json" in content_type
        )

        if is_text_file:
            # 文本文件：智能解码
            raw = resp.content
            for enc in ("utf-8", "utf-8-sig", "gbk", "latin-1"):
                try:
                    text = raw.decode(enc)
                    break
                except Exception:
                    continue
            else:
                text = raw.decode("utf-8", errors="replace")

            # 如果是 HTML 包裹，提取内容
            if "text/html" in content_type or ("<html" in text.lower() and len(text) > 100):
                import html as html_mod
                import re
                pre_match = re.search(r'<pre[^>]*>(.*?)</pre>', text, re.DOTALL | re.IGNORECASE)
                if pre_match:
                    text = html_mod.unescape(pre_match.group(1))
                else:
                    text = html_mod.unescape(re.sub(r'<[^>]+>', '', text))

            with open(save_path, "w", encoding="utf-8") as f:
                f.write(text)
        else:
            # 二进制文件：流式下载
            total = int(resp.headers.get("content-length", 0))
            downloaded = 0
            with open(save_path, "wb") as f:
                for chunk in resp.iter_content(chunk_size=8192):
                    if chunk:
                        f.write(chunk)
                        downloaded += len(chunk)
                        if total > 0:
                            pct = int(downloaded / total * 100)
                            if pct % 10 == 0:
                                self.log.emit(f"  [dl] {desc}: {pct}%")

    def _gen_bat(self, new_exe: str, current_exe: str) -> str:
        """生成延迟替换 bat 脚本；失败时抛出异常。"""
        bat_path = os.path.join(self.exe_dir, "_update_replace.bat")
        try:
            with open(bat_path, "w", encoding="utf-8") as f:
                f.write(
                    "@echo off\n"
                    "chcp 65001 > nul\n"
                    "timeout /t 5 /nobreak > nul\n"
                    f"move /Y \"{new_exe}\" \"{current_exe}\"\n"
                    "timeout /t 2 /nobreak > nul\n"
                    f"start \"\" \"{current_exe}\"\n"
                    "del \"%~f0\"\n"
                )
        except Exception as e:
            raise OSError(f"生成替换脚本失败: {bat_path}\n{e}") from e
        return bat_path


# ══════════════════════════════════════════════════════════════════════════
# 更新对话框（必须在主线程构建）
# ══════════════════════════════════════════════════════════════════════════
class _UpdateDialog(QDialog):

    def __init__(self, parent=None):
        try:
            super().__init__(parent)
            self.setWindowTitle("联网更新")
            self.setWindowFlags(Qt.Dialog | Qt.WindowTitleHint)
            self.setFixedSize(560, 420)
            self.result_data = None

            self.lbl_title = QLabel("正在执行更新，请稍候...")
            font = QFont(); font.setPointSize(10); font.setBold(True)
            self.lbl_title.setFont(font)
            self.lbl_title.setAlignment(Qt.AlignCenter)

            self.progress_bar = QProgressBar()
            self.progress_bar.setRange(0, 100)
            self.progress_bar.setValue(0)
            self.progress_bar.setFixedHeight(22)
            self.progress_bar.setFormat("%p%")

            self.txt_log = QTextEdit()
            self.txt_log.setReadOnly(True)
            self.txt_log.setFont(QFont("Consolas", 9))
            self.txt_log.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)

            self.btn_ok = QPushButton("确定")
            self.btn_ok.setFixedSize(100, 32)
            self.btn_ok.setEnabled(False)
            self.btn_ok.clicked.connect(self.accept)

            btn_row = QHBoxLayout()
            btn_row.addStretch()
            btn_row.addWidget(self.btn_ok)

            layout = QVBoxLayout(self)
            layout.setContentsMargins(16, 16, 16, 16)
            layout.setSpacing(10)
            layout.addWidget(self.lbl_title)
            layout.addWidget(self.progress_bar)
            layout.addWidget(self.txt_log)
            layout.addLayout(btn_row)

        except Exception as e:
            err_text = _fmt_error(e)
            QMessageBox.critical(
                None, "更新界面初始化失败",
                "无法创建更新对话框，请截图发给售后：\n\n" + err_text
            )
            raise

    def append_log(self, text: str):
        try:
            self.txt_log.append(text)
            self.txt_log.moveCursor(QTextCursor.End)
        except Exception:
            pass  # 日志显示失败不阻断流程

    def set_progress(self, value: int):
        try:
            self.progress_bar.setValue(value)
        except Exception:
            pass

    def on_finished(self, result: dict):
        """由 finished_result 信号在主线程触发。"""
        try:
            self.result_data = result
            if result.get("success", False):
                self.lbl_title.setText("✅ 更新完成")
                self.append_log("\n[ok] 所有文件更新完毕。")
            else:
                self.lbl_title.setText("❌ 更新失败")
                msg = result.get("message", "未知错误")
                self.append_log(
                    "\n[error] 更新过程中出现错误，详细信息如下"
                    "（请截图或复制全部日志发给售后）:\n"
                    + "-" * 60 + "\n"
                    + msg + "\n"
                    + "-" * 60
                )
                # 额外弹出醒目错误框，便于客户截图
                QMessageBox.critical(
                    self, "更新失败",
                    "更新过程中出现错误，完整信息已显示在日志框内，\n"
                    "请截图（含日志框）发给售后支持。\n\n"
                    + msg[:600]   # 弹窗只截取前 600 字符，完整内容在日志框
                )
        except Exception as e:
            err_text = _fmt_error(e)
            try:
                QMessageBox.critical(
                    self, "on_finished 内部异常",
                    "处理更新结果时发生内部错误，请截图发给售后：\n\n" + err_text
                )
            except Exception:
                pass
        finally:
            self.btn_ok.setEnabled(True)


# ══════════════════════════════════════════════════════════════════════════
# 对外接口（必须在主线程调用）
# ══════════════════════════════════════════════════════════════════════════
class UpdateExecutor:
    """
    在主线程中调用 update(exe_dir)，弹出更新对话框，阻塞直到用户点确定。
    具体更新哪些文件，全部在此文件的 FILE_DICT / EXE_DICT 里配置，主程序无需关心。

    ── path 填写规则 ────────────────────────────────────────────────────────
    · 相对路径：相对于 exe_dir（程序根目录），例如：
        "updater.py"                        → <exe_dir>/updater.py
        "ear_fold_detection_plugin.ui"      → <exe_dir>/ear_fold_detection_plugin.ui
        "models/yolo_cut.onnx"              → <exe_dir>/models/yolo_cut.onnx
    · 绝对路径：直接使用完整路径，例如：
        "C:/deploy/config.json"             → 原样使用

    ── FILE_DICT：普通文件（直接覆盖，原文件自动备份到 _update_backup/）────
        FILE_DICT = {
            "key名（也是下载后的临时文件名）": {
                "addr": "http://your-server/path/to/file",  # 下载地址
                "path": "相对或绝对目标路径",                  # 覆盖目标
            },
        }

    ── EXE_DICT：可执行文件（下载后写成 .new，程序退出时由 bat 脚本替换）──
        EXE_DICT = {
            "key名": {
                "addr": "http://your-server/path/to/app.exe",
                "path": "ear_fold_detection_plugin.exe",     # 相对 exe_dir
            },
        }
    """

    # ══════════════════════════════════════════════════════════════════════
    # ★ 在此处配置需要更新的文件，其余代码无需改动 ★
    # ══════════════════════════════════════════════════════════════════════

    # 服务器更新根目录地址（留空则使用下方手动配置的 FILE_DICT / EXE_DICT）
    # 该地址的目录结构需与 exe_dir 完全一致，且服务器需开启目录索引（如 Nginx autoindex on）
    # 示例: SERVER_BASE_URL = "http://192.168.1.100/update/liuzhou_v1/"
    SERVER_BASE_URL: str = "http://110.42.211.132:48765/%E6%9F%B3%E5%B7%9E%E4%B8%80%E6%9C%9F%E6%9B%B4%E6%96%B0%E6%96%87%E4%BB%B6%E5%A4%B9/program/"

    FILE_DICT: dict = {
        # 示例1：更新同目录下的脚本文件（相对路径）
        # "updater.py": {
        #     "addr": "http://your-server/updater.py",
        #     "path": "updater.py",
        # },
        # 示例2：更新 UI 文件（相对路径）
        # "main.ui": {
        #     "addr": "http://your-server/ear_fold_detection_plugin.ui",
        #     "path": "ear_fold_detection_plugin.ui",
        # },
        # 示例3：更新子目录下的模型文件（相对路径，目录不存在会自动创建）
        # "yolo_cut.onnx": {
        #     "addr": "http://your-server/models/yolo_cut.onnx",
        #     "path": "models/yolo_cut.onnx",
        # },
    }
    EXE_DICT: dict = {
        # 示例：更新主程序 exe（下载后不立即替换，退出程序时由 bat 脚本完成替换）
        # "exe": {
        #     "addr": "http://your-server/ear_fold_detection_plugin.exe",
        #     "path": "ear_fold_detection_plugin.exe",   # 相对 exe_dir
        # },
    }
    # ══════════════════════════════════════════════════════════════════════

    @staticmethod
    def _scan_server(base_url: str) -> tuple:
        """
        递归扫描 HTTP 目录索引页面，自动生成 file_dict 和 exe_dict。

        要求服务器开启目录列表（Nginx: autoindex on; Apache: Options +Indexes）。
        目录结构应与 exe_dir 完全一致，扫描结果中的 path 即为相对路径。

        返回: (file_dict, exe_dict, error_msg)
          - error_msg 为 None 表示成功，否则为错误文字
        """
        class _LinkParser(HTMLParser):
            """解析 HTML 目录页，提取所有 <a href="..."> 链接。"""
            def __init__(self):
                super().__init__()
                self.links = []
            def handle_starttag(self, tag, attrs):
                if tag == "a":
                    for name, val in attrs:
                        if name == "href" and val:
                            self.links.append(val)

        file_dict = {}
        exe_dict  = {}

        # 确保 base_url 以 / 结尾
        if not base_url.endswith("/"):
            base_url += "/"

        base_parsed = urlparse(base_url)

        def _crawl(dir_url: str, rel_prefix: str):
            """递归爬取目录页，rel_prefix 是当前目录相对 base_url 的路径前缀。"""
            try:
                resp = requests.get(dir_url, timeout=15)
                resp.raise_for_status()
            except Exception as e:
                return f"无法访问目录: {dir_url}\n{e}"

            parser = _LinkParser()
            try:
                parser.feed(resp.text)
            except Exception:
                pass

            for href in parser.links:
                # 跳过父目录、锚点、查询参数、绝对 URL 指向其他域名
                if (href.startswith("?") or href.startswith("#")
                        or href in ("../", "..", "/")
                        or href.startswith("http") and not href.startswith(base_url)):
                    continue

                # 解码 URL 编码（中文文件名等）
                decoded = unquote(href)

                # 拼接完整 URL
                full_url = urljoin(dir_url, href)

                # 计算相对路径（用于 path 字段和 key）
                rel_path = rel_prefix + decoded.lstrip("/")

                if decoded.endswith("/"):
                    # 是子目录，递归
                    _crawl(full_url, rel_path)
                else:
                    # 是文件
                    # key 用相对路径，将 / 替换为 __ 避免作为 key 时混淆
                    key = rel_path.replace("\\", "/").replace("/", "__")
                    entry = {"addr": full_url, "path": rel_path.replace("\\", "/")}
                    if decoded.lower().endswith(".exe"):
                        exe_dict[key] = entry
                    else:
                        file_dict[key] = entry

            return None

        err = _crawl(base_url, "")
        if err:
            return {}, {}, err
        return file_dict, exe_dict, None

    @staticmethod
    def _show_confirm_dialog(file_dict: dict, exe_dict: dict) -> bool:
        """
        弹出确认对话框，展示将要更新的文件列表。
        用户点击"确认更新"返回 True，点击"取消"返回 False。
        """
        lines = []
        if file_dict:
            lines.append("── 普通文件 ──────────────────────────")
            for key, item in file_dict.items():
                lines.append(f"  {item['path']}")
                lines.append(f"    来源: {item['addr']}")
        if exe_dict:
            lines.append("")
            lines.append("── 可执行文件（重启后替换）────────────")
            for key, item in exe_dict.items():
                lines.append(f"  {item['path']}")
                lines.append(f"    来源: {item['addr']}")
        if not lines:
            lines.append("（未发现任何更新文件）")

        total = len(file_dict) + len(exe_dict)
        content = "\n".join(lines)

        dlg = QDialog()
        dlg.setWindowTitle("确认更新内容")
        dlg.setWindowFlags(Qt.Dialog | Qt.WindowTitleHint)
        dlg.setMinimumSize(580, 400)

        lbl = QLabel(f"检测到 <b>{total}</b> 个待更新文件，请确认后继续：")
        lbl.setWordWrap(True)

        txt = QTextEdit()
        txt.setReadOnly(True)
        txt.setFont(QFont("Consolas", 9))
        txt.setPlainText(content)

        btn_ok     = QPushButton("确认更新")
        btn_cancel = QPushButton("取消")
        btn_ok.setFixedSize(110, 32)
        btn_cancel.setFixedSize(80, 32)

        confirmed = [False]
        btn_ok.clicked.connect(lambda: (confirmed.__setitem__(0, True), dlg.accept()))
        btn_cancel.clicked.connect(dlg.reject)

        btn_row = QHBoxLayout()
        btn_row.addStretch()
        btn_row.addWidget(btn_ok)
        btn_row.addWidget(btn_cancel)

        layout = QVBoxLayout(dlg)
        layout.setContentsMargins(16, 16, 16, 16)
        layout.setSpacing(10)
        layout.addWidget(lbl)
        layout.addWidget(txt)
        layout.addLayout(btn_row)

        dlg.exec_()
        return confirmed[0]

    def update(self, exe_dir: str) -> dict:
        """主线程调用入口，弹出带 UI 的更新对话框。"""
        try:
            # ── Step 1: 决定使用哪套字典 ─────────────────────────────────
            file_dict = dict(self.FILE_DICT)
            exe_dict  = dict(self.EXE_DICT)

            if self.SERVER_BASE_URL.strip():
                # 有服务器地址：自动扫描生成字典
                scanning_box = QMessageBox(QMessageBox.Information, "正在扫描",
                                           f"正在扫描更新服务器目录...\n{self.SERVER_BASE_URL}")
                scanning_box.setStandardButtons(QMessageBox.NoButton)
                scanning_box.show()
                from PyQt5.QtWidgets import QApplication
                QApplication.processEvents()

                scanned_file, scanned_exe, scan_err = self._scan_server(self.SERVER_BASE_URL)
                scanning_box.close()

                if scan_err:
                    QMessageBox.critical(
                        None, "扫描服务器失败",
                        f"无法获取更新列表，请检查网络和服务器地址。\n\n{scan_err}"
                    )
                    return {
                        "success": False, "message": "扫描服务器失败: " + scan_err,
                        "need_restart": False, "bat_path": None,
                    }

                file_dict = scanned_file
                exe_dict  = scanned_exe

            # ── Step 2: 弹出确认对话框 ────────────────────────────────────
            if not file_dict and not exe_dict:
                QMessageBox.information(None, "无更新", "未检测到任何需要更新的文件。")
                return {
                    "success": True, "message": "无更新项",
                    "need_restart": False, "bat_path": None,
                }

            confirmed = self._show_confirm_dialog(file_dict, exe_dict)
            if not confirmed:
                return {
                    "success": False, "message": "用户取消了更新",
                    "need_restart": False, "bat_path": None,
                }

            # ── Step 3: 执行更新 ──────────────────────────────────────────
            dialog = _UpdateDialog()

            try:
                worker = _WorkThread(exe_dir, file_dict, exe_dict)
                worker.log.connect(dialog.append_log)
                worker.progress.connect(dialog.set_progress)
                worker.finished_result.connect(dialog.on_finished)
                worker.start()
            except Exception as e:
                err_text = _fmt_error(e)
                QMessageBox.critical(
                    None, "启动更新线程失败",
                    "无法启动后台更新线程，请截图发给售后：\n\n" + err_text
                )
                return {
                    "success": False,
                    "message": "启动更新线程失败: " + err_text,
                    "need_restart": False, "bat_path": None,
                }

            dialog.exec_()          # 主线程阻塞，等用户点确定

            try:
                worker.wait(10000)  # 最多等 10 秒确保线程退出
            except Exception:
                pass

            if dialog.result_data is not None:
                return dialog.result_data

            return {
                "success": False, "message": "对话框被意外关闭",
                "need_restart": False, "bat_path": None,
            }

        except Exception as e:
            err_text = _fmt_error(e)
            try:
                QMessageBox.critical(
                    None, "UpdateExecutor 异常",
                    "更新执行器发生错误，请截图发给售后：\n\n" + err_text
                )
            except Exception:
                pass
            return {
                "success": False,
                "message": "UpdateExecutor 异常: " + err_text,
                "need_restart": False, "bat_path": None,
            }


