# -*- coding: utf-8 -*-
"""
MCOP - 从零手写的 Minecraft 启动器 (纯标准库, 离线模式)
功能: 版本清单下载 / 客户端+依赖库+资源文件下载 / 离线登录 / JVM 参数拼装 / 启动游戏
"""

import hashlib
import json
import math
import os
import platform
import queue
import re
import shutil
import ssl
import subprocess
import sys
import tempfile
import threading
import time
import ctypes
from ctypes import wintypes
import urllib.request
import urllib.error
import urllib.parse
import zipfile
from concurrent.futures import ThreadPoolExecutor, as_completed

import tkinter as tk
from tkinter import ttk, messagebox, filedialog, scrolledtext

# ---------------- 基本常量 ----------------
APP_NAME = "MCOP"
APP_VERSION = "1.1"
VERSION_MANIFEST_URL = "https://piston-meta.mojang.com/mc/game/version_manifest.json"
RESOURCE_BASE = "https://resources.download.minecraft.net"
USER_AGENT = "MCOP/1.0"

BASE_DIR = os.path.dirname(os.path.abspath(__file__))
GAME_DIR = os.path.join(BASE_DIR, "game")          # 相当于 .minecraft
RUNTIME_DIR = os.path.join(BASE_DIR, "runtime")    # 自动下载的 Java 放这里
MANIFEST_DIR = os.path.join(GAME_DIR, ".mcop_manifests")  # 整合包安装清单, 删除版本时据此清理
SETTINGS_FILE = os.path.join(GAME_DIR, "mcop_settings.json")

OS_NAME = "windows"  # 本启动器只面向 Windows
IS_X86 = platform.architecture()[0] == "32bit"

DOWNLOAD_THREADS = 16

# ---------------- 下载源 ----------------
SOURCES = {
    "bmclapi": "https://bmclapi2.bangbang93.com",   # 国内镜像 (推荐)
    "official": None,                                # Mojang 官方源
}


def rewrite_url(url, source):
    """把 Mojang 官方 URL 改写为镜像 URL"""
    base = SOURCES.get(source)
    if not base:
        return url
    mapping = [
        ("https://piston-meta.mojang.com", base),
        ("https://piston-data.mojang.com", base),
        ("https://launchermeta.mojang.com", base),
        ("https://launcher.mojang.com", base),
        ("https://libraries.minecraft.net", base + "/maven"),
        ("https://resources.download.minecraft.net", base + "/assets"),
        # 加载器 Maven 仓库镜像 (BMCLAPI 支持 Forge / Fabric; NeoForge 若镜像无则回退官方)
        ("https://maven.minecraftforge.net", base + "/maven"),
        ("https://maven.fabricmc.net", base + "/maven"),
        ("https://maven.neoforged.net/releases", base + "/maven"),
    ]
    for old, new in mapping:
        if url.startswith(old):
            return new + url[len(old):]
    return url


# ---------------- 工具函数 ----------------
def ensure_dir(path):
    os.makedirs(path, exist_ok=True)


_SSL_CTX = None
_SSL_VERIFY_WARNED = False


def _ssl_context():
    """构造 SSL 上下文; Windows 上 Python 若找不到系统根证书, 尝试加载 certifi 证书包"""
    global _SSL_CTX
    if _SSL_CTX is None:
        ctx = ssl.create_default_context()
        try:
            import certifi
            ctx.load_verify_locations(certifi.where())
        except Exception:
            pass
        _SSL_CTX = ctx
    return _SSL_CTX


def http_get(url, timeout=30):
    global _SSL_VERIFY_WARNED
    req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
    try:
        return urllib.request.urlopen(req, timeout=timeout, context=_ssl_context())
    except Exception as e:
        msg = str(e)
        if "CERTIFICATE_VERIFY_FAILED" not in msg and "certificate verify failed" not in msg:
            raise
        # 系统缺少根证书(精简版 Windows / 未装 certifi): 回退为跳过校验, 仍然走 HTTPS 加密
        if not _SSL_VERIFY_WARNED:
            _SSL_VERIFY_WARNED = True
            print("[MCOP] 系统根证书缺失, 已临时跳过 HTTPS 证书校验以继续下载。")
        return urllib.request.urlopen(req, timeout=timeout,
                                     context=ssl._create_unverified_context())


def fetch_json(url, timeout=30):
    with http_get(url, timeout) as resp:
        return json.loads(resp.read().decode("utf-8"))


def download_file(url, dest, expected_sha1=None, retries=3, cancel_flag=None, progress_cb=None):
    """下载文件到 dest, 校验 sha1, 失败重试。已存在且校验通过则跳过。"""
    if os.path.exists(dest):
        if expected_sha1 is None or sha1_of(dest) == expected_sha1:
            return True
    ensure_dir(os.path.dirname(dest))
    tmp = dest + ".part"
    for attempt in range(retries):
        if cancel_flag and cancel_flag.is_set():
            return False
        try:
            with http_get(url, timeout=60) as resp, open(tmp, "wb") as f:
                total = int(resp.headers.get("Content-Length") or 0)
                read = 0
                while True:
                    if cancel_flag and cancel_flag.is_set():
                        f.close()
                        try:
                            os.remove(tmp)
                        except OSError:
                            pass
                        return False
                    chunk = resp.read(1024 * 128)
                    if not chunk:
                        break
                    f.write(chunk)
                    read += len(chunk)
                    if progress_cb and total > 0:
                        progress_cb(read, total)
            if expected_sha1 and sha1_of(tmp) != expected_sha1:
                continue
            os.replace(tmp, dest)
            return True
        except Exception:
            if attempt == retries - 1:
                raise
    return False


def sha1_of(path):
    h = hashlib.sha1()
    with open(path, "rb") as f:
        for chunk in iter(lambda: f.read(1024 * 256), b""):
            h.update(chunk)
    return h.hexdigest()


def offline_uuid(username):
    """离线模式 UUID: 与官方离线算法一致 (MD5 of 'OfflinePlayer:<name>')"""
    digest = hashlib.md5(("OfflinePlayer:" + username).encode("utf-8")).digest()
    b = bytearray(digest)
    b[6] = (b[6] & 0x0F) | 0x30  # version 3
    b[8] = (b[8] & 0x3F) | 0x80  # variant
    hexs = b.hex()
    return "%s-%s-%s-%s-%s" % (hexs[0:8], hexs[8:12], hexs[12:16], hexs[16:20], hexs[20:32])


# ---------------- 规则判定 (rules) ----------------
def rule_allowed(rules):
    if not rules:
        return True
    allowed = False
    for rule in rules:
        applies = True
        os_rule = rule.get("os")
        if os_rule:
            if "name" in os_rule and os_rule["name"] != OS_NAME:
                applies = False
            if "arch" in os_rule:
                want = os_rule["arch"]  # "x86" or "x64"(不存在, 只有x86标记)
                if want == "x86" and not IS_X86:
                    applies = False
        # features 规则全部不适用(离线无 demo 等)
        if "features" in rule:
            applies = False
        if applies:
            allowed = (rule.get("action") == "allow")
    return allowed


# ---------------- 核心: 版本与下载 ----------------
class MinecraftCore:
    def __init__(self, game_dir, log=print, progress=None, cancel_flag=None, source="bmclapi"):
        self.game_dir = game_dir
        self.log = log
        self.progress = progress or (lambda cur, total, text: None)
        self.cancel_flag = cancel_flag or threading.Event()
        self.source = source
        # 启动加速缓存: 避免每次启动重复读盘 / 解压 jar / 遍历库文件
        self._info_cache = {}       # version_id -> 合并后的 version info
        self._java_req_cache = {}   # version_id -> 所需 Java 主版本
        self._classpath_cache = {}  # version_id -> classpath 字符串
        ensure_dir(game_dir)
        for d in ("versions", "libraries", "assets/objects", "assets/indexes"):
            ensure_dir(os.path.join(game_dir, d))

    def url(self, u):
        return rewrite_url(u, self.source)

    # ---- 版本清单 ----
    def get_version_manifest(self):
        self.log("正在获取版本清单 (%s) ..." % ("BMCLAPI镜像" if self.source == "bmclapi" else "官方源"))
        return fetch_json(self.url(VERSION_MANIFEST_URL))

    def load_version_info(self, version_id):
        """加载版本 JSON, 优先读本地缓存, 否则联网下载; 支持 inheritsFrom 递归合并; 内存缓存加速启动"""
        if version_id in self._info_cache:
            return self._info_cache[version_id]
        vdir = os.path.join(self.game_dir, "versions", version_id)
        vjson_path = os.path.join(vdir, version_id + ".json")
        if os.path.exists(vjson_path):
            with open(vjson_path, "r", encoding="utf-8") as f:
                info = json.load(f)
        else:
            manifest = self.get_version_manifest()
            for v in manifest["versions"]:
                if v["id"] == version_id:
                    info = fetch_json(self.url(v["url"]))
                    ensure_dir(vdir)
                    with open(vjson_path, "w", encoding="utf-8") as f:
                        json.dump(info, f, ensure_ascii=False, indent=2)
                    break
            else:
                raise RuntimeError("找不到版本: " + version_id)
        # 加载器版本通过 inheritsFrom 继承原版: 合并 libraries, arguments 等
        if "inheritsFrom" in info:
            parent = self.load_version_info(info["inheritsFrom"])
            merged = dict(parent)  # 先复制父版本全部字段
            for k, v in info.items():
                if k == "libraries":
                    merged["libraries"] = parent.get("libraries", []) + (info.get("libraries") or [])
                elif k == "arguments":
                    pargs = parent.get("arguments") or {}
                    cargs = info.get("arguments") or {}
                    # 加载器版本(neoforge/forge/fabric)仅含自身专属参数, 标准 MC 参数
                    # (--username/--version/--accessToken/-Djava.library.path 等) 在父版本中,
                    # 故拼接而非替换, 否则会缺参数导致启动失败
                    merged["arguments"] = {
                        "jvm": pargs.get("jvm", []) + cargs.get("jvm", []),
                        "game": pargs.get("game", []) + cargs.get("game", []),
                    }
                else:
                    merged[k] = v  # 子版本覆盖 mainClass / downloads / assetIndex / id 等
            info = merged
        self._info_cache[version_id] = info
        return info

    def is_installed(self, version_id):
        vdir = os.path.join(self.game_dir, "versions", version_id)
        return os.path.exists(os.path.join(vdir, version_id + ".json")) and \
               os.path.exists(os.path.join(vdir, version_id + ".jar"))

    # ---- 客户端 jar ----
    def download_client(self, info):
        version_id = info["id"]
        client = info["downloads"]["client"]
        dest = os.path.join(self.game_dir, "versions", version_id, version_id + ".jar")
        expected_sha1 = client.get("sha1")

        # 已存在且校验通过则跳过
        if os.path.exists(dest):
            if expected_sha1 is None or sha1_of(dest) == expected_sha1:
                self.log("客户端 %s 已存在, 跳过下载" % version_id)
                return dest

        # 加载器版本(inheritsFrom)的 client jar 与父版本(原版)完全相同,
        # 直接复用父版本 jar, 避免重新下载(尤其 BMCLAPI 会 302 到官方源导致很慢)
        parent_id = info.get("inheritsFrom")
        if parent_id:
            parent_jar = os.path.join(self.game_dir, "versions", parent_id, parent_id + ".jar")
            if os.path.exists(parent_jar):
                if expected_sha1 is None or sha1_of(parent_jar) == expected_sha1:
                    ensure_dir(os.path.dirname(dest))
                    shutil.copyfile(parent_jar, dest)
                    self.log("复用父版本 %s 的客户端 jar (sha1 匹配)" % parent_id)
                    return dest

        self.log("下载客户端 %s ..." % version_id)
        def _cb(read, total):
            if total > 0:
                self.progress(read, total, "客户端 %s" % version_id)
        download_file(self.url(client["url"]), dest, expected_sha1,
                      cancel_flag=self.cancel_flag, progress_cb=_cb)
        return dest

    # ---- 依赖库 & natives ----
    def iter_libraries(self, info):
        """产出 (artifact_dict, dest_rel_path, is_native)"""
        for lib in info.get("libraries", []):
            if not rule_allowed(lib.get("rules")):
                continue
            downloads = lib.get("downloads")
            if not downloads:
                continue
            natives = lib.get("natives")
            if natives and OS_NAME in natives:
                clf_key = natives[OS_NAME]
                # 处理 ${arch} 占位
                clf_key = clf_key.replace("${arch}", "32" if IS_X86 else "64")
                clf = downloads.get("classifiers", {}).get(clf_key)
                if clf:
                    yield clf, "libraries/" + clf["path"], True
                continue  # natives 库不再走 artifact
            artifact = downloads.get("artifact")
            if artifact:
                yield artifact, "libraries/" + artifact["path"], False

    def download_libraries(self, info):
        tasks = list(self.iter_libraries(info))
        total = len(tasks)
        self.log("需要 %d 个依赖库文件, %d 线程并发下载 ..." % (total, DOWNLOAD_THREADS))
        counter = {"done": 0}
        lock = threading.Lock()
        natives = []
        natives_lock = threading.Lock()

        def work(task):
            artifact, rel, is_native = task
            if self.cancel_flag.is_set():
                return
            dest = os.path.join(self.game_dir, rel)
            download_file(self.url(artifact["url"]), dest, artifact.get("sha1"),
                          cancel_flag=self.cancel_flag)
            if is_native:
                with natives_lock:
                    natives.append(dest)
            with lock:
                counter["done"] += 1
                d = counter["done"]
            if d % 5 == 0 or d == total:
                self.progress(d, total, "依赖库")

        with ThreadPoolExecutor(max_workers=DOWNLOAD_THREADS) as ex:
            futures = [ex.submit(work, t) for t in tasks]
            for fu in as_completed(futures):
                if self.cancel_flag.is_set():
                    break
                fu.result()
        return natives

    def extract_natives(self, info, natives_jars):
        version_id = info["id"]
        natives_dir = os.path.join(self.game_dir, "versions", version_id, "natives")
        ensure_dir(natives_dir)
        for jar in natives_jars:
            if not os.path.exists(jar):
                continue
            try:
                with zipfile.ZipFile(jar) as zf:
                    for name in zf.namelist():
                        if name.startswith("META-INF") or name.endswith("/"):
                            continue
                        target = os.path.join(natives_dir, name)
                        ensure_dir(os.path.dirname(target))
                        with zf.open(name) as src, open(target, "wb") as dst:
                            shutil.copyfileobj(src, dst)
            except zipfile.BadZipFile:
                self.log("跳过损坏的 native 包: " + os.path.basename(jar))
        return natives_dir

    # ---- 资源文件 (assets) ----
    def download_assets(self, info):
        idx = info["assetIndex"]
        idx_rel = "assets/indexes/%s.json" % idx["id"]
        idx_path = os.path.join(self.game_dir, idx_rel)
        download_file(self.url(idx["url"]), idx_path, idx.get("sha1"), cancel_flag=self.cancel_flag)
        with open(idx_path, "r", encoding="utf-8") as f:
            index = json.load(f)
        objects = index.get("objects", {})
        items = list(objects.items())
        total = len(items)
        self.log("需要 %d 个资源文件, 多线程下载中 ..." % total)

        counter = {"done": 0}
        lock = threading.Lock()
        virtual = index.get("virtual", False)
        map_to_res = index.get("map_to_resources", False)

        def work(item):
            if self.cancel_flag.is_set():
                return
            name, meta = item
            h = meta["hash"]
            rel = "assets/objects/%s/%s" % (h[:2], h)
            dest = os.path.join(self.game_dir, rel)
            url = "%s/%s/%s" % (RESOURCE_BASE, h[:2], h)
            download_file(self.url(url), dest, h, retries=3, cancel_flag=self.cancel_flag)
            # 老版本兼容: 复制到 virtual/legacy 或 resources
            if virtual:
                vdst = os.path.join(self.game_dir, "assets/virtual/legacy", name)
                if not os.path.exists(vdst):
                    ensure_dir(os.path.dirname(vdst))
                    shutil.copyfile(dest, vdst)
            if map_to_res:
                rdst = os.path.join(self.game_dir, "resources", name)
                if not os.path.exists(rdst):
                    ensure_dir(os.path.dirname(rdst))
                    shutil.copyfile(dest, rdst)
            with lock:
                counter["done"] += 1
                if counter["done"] % 50 == 0 or counter["done"] == total:
                    self.progress(counter["done"], total, "资源文件")

        with ThreadPoolExecutor(max_workers=DOWNLOAD_THREADS) as ex:
            futures = [ex.submit(work, it) for it in items]
            for fu in as_completed(futures):
                if self.cancel_flag.is_set():
                    break
                fu.result()
        return idx["id"]

    # ---- 参数拼装 ----
    def build_classpath(self, info):
        version_id = info["id"]
        parts = []
        for artifact, rel, is_native in self.iter_libraries(info):
            if is_native:
                continue
            p = os.path.join(self.game_dir, rel)
            if os.path.exists(p):
                parts.append(p)
        parts.append(os.path.join(self.game_dir, "versions", version_id, version_id + ".jar"))
        return ";".join(parts)

    @staticmethod
    def substitute(text, repl):
        for k, v in repl.items():
            text = text.replace("${%s}" % k, v)
        return text

    def parse_args(self, arg_list, repl):
        out = []
        for arg in arg_list:
            if isinstance(arg, str):
                out.append(self.substitute(arg, repl))
            elif isinstance(arg, dict):
                if rule_allowed(arg.get("rules")):
                    val = arg["value"]
                    if isinstance(val, list):
                        out.extend(self.substitute(x, repl) for x in val)
                    else:
                        out.append(self.substitute(val, repl))
        return out

    def build_launch_command(self, info, username, java_path, max_mem_mb, java_major=None):
        version_id = info["id"]
        natives_dir = os.path.join(self.game_dir, "versions", version_id, "natives")
        libraries_dir = os.path.join(self.game_dir, "libraries")
        classpath = self.build_classpath(info)
        repl = {
            "auth_player_name": username,
            "version_name": version_id,
            "game_directory": self.game_dir,
            "assets_root": os.path.join(self.game_dir, "assets"),
            "assets_index_name": info.get("assetIndex", {}).get("id", info.get("assets", "legacy")),
            "auth_uuid": offline_uuid(username),
            "auth_access_token": "0",
            "user_type": "legacy",
            "version_type": info.get("type", "release"),
            "natives_directory": natives_dir,
            "launcher_name": APP_NAME,
            "launcher_version": APP_VERSION,
            "classpath": classpath,
            "clientid": "0",
            "auth_xuid": "0",
            # NeoForge / Fabric 新版用 library_directory 让加载器自行从库目录加载 jar
            "library_directory": libraries_dir,
        }

        jvm_args, game_args = [], []
        if "arguments" in info:  # 新版本格式
            jvm_args = self.parse_args(info["arguments"].get("jvm", []), repl)
            game_args = self.parse_args(info["arguments"].get("game", []), repl)
        else:  # 老版本格式 (<=1.12.2)
            game_args = [self.substitute(x, repl)
                         for x in info["minecraftArguments"].split(" ")]
            jvm_args = [
                "-Djava.library.path=" + natives_dir,
                "-cp", classpath,
            ]

        # 若 jvm args 中没有 -cp / -classpath (如 NeoForge 26+ 改用 library_directory),
        # 手动补上, 否则 JVM 找不到主类
        has_cp = any(a in ("-cp", "-classpath") for a in jvm_args)
        if not has_cp:
            jvm_args = ["-cp", classpath] + jvm_args

        mem = "-Xmx%dM" % max_mem_mb
        cmd = [java_path, mem, "-Xms256M",
               "-Dfile.encoding=UTF-8",
               "-Dstdout.encoding=UTF-8", "-Dstderr.encoding=UTF-8"]
        # JVM 启动加速 / GC 调优参数 (Minecraft 社区通用优化)
        cmd += [
            "-XX:+UseG1GC", "-XX:+ParallelRefProcEnabled", "-XX:MaxGCPauseMillis=200",
            "-XX:+UnlockExperimentalVMOptions", "-XX:+DisableExplicitGC",
            "-XX:G1NewSizePercent=30", "-XX:G1MaxNewSizePercent=40", "-XX:G1HeapRegionSize=8M",
            "-XX:G1ReservePercent=20", "-XX:G1HeapWastePercent=5", "-XX:G1MixedGCCountTarget=4",
            "-XX:InitiatingHeapOccupancyPercent=15", "-XX:G1MixedGCLiveThresholdPercent=90",
            "-XX:G1RSetUpdatingPauseTimePercent=5", "-XX:SurvivorRatio=32",
            "-XX:+PerfDisableSharedMem",
            # 字符串去重, 降低内存占用间接减少 GC
            "-XX:+UseStringDeduplication",
        ]
        cmd += jvm_args
        cmd.append(info["mainClass"])
        cmd += game_args
        return cmd

    # ---- 精确推断所需 Java 版本 ----
    def required_java_from_jar(self, version_id):
        """读客户端 jar 里的 class 文件版本号, 精确得到所需 Java 主版本 (52->8, 65->21, 69->25)。结果内存缓存。"""
        if version_id in self._java_req_cache:
            return self._java_req_cache[version_id]
        jar = os.path.join(self.game_dir, "versions", version_id, version_id + ".jar")
        result = None
        try:
            with zipfile.ZipFile(jar) as zf:
                with zf.open("net/minecraft/client/main/Main.class") as f:
                    data = f.read(8)
            if data[:4] == b"\xca\xfe\xba\xbe":
                result = max(8, int.from_bytes(data[6:8], "big") - 44)
        except Exception:
            pass
        self._java_req_cache[version_id] = result
        return result

    # ---- 一键安装 ----
    def install_version(self, version_id):
        info = self.load_version_info(version_id)
        self.log("开始安装 %s ..." % version_id)
        self.download_client(info)
        natives = self.download_libraries(info)
        if self.cancel_flag.is_set():
            return None
        self.extract_natives(info, natives)
        self.download_assets(info)
        self.log("安装完成: %s" % version_id)
        return info

    # ---- Mod 加载器自动安装 ----
    def install_loader(self, loader_type, loader_version, mc_version=None, java_path=None):
        """
        自动安装 Mod 加载器, 返回安装后的版本 ID。
        loader_type: neoforge / forge / fabric
        策略: 直接从安装器 jar 提取 version.json -> 用 MCOP 镜像下载依赖库 -> --offline 收尾。
              这样避免安装器从官方 Maven 逐一下载(国内常超时)。
        """
        lt = loader_type.lower().replace("-", "").replace("_", "")
        loaders_dir = os.path.join(self.game_dir, "loader_installers")
        ensure_dir(loaders_dir)

        if lt == "neoforge":
            url = ("https://maven.neoforged.net/releases/net/neoforged/neoforge/%(v)s/"
                   "neoforge-%(v)s-installer.jar") % {"v": loader_version}
            installer_name = "neoforge-%s-installer.jar" % loader_version
        elif lt == "forge":
            if not mc_version:
                raise RuntimeError("Forge 安装需要 MC 版本号, 但整合包未提供正确的 minecraft 依赖")
            url = ("https://maven.minecraftforge.net/net/minecraftforge/forge/%(mc)s-%(v)s/"
                   "forge-%(mc)s-%(v)s-installer.jar") % {"mc": mc_version, "v": loader_version}
            installer_name = "forge-%s-%s-installer.jar" % (mc_version, loader_version)
        elif lt == "fabric":
            if not mc_version:
                raise RuntimeError("Fabric 安装需要 MC 版本号, 但整合包未提供正确的 minecraft 依赖")
            meta = fetch_json("https://meta.fabricmc.net/v2/versions/installer")
            inst_ver = meta[0]["version"]
            url = ("https://maven.fabricmc.net/net/fabricmc/fabric-installer/%(v)s/"
                   "fabric-installer-%(v)s.jar") % {"v": inst_ver}
            installer_name = "fabric-installer-%s.jar" % inst_ver
        else:
            raise RuntimeError("不支持的加载器类型: " + loader_type)

        installer_path = os.path.join(loaders_dir, installer_name)
        self.log("下载 %s 安装器 ..." % loader_type)
        try:
            download_file(self.url(url), installer_path, cancel_flag=self.cancel_flag)
        except Exception:
            if self.url(url) != url:
                self.log("镜像下载失败, 尝试官方源 ...")
                download_file(url, installer_path, cancel_flag=self.cancel_flag)
            else:
                raise

        java = java_path or find_java()
        if not java or not os.path.exists(java):
            raise RuntimeError("找不到 Java, 无法运行加载器安装器")
        java_exe = java.replace("javaw.exe", "java.exe")

        # 安装器要求游戏目录存在 launcher_profiles.json
        lp = os.path.join(self.game_dir, "launcher_profiles.json")
        if not os.path.exists(lp):
            with open(lp, "w", encoding="utf-8") as f:
                json.dump({"profiles": {}, "settings": {}, "version": 3}, f, ensure_ascii=False)

        if lt in ("neoforge", "forge"):
            # 直接从安装器 jar 提取 version.json, 避免在线安装器逐个超时
            version_id = self._extract_version_json(installer_path)
            self.log("从安装器提取版本 JSON -> %s" % version_id)
            # install_profile.json 的库是 version.json 的超集, 含处理器所需的
            # installertools / universal 等, 需一并下载才能让 --offline 安装器跑通
            profile_libs = self._extract_install_profile_libs(installer_path)
            vjson = os.path.join(self.game_dir, "versions", version_id, version_id + ".json")
            with open(vjson, "r", encoding="utf-8") as f:
                raw_info = json.load(f)
            all_libs = raw_info.get("libraries", []) + profile_libs
            self.log("用 MCOP 镜像下载 %d 个依赖库 ..." % len(all_libs))
            self.download_libraries({"libraries": all_libs})
            if self.cancel_flag.is_set():
                return None
            self.log("依赖库下载完成, 离线模式运行安装器收尾 ...")
            offline_cmd = [java_exe, "-jar", installer_path, "--installClient",
                           self.game_dir, "--offline"]
            proc = subprocess.run(offline_cmd, capture_output=True, text=True,
                                  timeout=300, cwd=self.game_dir)
            out = ((proc.stdout or "") + "\n" + (proc.stderr or "")).strip()
            if proc.returncode != 0:
                # 离线收尾若失败(部分旧版安装器无 --offline), 退回在线安装器
                self.log("离线收尾失败, 退回在线安装器 (可能较慢) ...")
                cmd = [java_exe, "-jar", installer_path, "--installClient", self.game_dir]
                if self.source == "bmclapi":
                    cmd[3:3] = ["--mirror", SOURCES["bmclapi"] + "/maven"]
                proc = subprocess.run(cmd, capture_output=True, text=True,
                                      timeout=900, cwd=self.game_dir)
                out = ((proc.stdout or "") + "\n" + (proc.stderr or "")).strip()
                if proc.returncode != 0:
                    if out:
                        self.log("安装器输出:\n" + out[-2000:])
                    raise RuntimeError("%s 安装失败 (退出码 %d)" % (loader_type, proc.returncode))
        else:  # fabric
            self.log("运行 Fabric 安装器 ...")
            cmd = [java_exe, "-jar", installer_path, "client",
                   "--mcversion", mc_version, "--loader", loader_version,
                   "--dir", self.game_dir]
            proc = subprocess.run(cmd, capture_output=True, text=True, timeout=600, cwd=self.game_dir)
            out = ((proc.stdout or "") + "\n" + (proc.stderr or "")).strip()
            if proc.returncode != 0:
                if out:
                    self.log("Fabric 安装器输出:\n" + out[-2000:])
                raise RuntimeError("Fabric 安装失败 (退出码 %d)" % proc.returncode)
            version_id = self._find_loader_version_id(lt, loader_version)

        if out:
            tail = "\n".join(out.splitlines()[-6:])
            if tail:
                self.log("安装器输出: " + tail)
        self.log("%s 安装完成 -> %s" % (loader_type, version_id))
        return version_id

    def _extract_version_json(self, installer_path):
        """从 NeoForge/Forge 安装器 jar 中提取 version.json 到 versions/ 目录, 返回版本 ID"""
        with zipfile.ZipFile(installer_path) as zf:
            names = zf.namelist()
            if "version.json" not in names:
                raise RuntimeError("安装器中未找到 version.json")
            data = zf.read("version.json")
        info = json.loads(data.decode("utf-8"))
        version_id = info["id"]
        vdir = os.path.join(self.game_dir, "versions", version_id)
        ensure_dir(vdir)
        vjson = os.path.join(vdir, version_id + ".json")
        with open(vjson, "w", encoding="utf-8") as f:
            json.dump(info, f, ensure_ascii=False, indent=2)
        return version_id

    def _extract_install_profile_libs(self, installer_path):
        """从安装器 jar 中读取 install_profile.json 的 libraries 列表 (含处理器所需库)"""
        try:
            with zipfile.ZipFile(installer_path) as zf:
                if "install_profile.json" in zf.namelist():
                    ip = json.loads(zf.read("install_profile.json").decode("utf-8"))
                    return ip.get("libraries", [])
        except Exception:
            pass
        return []

    def _find_loader_version_id(self, loader_type, loader_version):
        """在 versions/ 目录中查找刚安装的加载器版本 ID"""
        versions_dir = os.path.join(self.game_dir, "versions")
        candidates = []
        for name in os.listdir(versions_dir):
            vdir = os.path.join(versions_dir, name)
            if not os.path.isdir(vdir):
                continue
            if not os.path.exists(os.path.join(vdir, name + ".json")):
                continue
            low = name.lower()
            if loader_type == "neoforge" and "neoforge" in low:
                if loader_version in name:
                    return name
                candidates.append(name)
            elif loader_type == "forge" and "forge" in low and "neoforge" not in low:
                if loader_version in name:
                    return name
                candidates.append(name)
            elif loader_type == "fabric" and "fabric" in low:
                if loader_version in name:
                    return name
                candidates.append(name)
        if candidates:
            return candidates[-1]
        raise RuntimeError("找不到已安装的 %s 版本目录" % loader_type)


# ---------------- Java 检测 ----------------
def find_java():
    """寻找 javaw.exe, 返回路径或 None"""
    # 1. PATH
    for name in ("javaw.exe", "java.exe"):
        p = shutil.which(name)
        if p:
            return p
    # 2. 常见安装目录
    roots = [os.environ.get("ProgramFiles", r"C:\Program Files"),
             os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)"),
             os.path.expandvars(r"%LOCALAPPDATA%\Programs")]
    sub = ["Java", "Eclipse Adoptium", "Eclipse Foundation", "Microsoft",
           "Zulu", "BellSoft", "Amazon Corretto", "Oracle\\Java"]
    for root in roots:
        for s in sub:
            base = os.path.join(root, s)
            if not os.path.isdir(base):
                continue
            for dirpath, _dirnames, filenames in os.walk(base):
                if "javaw.exe" in filenames and dirpath.endswith("bin"):
                    return os.path.join(dirpath, "javaw.exe")
    return None


def java_major_version(java_path):
    try:
        exe = java_path.replace("javaw.exe", "java.exe")
        out = subprocess.run([exe, "-version"], capture_output=True, text=True, timeout=10)
        text = out.stderr + out.stdout
        import re
        m = re.search(r'version "(\d+)(?:\.(\d+))?', text)
        if m:
            major = int(m.group(1))
            if major == 1:  # 1.8 格式
                return int(m.group(2))
            return major
    except Exception:
        pass
    return None


def required_java(version_id):
    """粗略判断 MC 版本需要的 Java 主版本"""
    def parse(v):
        nums = []
        for p in v.replace("_", ".").split("."):
            if p.isdigit():
                nums.append(int(p))
            else:
                break
        return tuple(nums)
    try:
        v = parse(version_id)
        if not v:                 # alpha/beta 等远古命名 (b1.x / a1.x)
            return 8
        if v[0] >= 14:            # 新的年号版本命名 (如 26.x) 至少需要 Java 25
            return 25
        if v >= (1, 20, 5):
            return 21
        if v >= (1, 18):
            return 17
        if v >= (1, 17):
            return 16
        return 8
    except Exception:
        return 8


# ---------------- Java 自动选择与下载 ----------------
ADOPTIUM_API = "https://api.adoptium.net/v3/binary/latest/{major}/ga/windows/x64/jre/hotspot/normal/eclipse"


def find_all_javas():
    """扫描系统里所有 Java, 返回 {javaw路径: 主版本号或None}"""
    paths = set()
    p = shutil.which("javaw.exe")
    if p:
        paths.add(os.path.normpath(p))
    jh = os.environ.get("JAVA_HOME")
    if jh:
        pj = os.path.join(jh, "bin", "javaw.exe")
        if os.path.exists(pj):
            paths.add(os.path.normpath(pj))
    roots = [os.environ.get("ProgramFiles", r"C:\Program Files"),
             os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)"),
             os.path.expandvars(r"%LOCALAPPDATA%\Programs")]
    sub = ["Java", "Eclipse Adoptium", "Eclipse Foundation", "Microsoft",
           "Zulu", "BellSoft", "Amazon Corretto", "Oracle\\Java"]
    walk_targets = [RUNTIME_DIR] if os.path.isdir(RUNTIME_DIR) else []
    for root in roots:
        for s in sub:
            base = os.path.join(root, s)
            if os.path.isdir(base):
                walk_targets.append(base)
    for base in walk_targets:
        for dirpath, _dirnames, filenames in os.walk(base):
            if "javaw.exe" in filenames and dirpath.endswith("bin"):
                paths.add(os.path.normpath(os.path.join(dirpath, "javaw.exe")))
    return {p: java_major_version(p) for p in sorted(paths)}


def java_suitable(major, req):
    """该 Java 主版本是否能跑需要 req 的游戏版本"""
    if major is None:
        return True  # 版本未知, 放行
    if req <= 8:
        return major in (8, 11)   # 老版本用太新的 Java 会炸
    return major >= req


def pick_java(candidates, req):
    """从 {path: major} 中挑最合适的, 没有则返回 None"""
    ok = [(p, m) for p, m in candidates.items() if java_suitable(m, req)]
    if not ok:
        return None

    def score(pm):
        _p, m = pm
        if m is None:
            return 999
        if req <= 8:
            return 0 if m == 8 else 1
        return m - req
    ok.sort(key=score)
    return ok[0][0]


def download_java(major, log=print, progress=None, cancel_flag=None):
    """从 Adoptium 自动下载 JRE 并解压, 返回 javaw.exe 路径"""
    ensure_dir(RUNTIME_DIR)
    zip_path = os.path.join(RUNTIME_DIR, "jre%d.zip" % major)
    url = ADOPTIUM_API.format(major=major)
    log("正在下载 Java %d (Adoptium Temurin JRE, 约 40-50MB) ..." % major)
    download_file(url, zip_path, expected_sha1=None,
                  progress_cb=progress, cancel_flag=cancel_flag)
    if cancel_flag and cancel_flag.is_set():
        return None
    dest = os.path.join(RUNTIME_DIR, "jre%d" % major)
    log("解压 Java %d ..." % major)
    with zipfile.ZipFile(zip_path) as zf:
        zf.extractall(dest)
    try:
        os.remove(zip_path)
    except OSError:
        pass
    for dirpath, _dirs, files in os.walk(dest):
        if "javaw.exe" in files and dirpath.endswith("bin"):
            return os.path.join(dirpath, "javaw.exe")
    raise RuntimeError("Java 解压后未找到 javaw.exe")


def req_to_download(req):
    """需要的 Java 版本 -> 实际要下载的 Adoptium 主版本"""
    if req <= 8:
        return 8
    if req == 16:
        return 17
    return req


# ---------------- Modrinth 资源站 ----------------
MODRINTH_API = "https://api.modrinth.com/v2"
# (显示名, 内部类型) —— 数据包在 Modrinth 上是 loader 而非独立项目类型, 搜索时特殊处理
MODRINTH_CATEGORIES = [("材质包", "resourcepack"), ("光影包", "shader"), ("数据包", "datapack"),
                       ("mod包", "mod"), ("整合包", "modpack")]
MODRINTH_DIRS = {"shader": "shaderpacks", "datapack": "datapacks",
                 "resourcepack": "resourcepacks", "mod": "mods", "modpack": "modpacks"}

# 个性化: 进度条可选颜色
BAR_COLORS = {"默认": None, "绿色": "#4caf50", "蓝色": "#2196f3",
              "橙色": "#ff9800", "紫色": "#9c27b0"}

# 界面配色 (借鉴 PCL + 美西螈/Modrinth 风格: 深色侧栏 + 白色卡片 + 强调色)
SIDEBAR_BG = "#262a33"        # 深色侧栏
SIDEBAR_HOVER = "#333947"     # 侧栏悬停/选中
SIDEBAR_TEXT = "#c8ccd4"      # 侧栏文字
CONTENT_BG = "#f3f4f6"        # 内容区浅灰
CARD_BG = "#ffffff"           # 卡片白
CARD_BORDER = "#e0e2e6"       # 卡片描边
DEFAULT_ACCENT = "#1bd96a"    # 默认强调色(类 Modrinth 绿)


def _shade(hex_color, factor):
    """把 #rrggbb 按比例调暗/调亮"""
    h = hex_color.lstrip("#")
    r, g, b = (int(h[i:i + 2], 16) for i in (0, 2, 4))
    r, g, b = (max(0, min(255, int(c * factor))) for c in (r, g, b))
    return "#%02x%02x%02x" % (r, g, b)


def _lerp_color(c1, c2, t):
    """两个 #rrggbb 颜色按 t(0~1) 平滑插值"""
    a = [int(c1[i:i + 2], 16) for i in (1, 3, 5)]
    b = [int(c2[i:i + 2], 16) for i in (1, 3, 5)]
    return "#%02x%02x%02x" % tuple(int(x + (y - x) * t) for x, y in zip(a, b))


# ---------------- MCOP 加载动画 (仿 uiverse popular-puma-86, 字母改为 MCOP) ----------------
LOADER_BG = "#0e0215"   # 深空紫黑


class McopLoader(tk.Canvas):
    """字母波浪 + 旋转星球 + 星星闪烁, 2 秒一循环"""

    CYCLE = 2.0

    def __init__(self, master, size=260, letters="MCOP"):
        super().__init__(master, width=size, height=size, bg=LOADER_BG,
                         highlightthickness=0, bd=0)
        self.size = size
        self._running = False
        self._t0 = time.monotonic()
        cx, cy = size / 2.0, size * 0.40
        r = size * 0.16
        k = size / 220.0

        # 星星 (7 颗, 位置与相位仿原作)
        star_cfg = [(20, 90, 1.0, 0.0), (56, 46, 1.05, 0.2), (-26, 56, 1.4, 0.4),
                    (-50, -70, 0.95, 0.7), (32, -66, 1.3, 0.35), (82, -36, 1.0, 0.9),
                    (-92, 26, 1.0, 0.95)]
        self._stars = []
        for dx, dy, sc, delay in star_cfg:
            x, y = cx + dx * k, cy + dy * k
            sr = max(1.5, 3 * sc * k * 2.2)
            it = self.create_oval(x - sr, y - sr, x + sr, y + sr,
                                  fill="#ffffff", outline="")
            self._stars.append((it, delay))

        # 星球: 外辉光 + 本体 + 内层 + 两条反向旋转的光影弧
        self.create_oval(cx - r * 1.18, cy - r * 1.18, cx + r * 1.18, cy + r * 1.18,
                         outline="#3a0d18", width=2)
        self.create_oval(cx - r, cy - r, cx + r, cy + r, fill="#4a0e14", outline="")
        self.create_oval(cx - r * 0.82, cy - r * 0.82, cx + r * 0.82, cy + r * 0.82,
                         fill="#5c1219", outline="")
        self._arc_shadow = self.create_arc(cx - r, cy - r, cx + r, cy + r,
                                           start=0, extent=130, style="arc",
                                           outline="#8c1f24", width=max(2, int(r * 0.5)))
        self._arc_light = self.create_arc(cx - r, cy - r, cx + r, cy + r,
                                          start=90, extent=110, style="arc",
                                          outline="#ff8a5f", width=max(2, int(r * 0.34)))

        # 字母 MCOP (依次错开 0.1s 的波浪)
        self._letters = []
        n = len(letters)
        spacing = size * 0.16
        x0 = cx - spacing * (n - 1) / 2.0
        self._ly = size * 0.74
        self._font_size = max(12, int(size * 0.10))
        for i, ch in enumerate(letters):
            it = self.create_text(x0 + i * spacing, self._ly, text=ch, fill=LOADER_BG,
                                  font=("Microsoft YaHei UI", self._font_size, "bold"))
            self._letters.append(it)

    def start(self):
        if not self._running:
            self._running = True
            self._t0 = time.monotonic()
            self._tick()

    def stop(self):
        self._running = False

    def _tick(self):
        if not self._running:
            return
        now = time.monotonic() - self._t0
        t = now % self.CYCLE

        # 星球光影弧旋转 (原作 90deg -> 450deg / 2s)
        ang = 90 + 360.0 * (t / self.CYCLE)
        self.itemconfigure(self._arc_light, start=ang % 360)
        self.itemconfigure(self._arc_shadow, start=(ang + 180) % 360)

        # 星星明暗闪烁 (2s 周期, 各自延迟)
        for it, delay in self._stars:
            p = ((now - delay) % self.CYCLE) / self.CYCLE
            bright = 0.2 + 0.5 * (1 - abs(p - 0.5) * 2)
            self.itemconfigure(it, fill=_lerp_color(LOADER_BG, "#ffffff", bright))

        # 字母: 0~0.2 亮起放大, 0.2~0.4 回落, 之后淡出
        for i, it in enumerate(self._letters):
            p = ((now - i * 0.1) % self.CYCLE) / self.CYCLE
            if p < 0.2:
                q = p / 0.2
                op, sc, dy = q, 1 + 0.2 * q, -3 * q
            elif p < 0.4:
                q = (p - 0.2) / 0.2
                op, sc, dy = 1 - 0.3 * q, 1.2 - 0.2 * q, -3 + 3 * q
            else:
                q = (p - 0.4) / 0.6
                op, sc, dy = max(0.0, 0.7 - 0.7 * q), 1.0, 0.0
            self.itemconfigure(it, fill=_lerp_color(LOADER_BG, "#ffffff", op),
                               font=("Microsoft YaHei UI",
                                     max(8, int(self._font_size * sc)), "bold"))
            self.coords(it, self.coords(it)[0], self._ly + dy)
        self.after(40, self._tick)


# ---------------- 纸飞机图形 (多边形绘制, 不再是字符贴图) ----------------
PLANE_TOP = [(1.0, 0.0), (-0.75, -0.62), (-0.28, -0.04)]   # 上翼
PLANE_BOT = [(1.0, 0.0), (-0.28, -0.04), (-0.75, 0.62)]    # 下翼


def _plane_coords(x, y, size, ang, pts):
    ca, sa = math.cos(ang), math.sin(ang)
    out = []
    for px, py in pts:
        out += [x + px * size * ca - py * size * sa,
                y + px * size * sa + py * size * ca]
    return out


def draw_plane(cv, x, y, size, ang, light="#ffffff", dark="#d7dde3"):
    a = cv.create_polygon(_plane_coords(x, y, size, ang, PLANE_TOP), fill=light, outline="")
    b = cv.create_polygon(_plane_coords(x, y, size, ang, PLANE_BOT), fill=dark, outline="")
    return a, b


def move_plane(cv, items, x, y, size, ang):
    cv.coords(items[0], *_plane_coords(x, y, size, ang, PLANE_TOP))
    cv.coords(items[1], *_plane_coords(x, y, size, ang, PLANE_BOT))


def _round_rect(cv, x1, y1, x2, y2, r, **kw):
    """用平滑多边形拼一个圆角矩形"""
    pts = []
    for cx, cy, a0 in ((x2 - r, y1 + r, 270), (x2 - r, y2 - r, 0),
                       (x1 + r, y2 - r, 90), (x1 + r, y1 + r, 180)):
        for i in range(5):
            a = math.radians(a0 + i * 22.5)
            pts += [cx + r * math.cos(a), cy + r * math.sin(a)]
    return cv.create_polygon(pts, smooth=True, **kw)


class CanvasLaunchButton(tk.Canvas):
    """画布版启动按钮: 圆角底 + 纸飞机图标 + 文字, 带悬停/按下/禁用反馈"""

    W, H = 230, 58

    def __init__(self, master, text, command, accent):
        super().__init__(master, width=self.W, height=self.H, bg=CARD_BG,
                         highlightthickness=0, bd=0, cursor="hand2")
        self._text = text
        self._command = command
        self._accent = accent
        self._state = "normal"
        self._hover = False
        self._press = False
        self.bind("<Enter>", self._on_enter)
        self.bind("<Leave>", self._on_leave)
        self.bind("<ButtonPress-1>", self._on_press)
        self.bind("<ButtonRelease-1>", self._on_release)
        self.redraw()

    # 兼容 set_busy/apply_theme 里的 configure(state/bg/activebackground) 调用
    def configure(self, cnf=None, **kw):
        bg = kw.pop("bg", None) or kw.pop("background", None)
        kw.pop("activebackground", None)
        st = kw.pop("state", None)
        if bg:
            self._accent = bg
        if st:
            self._state = st
            self._hover = self._press = False
        if kw:
            super().configure(cnf, **kw)
        if bg or st:
            self.redraw()
    config = configure

    def _on_enter(self, _e):
        if self._state == "normal":
            self._hover = True
            self.redraw()

    def _on_leave(self, _e):
        self._hover = self._press = False
        self.redraw()

    def _on_press(self, _e):
        if self._state == "normal":
            self._press = True
            self.redraw()

    def _on_release(self, e):
        was = self._press
        self._press = False
        if self._state == "normal":
            self.redraw()
            if was and 0 <= e.x <= self.W and 0 <= e.y <= self.H and self._command:
                self._command()

    def redraw(self):
        self.delete("all")
        ac = self._accent
        if self._state != "normal":
            ac = "#b9bec7"
        elif self._press:
            ac = _shade(ac, 0.72)
        elif self._hover:
            ac = _shade(ac, 0.86)
        dy = 2 if self._press else 0     # 按下时整体下沉 2px, 像真按钮
        _round_rect(self, 2, 2 + dy, self.W - 2, self.H - 2 + dy, 14,
                    fill=ac, outline="")
        px, py = 42, self.H // 2 + dy
        draw_plane(self, px, py, 15, math.radians(-26))
        self.create_text(px + 28, self.H / 2 + dy, anchor="w", text=self._text,
                         fill="#ffffff", font=("Microsoft YaHei UI", 13, "bold"))


# ---------------- GUI ----------------
def enable_file_drop(window, on_files):
    """给 tk 窗口开启 Windows 文件拖拽 (纯 ctypes, 无第三方依赖).
    on_files(path_list) 在窗口线程(主线程)被调用."""
    if os.name != "nt":
        return False
    try:
        user32 = ctypes.windll.user32
        shell32 = ctypes.windll.shell32
    except Exception:
        return False

    WM_DROPFILES = 0x0233
    GWL_WNDPROC = -4
    hwnd = wintypes.HWND(int(window.winfo_id()))
    WNDPROC = ctypes.WINFUNCTYPE(ctypes.c_void_p, wintypes.HWND, ctypes.c_uint,
                                 wintypes.WPARAM, wintypes.LPARAM)
    # 显式声明签名, 防止 64 位地址被截断
    user32.SetWindowLongPtrW.restype = ctypes.c_void_p
    user32.SetWindowLongPtrW.argtypes = [wintypes.HWND, ctypes.c_int, ctypes.c_void_p]
    user32.CallWindowProcW.restype = ctypes.c_void_p
    user32.CallWindowProcW.argtypes = [ctypes.c_void_p, wintypes.HWND, ctypes.c_uint,
                                       wintypes.WPARAM, wintypes.LPARAM]
    shell32.DragQueryFileW.restype = wintypes.UINT
    shell32.DragQueryFileW.argtypes = [wintypes.HANDLE, wintypes.UINT,
                                       wintypes.LPWSTR, wintypes.UINT]
    shell32.DragFinish.argtypes = [wintypes.HANDLE]
    shell32.DragAcceptFiles.argtypes = [wintypes.HWND, wintypes.BOOL]
    orig = {}

    @WNDPROC
    def wnd_proc(hwnd_, msg, wparam, lparam):
        if msg == WM_DROPFILES:
            hdrop = wintypes.HANDLE(wparam)
            count = shell32.DragQueryFileW(hdrop, -1, None, 0)
            paths = []
            for i in range(count):
                length = shell32.DragQueryFileW(hdrop, i, None, 0)
                buf = ctypes.create_unicode_buffer(length + 1)
                shell32.DragQueryFileW(hdrop, i, buf, length + 1)
                paths.append(buf.value)
            shell32.DragFinish(hdrop)
            try:
                on_files(paths)
            except Exception:
                pass
            return 0
        return user32.CallWindowProcW(orig["proc"], hwnd_, msg, wparam, lparam)

    orig["proc"] = user32.SetWindowLongPtrW(hwnd, GWL_WNDPROC, wnd_proc)
    shell32.DragAcceptFiles(hwnd, True)
    if not hasattr(window, "_drop_refs"):
        window._drop_refs = []
    window._drop_refs.append(wnd_proc)   # 防 GC
    return True



class LauncherGUI:
    def __init__(self, root):
        global DOWNLOAD_THREADS
        self.root = root
        root.title("%s 启动器 v%s (离线模式)" % (APP_NAME, APP_VERSION))
        root.geometry("920x620")
        root.minsize(860, 580)
        self.log_queue = queue.Queue()
        self.drop_queue = queue.Queue()
        self.cancel_flag = threading.Event()
        self.worker = None
        self.versions_meta = []
        self.settings = self.load_settings()
        try:
            DOWNLOAD_THREADS = max(1, min(64, int(self.settings.get("threads", DOWNLOAD_THREADS))))
        except (TypeError, ValueError):
            pass
        self.source = self.settings.get("source", "bmclapi")
        self.java_cache = self.settings.get("java_cache", {})   # {路径: 主版本号} 缓存, 免每次扫描
        self.core = MinecraftCore(GAME_DIR, log=self.log,
                                  progress=self.on_progress,
                                  cancel_flag=self.cancel_flag,
                                  source=self.source)
        self.build_ui()
        self.root.after(100, self.poll_queue)
        self.refresh_version_list()
        enable_file_drop(self.root, self._on_dropped_files)   # 支持拖入整合包

    # ---------- UI ----------
    NAV_ITEMS = [("home", "▶", "启动"), ("download", "↓", "下载"),
                 ("style", "◆", "个性化"), ("settings", "⚙", "设置")]

    def build_ui(self):
        self.apply_theme()  # 先应用保存的主题
        self.root.configure(bg=CONTENT_BG)

        body = tk.Frame(self.root, bg=CONTENT_BG)
        body.pack(fill="both", expand=True)

        self._build_sidebar(body)

        self.content = tk.Frame(body, bg=CONTENT_BG)
        self.content.pack(side="left", fill="both", expand=True)

        self.pages = {}
        for key, _g, _t in self.NAV_ITEMS:
            self.pages[key] = tk.Frame(self.content, bg=CONTENT_BG)
        self.active_page = "home"
        self.build_home_tab(self.pages["home"])
        self.build_download_tab(self.pages["download"])
        self.build_style_tab(self.pages["style"])
        self.build_settings_tab(self.pages["settings"])
        self._build_overlay()

        self.show_page("home")
        self.apply_theme()  # 控件建完再应用一次(日志字号/强调色等)

    # ---------- 全屏加载动画遮罩 ----------
    def _build_overlay(self):
        ov = tk.Frame(self.root, bg=LOADER_BG)
        self.overlay = ov
        self.loader = McopLoader(ov, size=250)
        self.loader.pack(pady=(100, 6))
        tk.Label(ov, textvariable=self.var_status, bg=LOADER_BG, fg="#cbb8d8",
                 font=("Microsoft YaHei UI", 10)).pack()
        tk.Button(ov, text="取消任务", command=self.on_cancel,
                  bg="#341240", fg="#e8ddf0", relief="flat", bd=0,
                  activebackground="#4a1c58", activeforeground="#ffffff",
                  font=("Microsoft YaHei UI", 10), padx=18, pady=5,
                  cursor="hand2").pack(pady=18)

    def _show_overlay(self, on):
        if on:
            self.overlay.place(relx=0, rely=0, relwidth=1, relheight=1)
            self.overlay.lift()
            self.loader.start()
        else:
            self.loader.stop()
            self.overlay.place_forget()

    # ---------- 纸飞机飞出动效 (仿 uiverse polite-tiger-43) ----------
    def fly_plane(self):
        """点启动时: 纸飞机从按钮起飞, 弧线加速爬升, 飞出窗口、飞出屏幕后消失"""
        try:
            x0 = self.btn_launch.winfo_rootx() + 42
            y0 = self.btn_launch.winfo_rooty() + self.btn_launch.winfo_height() // 2
        except Exception:
            return
        top = tk.Toplevel(self.root)
        top.overrideredirect(True)
        top.attributes("-topmost", True)
        # 用虚拟桌面坐标(覆盖所有显示器), 否则多屏时会算错方向
        vx, vy = top.winfo_vrootx(), top.winfo_vrooty()
        vw, vh = top.winfo_vrootwidth(), top.winfo_vrootheight()
        top.geometry("%dx%d+%d+%d" % (vw, vh, vx, vy))
        top.configure(bg="#ff00ff")
        try:
            top.attributes("-transparentcolor", "#ff00ff")  # 品红变透明且穿透点击
        except Exception:
            pass
        cv = tk.Canvas(top, bg="#ff00ff", highlightthickness=0, bd=0)
        cv.pack(fill="both", expand=True)

        # 画布坐标原点在虚拟桌面左上角, 转成相对坐标
        sx0, sy0 = x0 - vx, y0 - vy
        edge_r, edge_t = vw, 0          # 最右显示器的右缘 / 最上缘

        ac = self.accent()
        size = 26
        plane = draw_plane(cv, sx0, sy0, size, math.radians(-30), light="#ffffff",
                           dark=_shade(ac, 0.75))
        # 拖尾小圆点池 (颜色渐隐到品红 = 视觉消失)
        trail_n = 16
        trail = [cv.create_oval(0, 0, 0, 0, fill="#ff00ff", outline="")
                 for _ in range(trail_n)]
        history = []

        # 贝塞尔弧线: 从按钮朝右上爬升(和图标指向一致), 冲出最右显示器右上角
        p0 = (sx0, sy0)
        p2 = (edge_r + 220, edge_t - 220)
        p1 = (sx0 + (p2[0] - sx0) * 0.45, sy0 + (p2[1] - sy0) * 0.12)

        def bez(t):
            mt = 1 - t
            return (mt * mt * p0[0] + 2 * mt * t * p1[0] + t * t * p2[0],
                    mt * mt * p0[1] + 2 * mt * t * p1[1] + t * t * p2[1])

        t0 = time.monotonic()
        dur = 1.5

        def tick():
            t = min(1.0, (time.monotonic() - t0) / dur)
            e = t ** 2.2                       # ease-in: 起步慢、越飞越快
            x, y = bez(e)
            xa, ya = bez(max(0.0, e - 0.01))
            ang = math.atan2(y - ya, x - xa)   # 机身对准飞行方向
            # 轻微摆动, 不死板
            wob = math.sin(t * 9 * math.pi) * 5 * (1 - t)
            px, py = x - math.sin(ang) * wob, y + math.cos(ang) * wob
            move_plane(cv, plane, px, py, size, ang)
            history.append((px, py))
            if len(history) > trail_n:
                history.pop(0)
            for i, it in enumerate(trail):
                if i < len(history):
                    hx, hy = history[i]
                    k = (i + 1) / trail_n       # 越靠近飞机越亮
                    r = 1.5 + 2.5 * k
                    cv.coords(it, hx - r, hy - r, hx + r, hy + r)
                    cv.itemconfigure(it, fill=_lerp_color("#ff00ff", ac, k * 0.9))
            if t < 1.0:
                top.after(16, tick)
            else:
                top.destroy()
        tick()

    # ---------- 左侧导航栏 (PCL/美西螈风格) ----------
    def _build_sidebar(self, parent):
        sb = tk.Frame(parent, bg=SIDEBAR_BG, width=140)
        sb.pack(side="left", fill="y")
        sb.pack_propagate(False)
        self.lbl_logo = tk.Label(sb, text=APP_NAME, bg=SIDEBAR_BG, fg=self.accent(),
                                 font=("Microsoft YaHei UI", 15, "bold"))
        self.lbl_logo.pack(pady=(16, 2))
        tk.Label(sb, text="v%s" % APP_VERSION, bg=SIDEBAR_BG, fg="#7a8194",
                 font=("Microsoft YaHei UI", 8)).pack(pady=(0, 14))

        self.nav_items = {}
        for key, glyph, text in self.NAV_ITEMS:
            item = tk.Frame(sb, bg=SIDEBAR_BG, cursor="hand2")
            item.pack(fill="x", padx=8, pady=2)
            bar = tk.Frame(item, bg=SIDEBAR_BG, width=3)
            bar.pack(side="left", fill="y")
            lbl = tk.Label(item, text="%s  %s" % (glyph, text), bg=SIDEBAR_BG,
                           fg=SIDEBAR_TEXT, font=("Microsoft YaHei UI", 11),
                           anchor="w", padx=10, pady=9)
            lbl.pack(side="left", fill="x", expand=True)
            self.nav_items[key] = {"item": item, "bar": bar, "lbl": lbl}
            for w in (item, lbl):
                w.bind("<Button-1>", lambda _e, k=key: self.show_page(k))
                w.bind("<Enter>", lambda _e, k=key: self._nav_hover(k, True))
                w.bind("<Leave>", lambda _e, k=key: self._nav_hover(k, False))

        tk.Label(sb, text="离线启动器", bg=SIDEBAR_BG, fg="#5a6172",
                 font=("Microsoft YaHei UI", 8)).pack(side="bottom", pady=10)

    def accent(self):
        """当前强调色: 跟随个性化里的颜色选择, 默认 Modrinth 绿"""
        cname = self.var_bar_color.get() if hasattr(self, "var_bar_color") \
            else self.settings.get("bar_color", "默认")
        return BAR_COLORS.get(cname) or DEFAULT_ACCENT

    def _nav_hover(self, key, on):
        if key == self.active_page:
            return
        bg = SIDEBAR_HOVER if on else SIDEBAR_BG
        self.nav_items[key]["item"].configure(bg=bg)
        self.nav_items[key]["lbl"].configure(bg=bg)

    def _refresh_nav(self):
        ac = self.accent()
        if hasattr(self, "lbl_logo"):
            self.lbl_logo.configure(fg=ac)
        if hasattr(self, "btn_launch"):
            self.btn_launch.configure(bg=ac, activebackground=_shade(ac, 0.85))
        if not hasattr(self, "nav_items"):
            return
        for k, w in self.nav_items.items():
            active = (k == self.active_page)
            bg = SIDEBAR_HOVER if active else SIDEBAR_BG
            w["item"].configure(bg=bg)
            w["lbl"].configure(bg=bg, fg="#ffffff" if active else SIDEBAR_TEXT)
            w["bar"].configure(bg=ac if active else SIDEBAR_BG)

    def show_page(self, key):
        self.active_page = key
        for pg in self.pages.values():
            pg.pack_forget()
        self.pages[key].pack(fill="both", expand=True)
        self._refresh_nav()

    @staticmethod
    def card(parent):
        """白色卡片容器, 返回 (外框, 内框)"""
        outer = tk.Frame(parent, bg=CARD_BG, highlightbackground=CARD_BORDER,
                         highlightthickness=1)
        inner = tk.Frame(outer, bg=CARD_BG, padx=10, pady=8)
        inner.pack(fill="both", expand=True)
        return outer, inner

    # ---------- 主页 ----------
    def build_home_tab(self, tab):
        # ---- 卡片1: 启动区 (PCL 式大按钮) ----
        launch_card, lc = self.card(tab)
        launch_card.pack(fill="x", padx=10, pady=(10, 6))

        row1 = tk.Frame(lc, bg=CARD_BG)
        row1.pack(fill="x")
        ttk.Label(row1, text="用户名:").pack(side="left")
        self.var_user = tk.StringVar(value=self.settings.get("username", "Player"))
        ttk.Entry(row1, textvariable=self.var_user, width=14).pack(side="left", padx=(4, 14))

        ttk.Label(row1, text="版本:").pack(side="left")
        self.var_version = tk.StringVar()
        self.cmb_version = ttk.Combobox(row1, textvariable=self.var_version,
                                        width=20, state="readonly")
        self.cmb_version.pack(side="left", padx=(4, 10))
        ttk.Button(row1, text="没有版本? 去下载",
                   command=lambda: self.show_page("download")).pack(side="left")

        row2 = tk.Frame(lc, bg=CARD_BG)
        row2.pack(fill="x", pady=(12, 2))
        ac = self.accent()
        self.btn_launch = CanvasLaunchButton(row2, "启动游戏", self.on_launch, ac)
        self.btn_launch.pack(side="left")
        self.btn_cancel = ttk.Button(row2, text="取消任务", command=self.on_cancel,
                                     state="disabled")
        self.btn_cancel.pack(side="left", padx=(10, 0))

        self.progress = ttk.Progressbar(lc, mode="determinate")
        self.progress.pack(fill="x", pady=(12, 2))
        self.var_status = tk.StringVar(value="就绪")
        ttk.Label(lc, textvariable=self.var_status).pack(anchor="w")

        tk.Label(lc, text="提示: 把 .mrpack 整合包拖到窗口任意位置即可自动安装",
                 bg=CARD_BG, fg="#888888",
                 font=("Microsoft YaHei UI", 9)).pack(anchor="w", pady=(6, 0))

        # ---- 卡片2: 已下载的游戏 ----
        inst_card, ic = self.card(tab)
        inst_card.pack(fill="x", padx=10, pady=6)
        self.lf_installed = ttk.LabelFrame(ic, text="已下载的游戏 (双击选中)", padding=4)
        self.lf_installed.pack(fill="both", expand=True)
        self.lst_installed = tk.Listbox(self.lf_installed, height=4, font=("Consolas", 9),
                                        bd=0, highlightthickness=0)
        self.lst_installed.pack(side="left", fill="both", expand=True)
        sb = ttk.Scrollbar(self.lf_installed, orient="vertical", command=self.lst_installed.yview)
        sb.pack(side="left", fill="y")
        self.lst_installed.configure(yscrollcommand=sb.set)
        self.lst_installed.bind("<Double-Button-1>", self.on_installed_select)
        ttk.Button(self.lf_installed, text="删除选中",
                   command=self.on_delete_version).pack(side="left", padx=6)

        # ---- 卡片3: 运行日志 ----
        log_card, lg = self.card(tab)
        log_card.pack(fill="both", expand=True, padx=10, pady=(6, 10))
        ttk.Label(lg, text="运行日志").pack(anchor="w")
        self.txt_log = scrolledtext.ScrolledText(lg, state="disabled",
                                                 font=("Consolas", 9),
                                                 bd=0, highlightthickness=0)
        self.txt_log.pack(fill="both", expand=True, pady=(4, 0))
        self.refresh_installed_list()

    # ---------- 下载页 ----------
    def build_download_tab(self, tab):
        nb2 = ttk.Notebook(tab)
        nb2.pack(fill="both", expand=True, padx=10, pady=10)
        self.nb2 = nb2
        nb2.bind("<<NotebookTabChanged>>", self.on_resource_tab_changed)

        # ---- 子页: 下载游戏 ----
        gtab = ttk.Frame(nb2, padding=8)
        nb2.add(gtab, text=" 下载游戏 ")
        gf = ttk.Frame(gtab)
        gf.pack(fill="x", pady=(2, 6))
        ttk.Label(gf, text="版本:").pack(side="left")
        self.cmb_version2 = ttk.Combobox(gf, textvariable=self.var_version, width=20, state="readonly")
        self.cmb_version2.pack(side="left", padx=(4, 8))
        self.var_show_all = tk.BooleanVar(value=False)
        ttk.Checkbutton(gf, text="显示快照/旧版", variable=self.var_show_all,
                        command=self.fill_version_combo).pack(side="left", padx=4)
        ttk.Button(gf, text="刷新列表", command=self.refresh_version_list).pack(side="left", padx=4)
        self.btn_download = ttk.Button(gf, text="下载 / 安装", command=self.on_download)
        self.btn_download.pack(side="left", padx=8)
        ttk.Label(gtab, text="下载进度与详细日志请切换到左侧「启动」页查看",
                  foreground="#888888").pack(anchor="w", pady=(6, 0))

        # ---- 子页: 各资源分类 (Modrinth) ----
        self.mod_panels = {}
        for name, kind in MODRINTH_CATEGORIES:
            p = ttk.Frame(nb2, padding=6)
            nb2.add(p, text=" %s " % name)
            self.build_resource_panel(p, kind)

    # ---------- 资源子页 (材质包/光影包/数据包/mod包/整合包) ----------
    def build_resource_panel(self, tab, kind):
        st = {"kind": kind, "hits": [], "loaded": False}
        self.mod_panels[kind] = st

        top = ttk.Frame(tab)
        top.pack(fill="x")
        ttk.Label(top, text="关键词:").pack(side="left")
        st["var"] = tk.StringVar()
        ent = ttk.Entry(top, textvariable=st["var"], width=24)
        ent.pack(side="left", padx=(4, 8))
        ent.bind("<Return>", lambda _e, k=kind: self.on_mod_search(k))
        st["btn_search"] = ttk.Button(top, text="搜索",
                                      command=lambda k=kind: self.on_mod_search(k))
        st["btn_search"].pack(side="left")
        ttk.Button(top, text="排行榜(下载最多)",
                   command=lambda k=kind: self.on_mod_search(k, rank=True)).pack(side="left", padx=8)
        ttk.Label(top, text="来源: Modrinth", foreground="#888888").pack(side="left")

        mid = ttk.Frame(tab)
        mid.pack(fill="both", expand=True, pady=6)
        lst = tk.Listbox(mid, font=("Consolas", 9))
        lst.pack(side="left", fill="both", expand=True)
        sb = ttk.Scrollbar(mid, orient="vertical", command=lst.yview)
        sb.pack(side="left", fill="y")
        lst.configure(yscrollcommand=sb.set)
        lst.bind("<Double-Button-1>", lambda _e, k=kind: self.on_mod_detail(k))
        st["listbox"] = lst

        bot = ttk.Frame(tab)
        bot.pack(fill="x")
        st["btn_download"] = ttk.Button(bot, text="下载选中 (双击条目也可)",
                                        command=lambda k=kind: self.on_mod_download(k))
        st["btn_download"].pack(side="left")
        hint = {"mod": "提示: mod需Forge/Fabric加载器才能生效(MCOP暂不支持装加载器)",
                "shader": "提示: 光影需Iris/OptiFine才能生效",
                "datapack": "提示: 数据包要移到 game\\saves\\存档名\\datapacks 里才生效",
                "resourcepack": "提示: 材质包在 游戏内→选项→资源包 里启用",
                "modpack": "提示: .mrpack整合包可导入支持该格式的启动器"}.get(kind, "")
        ttk.Label(bot, text=hint, foreground="#a06000").pack(side="left", padx=10)

    # ---------- 个性化 ----------
    def build_style_tab(self, tab):
        card, frm = self.card(tab)
        card.pack(fill="x", padx=10, pady=10)
        style = ttk.Style(self.root)

        ttk.Label(frm, text="界面主题:").grid(row=0, column=0, sticky="w", pady=6)
        self.var_theme = tk.StringVar(value=self.settings.get("theme") or style.theme_use())
        self.cmb_theme = ttk.Combobox(frm, textvariable=self.var_theme, width=14, state="readonly")
        self.cmb_theme["values"] = sorted(style.theme_names())
        self.cmb_theme.grid(row=0, column=1, sticky="w", pady=6)
        self.cmb_theme.bind("<<ComboboxSelected>>", lambda _e: self.apply_theme())
        ttk.Label(frm, text="(选择后立即生效)", foreground="#888888").grid(row=0, column=2, sticky="w", padx=8)

        ttk.Label(frm, text="日志字号:").grid(row=1, column=0, sticky="w", pady=6)
        self.var_font_size = tk.StringVar(value=str(self.settings.get("log_font_size", 9)))
        sp = ttk.Spinbox(frm, from_=8, to=20, textvariable=self.var_font_size, width=6,
                         command=self.apply_theme)
        sp.grid(row=1, column=1, sticky="w", pady=6)
        sp.bind("<Return>", lambda _e: self.apply_theme())
        sp.bind("<FocusOut>", lambda _e: self.apply_theme())

        ttk.Label(frm, text="强调色 (进度条/侧栏/启动按钮):").grid(row=2, column=0, sticky="w", pady=6)
        self.var_bar_color = tk.StringVar(value=self.settings.get("bar_color", "默认"))
        cmb = ttk.Combobox(frm, textvariable=self.var_bar_color, width=14, state="readonly")
        cmb["values"] = list(BAR_COLORS.keys())
        cmb.grid(row=2, column=1, sticky="w", pady=6)
        cmb.bind("<<ComboboxSelected>>", lambda _e: self.apply_theme())

        ttk.Button(frm, text="保存个性化",
                   command=self.on_save_style).grid(row=3, column=1, sticky="w", pady=(14, 4))
        ttk.Label(frm, text="提示: 部分系统原生主题(如 vista/xpnative)不支持改进度条颜色, 换 clam 主题可生效",
                  foreground="#888888").grid(row=4, column=0, columnspan=3, sticky="w", pady=(12, 0))

    def on_save_style(self):
        self.apply_theme()
        self.save_settings()
        self.log("个性化设置已保存")

    def apply_theme(self):
        style = ttk.Style(self.root)
        name = self.var_theme.get() if hasattr(self, "var_theme") else self.settings.get("theme", "")
        if name and name in style.theme_names():
            try:
                style.theme_use(name)
            except Exception:
                pass
        # 卡片上的 ttk 控件背景刷成卡片白
        for s in ("TLabel", "TCheckbutton", "TLabelframe", "TLabelframe.Label"):
            try:
                style.configure(s, background=CARD_BG)
            except Exception:
                pass
        cname = self.var_bar_color.get() if hasattr(self, "var_bar_color") \
            else self.settings.get("bar_color", "默认")
        color = BAR_COLORS.get(cname)
        if color:
            try:
                style.configure("Horizontal.TProgressbar", background=color)
            except Exception:
                pass
        if hasattr(self, "txt_log"):
            try:
                size = int(self.var_font_size.get())
            except (TypeError, ValueError):
                size = 9
            self.txt_log.configure(font=("Consolas", max(8, min(20, size))))
        self._refresh_nav()

    # ---------- 设置 ----------
    def build_settings_tab(self, tab):
        card, frm = self.card(tab)
        card.pack(fill="x", padx=10, pady=10)

        ttk.Label(frm, text="内存(MB):").grid(row=0, column=0, sticky="w", pady=6)
        self.var_mem = tk.StringVar(value=str(self.settings.get("memory", 2048)))
        ttk.Entry(frm, textvariable=self.var_mem, width=8).grid(row=0, column=1, sticky="w", pady=6)

        ttk.Label(frm, text="Java:").grid(row=1, column=0, sticky="w", pady=6)
        self.var_java = tk.StringVar(value=self.settings.get("java") or find_java() or "")
        ttk.Entry(frm, textvariable=self.var_java, width=48).grid(row=1, column=1, columnspan=2,
                                                                  sticky="w", pady=6)
        ttk.Button(frm, text="浏览", command=self.browse_java).grid(row=1, column=3, padx=6)

        self.var_auto_java = tk.BooleanVar(value=self.settings.get("auto_java", True))
        ttk.Checkbutton(frm, text="自动选择Java(没有就自动下载)",
                        variable=self.var_auto_java).grid(row=2, column=1, columnspan=3,
                                                          sticky="w", pady=6)

        ttk.Label(frm, text="下载源:").grid(row=3, column=0, sticky="w", pady=6)
        self.var_source = tk.StringVar()
        self.cmb_source = ttk.Combobox(frm, textvariable=self.var_source, width=14, state="readonly")
        self.cmb_source["values"] = ["BMCLAPI镜像", "官方源"]
        self.cmb_source.current(0 if self.source == "bmclapi" else 1)
        self.cmb_source.grid(row=3, column=1, sticky="w", pady=6)
        self.cmb_source.bind("<<ComboboxSelected>>", self.on_source_change)

        ttk.Label(frm, text="下载线程数:").grid(row=4, column=0, sticky="w", pady=6)
        self.var_threads = tk.StringVar(value=str(self.settings.get("threads", 16)))
        ttk.Spinbox(frm, from_=1, to=64, textvariable=self.var_threads,
                    width=6).grid(row=4, column=1, sticky="w", pady=6)

        bf = tk.Frame(frm, bg=CARD_BG)
        bf.grid(row=5, column=0, columnspan=4, sticky="w", pady=(14, 4))
        ttk.Button(bf, text="保存设置", command=self.on_save_settings).pack(side="left")
        ttk.Button(bf, text="打开游戏目录",
                   command=lambda: os.startfile(GAME_DIR)).pack(side="left", padx=8)
        ttk.Button(bf, text="重新扫描Java", command=self.on_rescan_java).pack(side="left")

        ttk.Label(frm, text="设置在下载/启动时也会自动保存; 内存建议 2048-4096MB",
                  foreground="#888888").grid(row=6, column=0, columnspan=4,
                                             sticky="w", pady=(6, 0))

    def on_save_settings(self):
        global DOWNLOAD_THREADS
        try:
            DOWNLOAD_THREADS = max(1, min(64, int(self.var_threads.get())))
        except (TypeError, ValueError):
            pass
        self.save_settings()
        self.log("设置已保存")

    def on_rescan_java(self):
        self.log("正在扫描系统中的 Java ...")

        def work():
            try:
                self.java_cache = find_all_javas()
                self.save_java_cache()
                self.log("扫描完成, 共找到 %d 个 Java" % len(self.java_cache))
            except Exception as e:
                self.log("扫描失败: %s" % e)
        threading.Thread(target=work, daemon=True).start()

    # ---------- Modrinth 资源搜索/下载 ----------
    def on_resource_tab_changed(self, _event=None):
        """切换到资源标签页时, 若尚未加载则自动拉取该分类的热门排行榜"""
        if not hasattr(self, "nb2"):
            return
        tab_id = self.nb2.select()
        if not tab_id:
            return
        text = self.nb2.tab(tab_id, "text").strip()
        kind = next((k for n, k in MODRINTH_CATEGORIES if n == text), None)
        if kind is None:
            return  # "下载游戏" 等非资源标签, 跳过
        st = self.mod_panels.get(kind)
        if st and not st["loaded"]:
            self.on_mod_search(kind, rank=True)

    def on_mod_search(self, kind, rank=False):
        st = self.mod_panels[kind]
        q = st["var"].get().strip()
        st["btn_search"].configure(state="disabled")
        st["listbox"].delete(0, "end")
        st["listbox"].insert("end", "正在加载排行榜 ..." if rank
                             else "正在搜索 %s ..." % (q or "(热门)"))
        st["hits"] = []

        def work():
            try:
                if kind == "datapack":
                    facets = [["loaders:datapack"]]
                else:
                    facets = [["project_type:%s" % kind]]
                params = {"query": "" if rank else q, "limit": 30,
                          "facets": json.dumps(facets)}
                if rank:
                    params["index"] = "downloads"   # 按下载量排序 = 排行榜
                data = fetch_json(MODRINTH_API + "/search?" + urllib.parse.urlencode(params))
                hits = data.get("hits", [])
                self.root.after(0, lambda: self.fill_mod_results(kind, hits, rank))
            except Exception as e:
                self.log("资源搜索失败: %s" % e)
                self.root.after(0, lambda: self.on_mod_search_failed(kind))
        threading.Thread(target=work, daemon=True).start()

    def on_mod_search_failed(self, kind):
        st = self.mod_panels[kind]
        st["btn_search"].configure(state="normal")
        st["listbox"].delete(0, "end")
        st["listbox"].insert("end", "搜索失败, 详见主页日志")

    def fill_mod_results(self, kind, hits, rank=False):
        st = self.mod_panels[kind]
        st["btn_search"].configure(state="normal")
        st["hits"] = hits
        st["loaded"] = True
        lst = st["listbox"]
        lst.delete(0, "end")
        if not hits:
            lst.insert("end", "没有找到相关资源")
            return
        for i, h in enumerate(hits):
            prefix = "%2d. " % (i + 1) if rank else ""
            lst.insert("end", "%s%s  |  作者 %s  |  下载量 %s" % (
                prefix, h.get("title", "?"), h.get("author") or "?",
                self.fmt_count(h.get("downloads", 0))))
        cname = next((n for n, k in MODRINTH_CATEGORIES if k == kind), kind)
        if rank:
            self.log("%s 排行榜已更新 (按下载量排序, 前 %d 名)" % (cname, len(hits)))
        else:
            self.log("在 Modrinth 找到 %d 个%s资源" % (len(hits), cname))

    @staticmethod
    def fmt_count(n):
        try:
            n = int(n)
        except (TypeError, ValueError):
            return str(n)
        return "%.1f万" % (n / 10000.0) if n >= 10000 else str(n)

    def on_mod_detail(self, kind):
        """双击列表项: 打开资源详情页 (封面/介绍/截图), 再由用户点下载"""
        st = self.mod_panels[kind]
        sel = st["listbox"].curselection()
        if not sel or not st["hits"] or sel[0] >= len(st["hits"]):
            return
        self._open_mod_detail(st["hits"][sel[0]], kind)

    @staticmethod
    def _strip_markdown(text):
        """把 Markdown 粗清洗成纯文本 (仅用于简介展示)"""
        if not text:
            return ""
        text = re.sub(r'!\[([^\]]*)\]\([^)]*\)', '', text)          # 图片 -> 空
        text = re.sub(r'\[([^\]]*)\]\([^)]*\)', r'\1', text)         # 链接 -> 文字
        text = text.replace('`', '')
        text = re.sub(r'\*\*([^*]+)\*\*', r'\1', text)
        text = re.sub(r'__([^_]+)__', r'\1', text)
        text = re.sub(r'(?<!\*)\*([^*]+)\*(?!\*)', r'\1', text)
        text = re.sub(r'^#{1,6}\s*', '', text, flags=re.M)
        text = re.sub(r'^\s*>\s?', '', text, flags=re.M)
        text = re.sub(r'^[-*_]{3,}\s*$', '', text, flags=re.M)
        text = re.sub(r'^\s*[-*+]\s+', '• ', text, flags=re.M)
        return text.strip()

    @staticmethod
    def _is_png(path):
        try:
            with open(path, "rb") as f:
                return f.read(8) == b"\x89PNG\r\n\x1a\n"
        except Exception:
            return False

    @staticmethod
    def _convert_to_png(src, dst):
        """用 Windows 内置 WIC (.NET) 把 webp/jpg 等图片转成 PNG, 供 tkinter 加载。
        返回是否成功。Win10 1809+ 内置 webp 解码器。"""
        ps = (
            "Add-Type -AssemblyName PresentationCore; "
            "$f=[System.Windows.Media.Imaging.BitmapFrame]::Create([System.Uri]::new('file:///%s')); "
            "$e=New-Object System.Windows.Media.Imaging.PngBitmapEncoder; "
            "$e.Frames.Add($f); "
            "$s=[System.IO.File]::OpenWrite('%s'); $e.Save($s); $s.Close()"
        ) % (src.replace("'", "''"), dst.replace("'", "''"))
        try:
            subprocess.run(["powershell.exe", "-NoProfile", "-Command", ps],
                           capture_output=True, timeout=20)
            return os.path.exists(dst) and os.path.getsize(dst) > 0
        except Exception:
            return False

    def _load_image(self, url, name):
        """下载图片并确保是 PNG (非 PNG 用系统 WIC 转换), 返回 PNG 文件路径或 None。
        注意: 本方法在后台线程运行, 只做下载/转码, PhotoImage 须在主线程创建。"""
        p = os.path.join(tempfile.gettempdir(), "mcop_%s" % name)
        try:
            download_file(url, p)
        except Exception:
            return None
        if self._is_png(p):
            return p
        # webp / jpg 等 -> 转 png
        pngp = p + ".png"
        if self._convert_to_png(p, pngp):
            return pngp
        return None

    def _open_mod_detail(self, hit, kind):
        title = hit.get("title", "?")
        win = tk.Toplevel(self.root)
        win.title("%s - %s" % (title, APP_NAME))
        win.geometry("700x640")
        win.transient(self.root)
        win._images = []   # 防止 PhotoImage 被 GC

        # ---- 顶部: 封面 + 标题信息 ----
        head = ttk.Frame(win, padding=10)
        head.pack(fill="x")
        lbl_icon = ttk.Label(head, text="加载封面...", width=8, anchor="center",
                             relief="solid", borderwidth=1)
        lbl_icon.pack(side="left", padx=(0, 12))
        win._lbl_icon = lbl_icon
        info = ttk.Frame(head)
        info.pack(side="left", fill="x", expand=True)
        ttk.Label(info, text=title, font=("Microsoft YaHei", 14, "bold")).pack(anchor="w")
        sub = "作者: %s   |   下载量: %s   |   关注: %s" % (
            hit.get("author") or "?", self.fmt_count(hit.get("downloads", 0)),
            self.fmt_count(hit.get("follows", 0)))
        ttk.Label(info, text=sub, foreground="#666").pack(anchor="w", pady=(2, 4))
        cats = hit.get("display_categories") or hit.get("categories") or []
        if cats:
            ttk.Label(info, text="分类: " + "  ".join(cats), foreground="#1976d2").pack(anchor="w")

        # ---- 简介 ----
        desc = hit.get("description") or ""
        if desc:
            ttk.Label(win, text="简介", font=("Microsoft YaHei", 10, "bold")).pack(anchor="w", padx=10)
            ttk.Label(win, text=desc, wraplength=660, justify="left").pack(
                anchor="w", padx=10, pady=(0, 6))

        # ---- 长介绍 (body) ----
        ttk.Label(win, text="详细介绍", font=("Microsoft YaHei", 10, "bold")).pack(anchor="w", padx=10)
        body_txt = scrolledtext.ScrolledText(win, height=9, wrap="word",
                                             font=("Microsoft YaHei", 9))
        body_txt.pack(fill="both", expand=True, padx=10, pady=(0, 6))
        body_txt.insert("end", "正在加载详细介绍 ...")
        body_txt.configure(state="disabled")
        win._body_txt = body_txt

        # ---- 截图区 ----
        ttk.Label(win, text="截图", font=("Microsoft YaHei", 10, "bold")).pack(anchor="w", padx=10)
        gal_frame = ttk.Frame(win)
        gal_frame.pack(fill="x", padx=10, pady=(0, 6))
        ttk.Label(gal_frame, text="(加载中...)", foreground="#888").pack(anchor="w")
        win._gal_frame = gal_frame

        # ---- 底部按钮 ----
        bot = ttk.Frame(win, padding=8)
        bot.pack(fill="x", side="bottom")
        ttk.Button(bot, text="下载此资源",
                   command=lambda: (self._download_mod(hit, kind), win.destroy())).pack(side="right")
        ttk.Button(bot, text="关闭", command=win.destroy).pack(side="right", padx=6)
        ttk.Label(bot, text="双击列表打开详情页, 点「下载此资源」才会真正下载",
                  foreground="#888").pack(side="left")

        # ---- 后台拉取详情 (body + gallery + 图片) ----
        def work():
            try:
                proj = fetch_json("%s/project/%s" % (MODRINTH_API, hit["project_id"]))
                body = self._strip_markdown(proj.get("body") or "")
                gallery = proj.get("gallery") or []
                icon_url = proj.get("icon_url") or hit.get("icon_url")
                icon_img = self._load_image(icon_url, "icon_%s" % hit["project_id"]) if icon_url else None
                shots = []
                for i, g in enumerate(gallery[:4]):
                    url = g.get("url")
                    if not url:
                        continue
                    img = self._load_image(url, "shot_%s_%d" % (hit["project_id"], i))
                    if img is not None:
                        shots.append((img, g.get("title", "")))
                self.root.after(0, lambda: self._fill_mod_detail(win, body, icon_img, shots))
            except Exception as e:
                self.root.after(0, lambda: self._fill_mod_detail(
                    win, "加载详情失败: %s" % e, None, []))
        threading.Thread(target=work, daemon=True).start()

    def _fill_mod_detail(self, win, body, icon_path, shots):
        """主线程回填: 把图片路径转成 PhotoImage 并显示"""
        if not win.winfo_exists():
            return
        # 封面
        if icon_path:
            try:
                icon_img = tk.PhotoImage(file=icon_path)
                sw, sh = max(1, icon_img.width() // 64), max(1, icon_img.height() // 64)
                icon_img = icon_img.subsample(sw, sh)
                win._images.append(icon_img)
                win._lbl_icon.configure(image=icon_img, text="")
            except Exception:
                win._lbl_icon.configure(text="无封面")
        else:
            win._lbl_icon.configure(text="无封面")
        # 长介绍
        win._body_txt.configure(state="normal")
        win._body_txt.delete("1.0", "end")
        win._body_txt.insert("end", body or "(无介绍)")
        win._body_txt.configure(state="disabled")
        # 截图
        for c in win._gal_frame.winfo_children():
            c.destroy()
        if not shots:
            ttk.Label(win._gal_frame, text="(无截图)", foreground="#888").pack(anchor="w")
        else:
            row = ttk.Frame(win._gal_frame)
            row.pack(fill="x")
            for i, (spath, cap) in enumerate(shots):
                cell = ttk.Frame(row)
                cell.grid(row=0, column=i, padx=4)
                try:
                    img = tk.PhotoImage(file=spath)
                    sw = max(1, img.width() // 150)
                    sh = max(1, img.height() // 150)
                    img = img.subsample(sw, sh)
                    win._images.append(img)
                    ttk.Label(cell, image=img).pack()
                    if cap:
                        ttk.Label(cell, text=cap, foreground="#666", wraplength=150).pack()
                except Exception:
                    ttk.Label(cell, text="[图片加载失败]", foreground="#888").pack()

    def on_mod_download(self, kind):
        st = self.mod_panels[kind]
        sel = st["listbox"].curselection()
        if not sel or not st["hits"] or sel[0] >= len(st["hits"]):
            messagebox.showinfo(APP_NAME, "请先在搜索结果里选中一个资源")
            return
        self._download_mod(st["hits"][sel[0]], kind)

    def _download_mod(self, hit, kind):
        """下载一个 Modrinth 资源 (供列表选中 / 详情页按钮共用)"""
        title = hit.get("title", "?")
        self.cancel_flag.clear()
        self.set_busy(True)

        def work():
            try:
                self.log("正在获取 %s 的最新版本 ..." % title)
                versions = fetch_json("%s/project/%s/version" % (MODRINTH_API, hit["project_id"]))
                fmeta = None
                for v in versions:
                    files = v.get("files") or []
                    for f in files:
                        if f.get("primary"):
                            fmeta = f
                            break
                    if fmeta is None and files:
                        fmeta = files[0]
                    if fmeta:
                        break
                if not fmeta:
                    raise RuntimeError("该项目没有可下载的文件")
                subdir = MODRINTH_DIRS.get(kind, "downloads")
                dest = os.path.join(GAME_DIR, subdir, fmeta["filename"])
                self.log("下载 %s ..." % fmeta["filename"])
                ok = download_file(fmeta["url"], dest,
                                   progress_cb=lambda r, t: self.on_progress(r, t, title),
                                   cancel_flag=self.cancel_flag)
                if ok:
                    self.log("下载完成: %s  →  game\\%s\\" % (title, subdir))
                    if kind == "mod":
                        self.log("提示: mod 需要 Forge/Fabric 加载器才能生效(MCOP 暂未支持加载器安装)")
                    elif kind == "datapack":
                        self.log("提示: 数据包要移到 game\\saves\\<存档名>\\datapacks 里才会生效")
                    elif kind == "shader":
                        self.log("提示: 光影需要安装 Iris 或 OptiFine 才能生效")
                    elif kind == "modpack":
                        self.log("提示: .mrpack 整合包可导入支持该格式的启动器")
                else:
                    self.log("已取消下载")
            except Exception as e:
                self.log("资源下载失败: %s" % e)
            finally:
                self.log_queue.put(("done", None))
        self.worker = threading.Thread(target=work, daemon=True)
        self.worker.start()

    # ---------- 已下载列表 ----------
    def refresh_installed_list(self):
        vroot = os.path.join(GAME_DIR, "versions")
        items = []
        if os.path.isdir(vroot):
            for name in sorted(os.listdir(vroot)):
                vdir = os.path.join(vroot, name)
                if os.path.exists(os.path.join(vdir, name + ".json")) and \
                   os.path.exists(os.path.join(vdir, name + ".jar")):
                    items.append(name)
        self.lst_installed.delete(0, "end")
        for it in items:
            self.lst_installed.insert("end", it)
        self.lf_installed.configure(text="已下载的游戏 (双击选中, 共 %d 个)" % len(items))

    def on_installed_select(self, _evt=None):
        sel = self.lst_installed.curselection()
        if sel:
            self.var_version.set(self.lst_installed.get(sel[0]))

    # ---------- 整合包安装清单 ----------
    @staticmethod
    def _manifest_path(version_id):
        safe = re.sub(r'[\\/:*?"<>|]', "_", version_id)
        return os.path.join(MANIFEST_DIR, safe + ".json")

    def _write_pack_manifest(self, version_id, files):
        """记录整合包写入游戏目录的文件, 供删除版本时清理"""
        if not version_id or not files:
            return
        ensure_dir(MANIFEST_DIR)
        data = {"version": version_id,
                "created": time.strftime("%Y-%m-%d %H:%M:%S"),
                "files": sorted(set(f for f in files if f))}
        try:
            with open(self._manifest_path(version_id), "w", encoding="utf-8") as f:
                json.dump(data, f, ensure_ascii=False, indent=2)
            self.log("已登记整合包文件清单: %d 个 (删除该版本时会一并清理)"
                     % len(data["files"]))
        except OSError as e:
            self.log("写入安装清单失败: %s" % e)

    def _read_pack_manifest(self, version_id):
        p = self._manifest_path(version_id)
        if not os.path.exists(p):
            return []
        try:
            with open(p, "r", encoding="utf-8") as f:
                return json.load(f).get("files") or []
        except (OSError, ValueError):
            return []

    def _delete_pack_files(self, version_id):
        """按清单删除整合包写入的文件, 返回 (已删, 失败)。存档与越界路径一律跳过。"""
        files = self._read_pack_manifest(version_id)
        if not files:
            return 0, 0
        root = os.path.normcase(os.path.abspath(GAME_DIR)) + os.sep
        removed = failed = 0
        touched_dirs = set()
        for rel in files:
            rel_path = rel.replace("/", os.sep)
            if rel_path.split(os.sep)[0].lower() == "saves":
                continue                      # 保护存档
            target = os.path.abspath(os.path.join(GAME_DIR, rel_path))
            if not os.path.normcase(target).startswith(root):
                continue                      # 越界保护
            if not os.path.isfile(target):
                continue
            try:
                os.remove(target)
                removed += 1
                touched_dirs.add(os.path.dirname(target))
            except OSError as e:
                failed += 1
                self.log("删除失败 %s: %s" % (rel, e))
        # 自底向上清理变空的目录 (非空会抛错, 自动跳过)
        for d in sorted(touched_dirs, key=len, reverse=True):
            cur = d
            while os.path.normcase(cur).startswith(root):
                try:
                    os.rmdir(cur)
                except OSError:
                    break
                cur = os.path.dirname(cur)
        # 清单本身也删掉
        try:
            os.remove(self._manifest_path(version_id))
        except OSError:
            pass
        return removed, failed

    def on_delete_version(self):
        sel = self.lst_installed.curselection()
        if not sel:
            messagebox.showinfo(APP_NAME, "请先在列表里选中一个版本")
            return
        v = self.lst_installed.get(sel[0])
        pack_files = self._read_pack_manifest(v)
        msg = "确定删除 %s ?\n\n" % v
        if pack_files:
            msg += "同时会清理该整合包安装的 %d 个文件 (mods/配置等)。\n" % len(pack_files)
        else:
            msg += "该版本没有整合包安装记录, 只删除版本文件。\n"
        msg += "存档 (saves) 不会被删除。"
        if not messagebox.askyesno(APP_NAME, msg):
            return

        vdir = os.path.join(GAME_DIR, "versions", v)
        if os.path.exists(vdir):
            try:
                shutil.rmtree(vdir)
            except OSError as e:
                self.log("删除版本目录失败: %s" % e)
                messagebox.showerror(APP_NAME, "删除版本目录失败(文件可能被占用):\n%s" % e)
                return
        self.log("已删除版本: " + v)

        if pack_files:
            removed, failed = self._delete_pack_files(v)
            self.log("已清理整合包文件: %d 个" % removed)
            if failed:
                self.log("有 %d 个文件删除失败(可能被游戏占用), 请关闭游戏后重试" % failed)
        else:
            self.log("(无安装清单: 该版本是旧版本安装或手动放入, 未清理 mods/配置)")
        self.refresh_installed_list()

    def browse_java(self):
        p = filedialog.askopenfilename(title="选择 javaw.exe",
                                       filetypes=[("javaw", "javaw.exe"), ("java", "java.exe")])
        if p:
            self.var_java.set(p)

    def on_source_change(self, _evt=None):
        self.source = "bmclapi" if self.cmb_source.current() == 0 else "official"
        self.core.source = self.source
        self.save_settings()
        self.log("下载源已切换为: " + ("BMCLAPI镜像" if self.source == "bmclapi" else "官方源"))

    # ---------- 日志/进度线程安全 ----------
    def log(self, msg):
        self.log_queue.put(("log", msg))

    def on_progress(self, cur, total, text):
        self.log_queue.put(("progress", (cur, total, text)))

    # ---------- 拖入整合包 ----------
    def _on_dropped_files(self, paths):
        self.drop_queue.put(paths)

    def _handle_dropped_files(self, paths):
        packs = [p for p in paths
                 if p.lower().endswith(".mrpack") or p.lower().endswith(".zip")]
        if not packs:
            self.log("未识别到整合包文件, 支持 .mrpack (Modrinth) 格式")
            return
        if self.worker and self.worker.is_alive():
            self.log("当前有任务在运行, 稍后再试")
            return
        p = packs[0]
        self.log("拖入整合包: " + os.path.basename(p))
        self.install_modpack(p)

    def install_modpack(self, pack_path):
        """安装 .mrpack (Modrinth) 整合包: 下载 mods/资源包 + 覆盖 overrides"""
        if self.worker and self.worker.is_alive():
            return
        self.cancel_flag.clear()
        self.set_busy(True)

        def work():
            try:
                with zipfile.ZipFile(pack_path) as z:
                    names = z.namelist()
                    if "modrinth.index.json" not in names:
                        self.log("该文件不是 Modrinth (.mrpack) 格式, MCOP 暂不支持")
                        self.log("(CurseForge 整合包需 API key, 暂未集成)")
                        return
                    index = json.loads(z.read("modrinth.index.json"))

                # depends / dependencies 两种字段名都兼容
                deps = index.get("depends") or index.get("dependencies") or {}
                mc_ver = deps.get("minecraft")
                loaders = {k: v for k, v in deps.items() if k != "minecraft"}
                self.log("整合包: %s" % index.get("name", os.path.basename(pack_path)))
                self.log("依赖: MC %s, 加载器 %s" %
                         (mc_ver or "?", loaders or "无"))

                files = index.get("files") or []
                written = []          # 记录整合包往游戏目录写了哪些文件(相对路径)
                total = len(files)
                if total > 0:
                    self.log("共 %d 个文件需从 Modrinth 下载 ..." % total)

                    def dl_one(f):
                        url = (f.get("downloads") or [None])[0]
                        if not url:
                            return False
                        dest = os.path.join(GAME_DIR, f["path"])
                        sha1 = (f.get("hashes") or {}).get("sha1")
                        ok = download_file(url, dest, sha1,
                                           cancel_flag=self.cancel_flag)
                        if ok:
                            written.append(f["path"])
                        return ok

                    ok_count = 0
                    from concurrent.futures import ThreadPoolExecutor, as_completed
                    with ThreadPoolExecutor(max_workers=DOWNLOAD_THREADS) as ex:
                        futs = {ex.submit(dl_one, f): f["path"] for f in files}
                        for i, fut in enumerate(as_completed(futs), 1):
                            if self.cancel_flag.is_set():
                                break
                            try:
                                if fut.result():
                                    ok_count += 1
                            except Exception as e:
                                self.log("失败 %s: %s" % (futs[fut], e))
                            self.on_progress(i, total, "整合包文件")
                else:
                    ok_count = 0
                    self.log("(该整合包把所有内容直接打包在 overrides 里, 无需联网下载)")

                if self.cancel_flag.is_set():
                    self.log("已取消安装")
                    return

                # 统计并复制 overrides / client-overrides 到游戏目录
                override_count = 0
                mod_count = 0
                self.log("复制整合包内置文件 ...")
                with zipfile.ZipFile(pack_path) as z:
                    for member in z.namelist():
                        if not (member.startswith("overrides/") or
                                member.startswith("client-overrides/")):
                            continue
                        rel = member.split("/", 1)[1]
                        if not rel or member.endswith("/"):
                            continue
                        target = os.path.join(GAME_DIR, rel)
                        ensure_dir(os.path.dirname(target))
                        with z.open(member) as src, open(target, "wb") as dst:
                            shutil.copyfileobj(src, dst)
                        written.append(rel)
                        override_count += 1
                        if rel.startswith("mods/") and rel.endswith(".jar"):
                            mod_count += 1

                self.log("整合包内容复制完成: 下载 %d/%d, 内置文件 %d 个 (含 %d 个 mod)" %
                         (ok_count, total, override_count, mod_count))

                # 自动安装 Mod 加载器 (NeoForge / Forge / Fabric)
                if loaders:
                    self._auto_install_loaders(loaders, mc_ver, written)
                elif mc_ver:
                    # 无加载器时清单记在原版版本名下, 删除该版本时一并清理
                    self._write_pack_manifest(mc_ver, written)
                    self.log("提示: 该整合包无加载器, 请在主页选择 MC %s 后启动" % mc_ver)
            except Exception as e:
                self.log("整合包安装失败: %s" % e)
            finally:
                self.log_queue.put(("done", None))

        self.worker = threading.Thread(target=work, daemon=True)
        self.worker.start()

    # ---------- 加载器自动安装 ----------
    def _ensure_java_for_loader(self, req):
        """确保有能运行加载器安装器的 Java, 返回路径或 None"""
        cands = {p: m for p, m in self.java_cache.items() if os.path.exists(p)}
        if not cands:
            self.log("扫描系统中的 Java ...")
            cands = find_all_javas()
            self.java_cache = cands
            self.save_java_cache()
        java = pick_java(cands, req)
        if java is None:
            target = req_to_download(req)
            self.log("没有 Java %d+, 自动下载 Java %d ..." % (req, target))
            java = download_java(target, log=self.log,
                                 progress=lambda r, t: self.on_progress(r, t, "Java %d 下载" % target),
                                 cancel_flag=self.cancel_flag)
            if java:
                self.java_cache[java] = java_major_version(java)
                self.save_java_cache()
        return java

    def _auto_install_loaders(self, loaders, mc_ver, pack_files=None):
        """逐个安装整合包声明的加载器, 并安装对应游戏版本, 最后自动选中"""
        self.log("=" * 10 + " 自动安装加载器 " + "=" * 10)
        for ltype, lver in loaders.items():
            try:
                # 推断安装器需要的 Java 版本
                lt = ltype.lower().replace("-", "").replace("_", "")
                if lt == "neoforge":
                    java_req = 21
                elif mc_ver:
                    java_req = required_java(mc_ver)
                else:
                    java_req = 21
                java = self._ensure_java_for_loader(java_req)
                if not java:
                    self.log("未能获取可用 Java, 跳过 %s" % ltype)
                    continue

                modded_id = self.core.install_loader(ltype, lver, mc_ver, java)
                self.log("加载器 %s %s 安装成功 -> %s" % (ltype, lver, modded_id))

                # 安装该 modded 版本的完整内容 (客户端 jar / 依赖库 / 资源 / natives)
                # load_version_info 会自动合并 inheritsFrom 的原版内容
                self.core.install_version(modded_id)

                # 把整合包写入的文件登记到该版本名下, 供删除版本时清理
                if pack_files:
                    self._write_pack_manifest(modded_id, pack_files)
                if self.cancel_flag.is_set():
                    self.log("已取消")
                    return

                # 自动选中该版本 (切回主线程更新 UI)
                self.root.after(0, lambda mid=modded_id: self._select_installed_version(mid))
            except Exception as e:
                self.log("加载器 %s 安装失败: %s" % (ltype, e))
        self.log("=" * 10 + " 加载器安装流程结束 " + "=" * 10)

    def _select_installed_version(self, vid):
        """刷新版本下拉框并选中刚安装的加载器版本"""
        self.fill_version_combo()
        vals = list(self.cmb_version["values"])
        if vid not in vals:
            vals.insert(0, vid)
            self.cmb_version["values"] = vals
            self.cmb_version2["values"] = vals
        self.var_version.set(vid)
        self.log("已自动选中版本: %s" % vid)

    def poll_queue(self):
        try:
            while True:
                paths = self.drop_queue.get_nowait()
                self._handle_dropped_files(paths)
        except queue.Empty:
            pass
        try:
            while True:
                kind, payload = self.log_queue.get_nowait()
                if kind == "log":
                    self.txt_log.configure(state="normal")
                    self.txt_log.insert("end", payload + "\n")
                    # 最多保留 5000 行, 防止游戏日志撑爆内存
                    if int(self.txt_log.index("end-1c").split(".")[0]) > 5000:
                        self.txt_log.delete("1.0", "2000.0")
                    self.txt_log.see("end")
                    self.txt_log.configure(state="disabled")
                elif kind == "progress":
                    cur, total, text = payload
                    self.progress["maximum"] = total
                    self.progress["value"] = cur
                    self.var_status.set("%s: %d/%d" % (text, cur, total))
                elif kind == "done":
                    self.set_busy(False)
                    self.refresh_installed_list()
        except queue.Empty:
            pass
        self.root.after(100, self.poll_queue)

    def set_busy(self, busy):
        state = "disabled" if busy else "normal"
        self.btn_download.configure(state=state)
        self.btn_launch.configure(state=state)
        for st in self.mod_panels.values():
            st["btn_download"].configure(state=state)
            st["btn_search"].configure(state=state)
        self.btn_cancel.configure(state="normal" if busy else "disabled")
        self._show_overlay(busy)
        if not busy:
            self.var_status.set("就绪")

    # ---------- 设置 ----------
    def load_settings(self):
        try:
            with open(SETTINGS_FILE, "r", encoding="utf-8") as f:
                return json.load(f)
        except Exception:
            return {}

    def save_settings(self):
        ensure_dir(GAME_DIR)
        data = {"username": self.var_user.get().strip() or "Player",
                "memory": self.var_mem.get(),
                "java": self.var_java.get(),
                "auto_java": self.var_auto_java.get(),
                "source": self.source,
                "java_cache": self.java_cache,
                "last_version": self.var_version.get(),
                "threads": self.var_threads.get(),
                "theme": self.var_theme.get(),
                "log_font_size": self.var_font_size.get(),
                "bar_color": self.var_bar_color.get()}
        try:
            with open(SETTINGS_FILE, "w", encoding="utf-8") as f:
                json.dump(data, f, ensure_ascii=False, indent=2)
        except Exception:
            pass

    def save_java_cache(self):
        """只更新 java_cache, 不碰界面控件, 可在后台线程安全调用"""
        try:
            data = self.load_settings()
            data["java_cache"] = self.java_cache
            ensure_dir(GAME_DIR)
            with open(SETTINGS_FILE, "w", encoding="utf-8") as f:
                json.dump(data, f, ensure_ascii=False, indent=2)
        except Exception:
            pass

    # ---------- 版本列表 ----------
    def refresh_version_list(self):
        def work():
            try:
                manifest = fetch_json(self.core.url(VERSION_MANIFEST_URL))
                self.versions_meta = manifest["versions"]
                self.log("版本清单获取成功, 共 %d 个版本" % len(self.versions_meta))
                self.root.after(0, self.fill_version_combo)
            except Exception as e:
                self.log("获取版本清单失败: %s" % e)
        threading.Thread(target=work, daemon=True).start()

    def fill_version_combo(self):
        ids = []
        for v in self.versions_meta:
            if self.var_show_all.get() or v["type"] == "release":
                ids.append(v["id"])
        # 加入本地已安装的非原版版本 (NeoForge / Forge / Fabric 等加载器)
        vdir = os.path.join(GAME_DIR, "versions")
        if os.path.isdir(vdir):
            for name in os.listdir(vdir):
                jp = os.path.join(vdir, name, name + ".json")
                if os.path.exists(jp) and name not in ids:
                    ids.append(name)
        self.cmb_version["values"] = ids
        self.cmb_version2["values"] = ids
        last = self.settings.get("last_version")
        if last in ids:
            self.var_version.set(last)
        elif ids:
            self.var_version.set(ids[0])

    # ---------- 操作 ----------
    def current_version(self):
        v = self.var_version.get().strip()
        if not v:
            messagebox.showwarning(APP_NAME, "请先选择版本")
            return None
        return v

    def on_download(self):
        v = self.current_version()
        if not v:
            return
        self.save_settings()
        self.cancel_flag.clear()
        self.set_busy(True)

        def work():
            try:
                self.core.install_version(v)
            except Exception as e:
                self.log("安装失败: %s" % e)
            finally:
                self.log_queue.put(("done", None))
        self.worker = threading.Thread(target=work, daemon=True)
        self.worker.start()

    def on_cancel(self):
        self.cancel_flag.set()
        self.log("已请求取消, 等待当前下载结束 ...")

    def on_launch(self):
        v = self.current_version()
        if not v:
            return
        self.fly_plane()   # 纸飞机飞出屏幕的动效
        username = self.var_user.get().strip() or "Player"
        try:
            mem = int(self.var_mem.get())
        except ValueError:
            mem = 2048
        if not self.core.is_installed(v):
            if not messagebox.askyesno(APP_NAME, "版本 %s 尚未完整安装, 是否先下载?" % v):
                return
            self.on_download()
            return
        self.save_settings()
        # 优先从 jar 的 class 版本精确推断, 失败再用版本号估算
        req = self.core.required_java_from_jar(v) or required_java(v)
        self.log("%s 需要 Java %d+" % (v, req))

        if self.var_auto_java.get():
            # 自动模式: 优先用缓存的 Java 信息直接启动(快), 后台再刷新缓存
            self.cancel_flag.clear()
            self.set_busy(True)

            def work():
                try:
                    cands = {p: m for p, m in self.java_cache.items() if os.path.exists(p)}
                    if not cands:
                        self.log("首次运行, 扫描系统中的 Java ...")
                        cands = find_all_javas()
                        self.java_cache = cands
                        self.save_java_cache()
                    java = pick_java(cands, req)
                    if java is None:
                        target = req_to_download(req)
                        self.log("没有能运行 %s 的 Java (需要 Java %d), 自动下载 Java %d ..."
                                 % (v, req, target))
                        java = download_java(
                            target, log=self.log,
                            progress=lambda r, t: self.on_progress(r, t, "Java %d 下载" % target),
                            cancel_flag=self.cancel_flag)
                        if java is None:
                            self.log("已取消")
                            return
                        self.log("Java %d 安装完成: %s" % (target, java))
                        self.java_cache[java] = java_major_version(java)
                        self.save_java_cache()
                    self.log("自动选择 Java %s -> %s" % (self.java_cache.get(java) or cands.get(java) or "?", java))
                    self.root.after(0, lambda: self.var_java.set(java))
                    self.do_launch(v, username, java, mem,
                                   self.java_cache.get(java) or cands.get(java))
                    # 启动后后台刷新 Java 缓存, 下次启动依然秒开
                    def rescan():
                        try:
                            self.java_cache = find_all_javas()
                            self.save_java_cache()
                        except Exception:
                            pass
                    threading.Thread(target=rescan, daemon=True).start()
                except Exception as e:
                    self.log("启动失败: %s" % e)
                finally:
                    self.log_queue.put(("done", None))
            self.worker = threading.Thread(target=work, daemon=True)
            self.worker.start()
        else:
            # 手动模式: 用界面里填的 Java
            java = self.var_java.get().strip()
            if not java or not os.path.exists(java):
                messagebox.showerror(APP_NAME, "找不到 Java, 请手动选择 javaw.exe 或勾选自动选择")
                return
            jv = self.java_cache.get(java)
            if jv is None:
                jv = java_major_version(java)
                if jv:
                    self.java_cache[java] = jv
            if jv is not None and not java_suitable(jv, req):
                if not messagebox.askyesno(APP_NAME, "当前 Java %s 可能无法运行 %s (建议 Java %d), 仍要尝试?" % (jv, v, req)):
                    return
            self.do_launch(v, username, java, mem, jv)

    def do_launch(self, v, username, java, mem, java_major=None):
        self.log("正在启动 %s (用户: %s) ..." % (v, username))
        info = self.core.load_version_info(v)

        # 启动前自检: 客户端 jar / 依赖库 / natives 是否齐全 (网络证书错误常导致下载不完整)
        missing = []
        for _artifact, rel, _is_native in self.core.iter_libraries(info):
            if not os.path.exists(os.path.join(GAME_DIR, rel)):
                missing.append(rel)
        if not os.path.exists(os.path.join(GAME_DIR, "versions", v, v + ".jar")):
            missing.append("versions/%s/%s.jar" % (v, v))
        natives_dir = os.path.join(GAME_DIR, "versions", v, "natives")
        if not os.path.isdir(natives_dir) or not os.listdir(natives_dir):
            missing.append("versions/%s/natives/" % v)
        if missing:
            self.log("⚠ 启动前自检: 缺少 %d 个必要文件, 游戏很可能启动失败。" % len(missing))
            for rel in missing[:6]:
                self.log("   缺失: " + rel)
            if len(missing) > 6:
                self.log("   ... 其余 %d 个" % (len(missing) - 6))
            self.log("   请切到「下载」页重新点「下载 / 安装」补齐 (证书问题已修复)。")

        # 用 java.exe 而不是 javaw.exe: 同样不弹控制台, 但能捕获真实异常输出
        java_exe = java
        if java.lower().endswith("javaw.exe"):
            cand = os.path.join(os.path.dirname(java), "java.exe")
            if os.path.exists(cand):
                java_exe = cand
        cmd = self.core.build_launch_command(info, username, java_exe, mem, java_major)
        self.log("命令行: " + " ".join(cmd[:6]) + " ...")
        creation = subprocess.CREATE_NO_WINDOW if hasattr(subprocess, "CREATE_NO_WINDOW") else 0
        # 捕获游戏输出, 实时显示在下方日志区
        proc = subprocess.Popen(cmd, cwd=GAME_DIR, creationflags=creation,
                                stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                                stdin=subprocess.DEVNULL)
        self.log("=" * 25 + " 游戏日志 " + "=" * 25)

        def pump():
            try:
                for line in iter(proc.stdout.readline, b""):
                    text = line.decode("utf-8", "replace").rstrip()
                    if text:
                        self.log(text)
            except Exception:
                pass
            finally:
                try:
                    proc.stdout.close()
                except Exception:
                    pass
                code = proc.wait()
                if code != 0:
                    self.log("⚠ 游戏异常退出 (代码 %s), 上面最后几行通常就是真实原因。" % code)
                else:
                    self.log("=" * 20 + " 游戏已退出 (代码 0) " + "=" * 20)

        threading.Thread(target=pump, daemon=True).start()
        self.log("游戏进程已启动(PID %d), 日志实时显示在下方。" % proc.pid)


def main():
    root = tk.Tk()
    root.withdraw()  # 主窗口先藏起来, 播放启动加载动画

    size = 300
    splash = tk.Toplevel(root, bg=LOADER_BG)
    splash.overrideredirect(True)
    splash.attributes("-topmost", True)
    sw, sh = splash.winfo_screenwidth(), splash.winfo_screenheight()
    splash.geometry("%dx%d+%d+%d" % (size, int(size * 1.06),
                                     (sw - size) // 2, int((sh - size) * 0.42)))
    loader = McopLoader(splash, size=size)
    loader.pack(fill="both", expand=True)
    tk.Label(splash, text="正在启动 ...", bg=LOADER_BG, fg="#7a6490",
             font=("Microsoft YaHei UI", 9)).pack(pady=(0, 12))
    loader.start()

    LauncherGUI(root)

    def finish_splash():
        loader.stop()
        splash.destroy()
        root.deiconify()
    root.after(2100, finish_splash)
    root.mainloop()


if __name__ == "__main__":
    main()
